曾经面试的时候,在说到单例模式的时候,我还是含含糊糊的,我觉得自己懂,但是又在某个结点被卡死了,所以决定写篇文章来说说单例模式
什么是单例模式?
单例模式: 一个类有且仅有一个实例,并且自行实例化向整个系统提供
那我们为什么需要单例模式呢?
为了节约JVM的资源,每创建一个对象,都需要在堆中分配内存,内存是有限的,更何况在某些情况下我们并不是都需要那么多的对象,比如在一个Controller的某个方法中多次用SimpleDateFormat类格式化日期,你就不需要多次的创建SimpleDateFormat类的对象,SimpleDateFormat的applyPattern()方法就可以设置格式化的形式。
单例模式之饿汉式
public class SingleTon{
private static SingleTon singleton = new SingleTon();
private SingleTon(){
}
public static SingleTon getInstance(){
return singleton;
}}
为什么称这种为饿汉式呢? 因为是一上来就创建对象,而不是等到需要了再创建对象。那么这种形式的单例模式是如何保证是单例的呢? 这是因为静态变量的特性。静态变量是什么时候被初始化的呢? 这就需要谈到类的生命周期了,什么是类的生命周期呢? 当你在使用一个类的静态方法或者静态变量、创建对象时,相应类的字节码就会被加载进内存中。
类的生命周期
- 类的加载
查找并加载字节码 -
连接
确定类与类之间的关系:- 验证
正确性校验 - 准备
static静态变量分配内存,赋初始化默认值
在准备阶段,会把num=0,之后再将0修改为10
在准备阶段,JVM中只有类,没有对象 - 解析
把类中的符号引用,转为直接引用
在解析阶段,JVM就可以将全类名映射成实际的内存地址,就用内存地址代替全类名
- 验证
-
初始化
给静态变量赋予正确的值。 显式赋值
- 使用: 对象的初始化时、对象的垃圾回收,对象的销毁
-
卸载
JVM结束类生命周期的时机: - 正常结束 - 异常结束/错误 - System.exit(); - 操作系统异常
其中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的"开始"(仅仅指的是开始,而非执行或者结束,因为这些阶段通常是互相交叉着混合进行。(这里是引自深入理解Java虚拟机(第2版),这里还是有点不大理解为什么要互相交叉着混合进行的)。
我们注意到在类的初始化阶段,对类的静态变量进行显式赋值。
什么时候能够触发类的初始化呢?
对于初始化阶段虚拟机是严格规定了如下几种情况,如果类未初始化会对类进行初始化。(言下之意就是说类只会初始化一次)
- 创建类的实例对应字节码中的new指令
- 访问类的静态变量(除常量【被final修饰的静态变量】,常量在编译期被确定),对应的字节码指令为 getstatic
- 访问类的静态方法invokestatic
- 反射
- 当初始化一个类时,发现其父类还未初始化,则先触发父类的初始化
- 虚拟机启动时,定义了main方法的那个类先初始化.
(这里我还有个小问题,JVM咋知道类有没有被初始化呢?是通过什么来判定类有没有初始化呢?)
既然类只会初始化一次,也就意味着静态变量只被显式的初始化一次。
总结: 这种饿汉式是的"单例性"是通过静态变量只显式的初始化一次来保证的
单例模式之简单懒汉式(线程不安全的版本)
public class SimpleSingleTon {
private static SimpleSingleTon singleton = null;
private SimpleSingleTon(){
}
public static SimpleSingleTon getInstance(){
if (singleton == null){
singleton = new SimpleSingleTon();
}
return singleton;
}
}
这种创建单例模式的缺点在于在多线程下它是不安全的,
为什么呢? 不少文章,在说到这里的时候,都会说到如果有两个线程同时调用getInstance()方法,我个人认为这个说法是有些问题的,我们知道CPU会给线程分配时间片,在同一时刻只会有一个线程拿到CPU的执行权,说同时的话,我个人感觉像是有两个线程同时获得了CPU的执行权一样,我认为这样的说法并不合适。
这种单例模式的不安全性在于: 假设有两个线程A和B, A在执行到判空语句的时候,时间片耗尽,B获得CPU的执行权,也执行到判空语句,发现singleton仍然是null,接在向下执行返回了一个对象,此时CPU的执行权又回到了A线程手上,A线程此时仍然记得singleton是空的,它也new了一个对象出来。两个线程就返回了两个不同的对象。
我们来测试一下:
public static void main(String[] args) {
// 这里偷个懒,使用内置的线程池
ExecutorService pool = Executors.newFixedThreadPool(100);
for (int i = 0; i < 10000 ; i++) {
pool.execute(()->System.out.println(SimpleSingleTon.getInstance()));
}
pool.shutdown();
}
得加大并发量才能测试出来,我也是测了三次才测出来:
不安全是吧! 我加锁,synchronized走起来
只需要在方法上加上synchronized即可保证,但是锁的范围越大,性能越不好,至于为什么? 会单独在开一个章节来介绍synchronized
那么我们就加在代码块上。
public class SimpleSingleTon {
private static SimpleSingleTon singleton = null;
private SimpleSingleTon(){
}
public static SimpleSingleTon getInstance(){
if (singleton == null){
synchronized(SimpleSingleTon.class){
// 这里让线程睡眠的原因是: 不睡眠,跑出来的全是一个实例。
try {
Thread.currentThread().sleep(100);
}catch (Exception e ){
e.printStackTrace();
}
singleton = new SimpleSingleTon();
}
}
return singleton;
}
public static void main(String[] args){
new Thread(()-> System.out.println(SimpleSingleTon.getInstance())).start();
new Thread(()-> System.out.println(SimpleSingleTon.getInstance())).start();
new Thread(()-> System.out.println(SimpleSingleTon.getInstance())).start();
new Thread(()-> System.out.println(SimpleSingleTon.getInstance())).start();
}
}
我们来分析一下原因: 假设有两个线程A和B, A在执行到判空语句的时候,时间片耗尽,B获得CPU的执行权,也执行到判空语句,发现singleton仍然是null,接在向下执行返回了一个对象,此时CPU的执行权又回到了A线程手上,A线程此时仍然记得singleton是空的,它也new了一个对象出来。两个线程就返回了两个不同的对象。
那就在进入锁之后再加一个判空呗。
但是还是有一个指令重排序的问题.
什么叫指令重排序?
简单的说,我们编写的java代码最终还是要解释成CPU的指令的,但是为了加速指令的执行,CPU可能并不是按照顺序来执行指令。
这是一个比较深奥的问题,值得反复的探讨,new对象对应指令大概是三个: 1. 首先是给对象分配内存空间 2. 初始化对象 3. 将内存的地址空间赋给引用.
CPU真正执行指令的时候可能是1-2-3,也可能是1-3-2。 1-2-3当然是没问题的, 1-3-2就会获得一个不完整的实例。这样你就会获得一个不完整的实例。你在使用的时候就有可能有问题。
那么该怎么测出来呢? 我们需要一个初始化比较复杂的对象,反正我是没测出来。
为了防止1-3-2的出现,我们就需要加上Volatile变量来禁止重排序。
public class SimpleSingleTon {
private static volatile SimpleSingleTon singleton = null;
private SimpleSingleTon(){
}
public static SimpleSingleTon getInstance(){
if (singleton == null){
synchronized(SimpleSingleTon.class){
try {
Thread.currentThread().sleep(100);
}catch (Exception e ){
e.printStackTrace();
}
singleton = new SimpleSingleTon();
}
}
return singleton;
}
}
其实Volatile还可以单独拿出来再讲一讲。这是后话了.
静态内部类的懒汉式
public class SimpleSingleTon {
private SimpleSingleTon(){
}
private static class LazySingleTon{
private static final SimpleSingleTon INSTANCE = new SimpleSingleTon();
}
public static SimpleSingleTon getInstance(){
return LazySingleTon.INSTANCE;
}
}
那么这种写法是如何来保证获取的实例是同一个的呢?
在前文我们说过,在调用类的静态触发类的初始化,也是同样的道理. 类只初始化一次.
枚举入门
这里为什么要讲枚举呢? 因为枚举实现的单例模式最为简单,最为极致,大道至简。
枚举是什么?
个人认为枚举是一种常量的容器,有人可能会问了,你为什么不把常量放在一个集合里呢? 因为这些常量要被全局的类所使用。
那么在Java中它是什么呢?
我们新建一个枚举,然后用javap -c命令反汇编它的字节码看看:
public enum EnumDemo {
HIGH,
LOW;
}
注意到我们的枚举在变成字节码之后,变成了类,或者说它本身就是类。继承自Enum类
我们写的HIGH和LOW前面都加上了static和final, 我们在枚举中写变量名,事实上JVM帮我们写了这么多。
你写的HIGH就是枚举类型的一个实例。
g)
注意我们写的变量JVM帮我们加上了static,后面又帮助我们初始化。
枚举的单例模式也是利用静态变量只加载一次的原理来保证实例的唯一性的。
怎么用? 以及特性?
从类的角度来说, 你是一个类,已经继承了Enum类,Java是只支持单重继承的,所以不可以在继承别的类。
但是你可以实现接口,但是我不建议你这么做.
可以实现接口
public enum EnumDemo implements Runnable{
/**
*
*/
HIGH,
LOW;
@Override
public void run() {
}
}
可以定义成员变量和构造函数、方法
研究了一下, 还是没看到字节码中哪个指令是调用父类构造函数的。但是它确实调用了。
还是上面的枚举代码,但是没有实现Runnable接口。
我们知道在初始化子类的对象时,一定会先调用父类的构造函数,既然所有的枚举类型都是Enum类的子类,我们就Enum的构造函数处打一个断点:
然后这个类过去之后,
将我们写的变量名就传了进来。
定义成员变量
不加声明的成员变量事实上枚举类的实例,你加个括号就调你自定义的构造函数
public enum EnumDemo{
/**
*
*/
HIGH("星期一", 1),
LOW("2", 3);
String key;
int value;
EnumDemo(String key, int value) {
this.key = key;
this.value = value;
}
}
值得拿出来说说方法:
- toString被重写:
打印枚举的实例(就是你写在枚举类中未加声明类型的变量),打印的是它的名字.
- 如何遍历:
我们写枚举的时候,编译器会自动帮我们加一个静态方法values().
这个方法能够返回你在枚举中定义的常量。
总结,基本上大多数单例模式都是借助静态变量只初始化一次来保证单例的。
单例模式大致上有以下几种写法:
1. 饿汉式: 一上来就new
2. 饱汉式(线程不安全版): 需要用的时候再new
3. 饱汉式线程安全版.
4. 静态内部类
5. 枚举
主要参考:
微信公众号java3y 单例模式 https://mp.weixin.qq.com/s/dU...
深入理解Java虚拟机(第2版)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。