本文借鉴于”程序员小灰”公众号内文章,文章地址如下:
关于单例模式有很多种实现方式,这些大家都是很熟悉了,各种博客也是写了N中方法,这些博客都看了一圈,但是很多就是一样的,有的标题是九种,其实还是那么核心的几种变来变去,还有把懒汉模式的安全和非安全分为两种,还有更厉害的是把懒汉基本写法、双重检查机制、双重检查机制+volatile三种分开,认为是三种写法,这是懒汉模式从非安全到安全的演变,完全就是一种模式,感觉完全都是在凑字数(如果这样凑,我估计能写出十几种,甚至更多)。总结一下其实只有四种,再怎么变都是离不开这四种写法。
1. 饿汉模式
饿汉模式很简单,就一种写法,本身也是线程安全的。
写法如下:
public class HungrySingleton {
private final static HungrySingleton singleton = new HungrySingleton();
private HungrySingleton(){}
public static HungrySingleton getInstance() {
return singleton;
}
}
这种写法本身就是安全的原因是在类的初始化的时候就会自动创建单例对象,并非在使用的时候去创建。在Spring的IOC容器实现中也是这样,在Spring容器加载的时候就将需要创建的对象创建好放在容器中提供使用。
2. 懒汉模式
懒汉模式存在线程安全问题,如何解决安全的问题是面试单例模式必须会问的,而且根据原始写法慢慢演变到安全写法解释其中原因。先看一下最终版安全写法!
写法如下:
public class LazySingleton {
private volatile static LazySingleton singleton = null;//volatile保证可见性
private LazySingleton() {
}
public static LazySingleton getInstance() { //双重检测机制
if (singleton == null) {
synchronized (LazySingleton.class) {
if (singleton == null) {
singleton = new LazySingleton();
}
return singleton;
}
}
return singleton;
}
}
原始的写法,只有一层判断和synchronized
锁,后期演变成双层判断和synchronized
锁(双重检查机制),最后演变成双层判断、synchronized
和volatile
结合,达到最终版线程安全模式。
2.1 一层判断和synchronized
锁不安全的原因
此种情况代码如下:
public class LazySingleton {
private static LazySingleton singleton = null;
private LazySingleton() {
}
public static LazySingleton getInstance() {
if (singleton == null) {
//A位置
synchronized (LazySingleton.class) {
singleton = new LazySingleton();
}
}
return singleton;
}
}
- 两个线程分别是ThreadA和ThreadB
- ThreadA线程运行到A位置,CPU切换到ThreadB线程执行
- ThreadB运行到A位置,并获取锁,创建单例,ThreadB释放锁,并获取了返回的单例
- ThreadA获取锁,此时又执行了一次创建单例
- ThreadA释放锁,并获取了返回的单例
从整个过程看就可以知道,在并发的时候可能会出现对象被多次创建的问题,从而导致线程非安全。
2.2 双层判断和synchronized
锁不安全的原因
此种情况代码如下:
public class LazySingleton {
private static LazySingleton singleton = null;
private LazySingleton() {}
public static LazySingleton getInstance() {
if (singleton == null) {
synchronized (LazySingleton.class) {
if (singleton == null) {
singleton = new LazySingleton();
}
}
}
return singleton;
}
}
这里是涉及到对创建对象过程指令运行理解和指令重排的问题。
创建对象指令正常顺序:
- 开辟内存空间
- 初始化对象
- 对变量赋值
创建对象对应的指令分为三步,如上所示,指令1是不会变得,始终在第一位,但是2、3指令可能会根据JVM的自身优化发生顺序的变化。这个时候就会存在问题。看下图:
从图中的执行流程可以看出来,如果出现这种情况的话,线程B就会拿到一个初始化未完成的对象,线程B在使用此对象的时候就会出现异常。虽然这种情况已经是很极端的情况下才会出现,但是为了保证安全,还是需要做调整。如何调整?最终版中已经给出了答案,在单例对象上加上volatile
修饰符。为什么加上它就可以了?
volatile
保证可见性还是得益于Java本身的先行发生原则(happens-before),也就是如果一件事情发生在另一个事情之前,结果必须反映。听着挺空的、不好理解。就拿上面的例子来看,线程A先于线程B发生,线程B需要的数据又在线程A中发生修改,那么线程B必然会得到线程A对该数据操作的结果,也就是”结果必须反映”。
先行发生原则(happens-before)是目的,而真正实现就涉及到另一个知识点内存屏障,具体内存屏障是什么?这里不多说了,有想了解的可以看一下文章开头注明的参考文章,里面说的很详细。
另外还要强调一下volatile
使用场景。这里可能会给人一种错觉,那就是volatile
可以防止并发安全问题,其实不是,在整个操作过程,对象只会被创建一次,不安全的原因是其他线程拿到了初始化未完成的单例对象。所以这里还是要强调一下volatile
的使用场景。
- 运行结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束
3. 静态内部类模式
饿汉模式和懒汉模式是常用的两种模式,也是在日常开发面试中涉及到最多的,尤其是懒汉模式线程安全的考量。另外其实还有实现方式,其中静态内部类的模式就是其中比较优雅的一种。
代码实现如下:
public class StaticSingleton {
private StaticSingleton() {}
private static class InnerInit {
private static StaticSingleton singleton = new StaticSingleton();//在静态内部中创建单例
}
public static StaticSingleton getInstance() {
return InnerInit.singleton;
}
}
这个实现和饿汉模式有点像,都是在类初始化的时候就将单例创建好,等待被使用。有的人在介绍缺点的时候,会认为静态内部类模式和饿汉模式这种提前初始化的方式是缺点之一,因为浪费资源、让启动时间变长。但是就个人认为启动时间长,一个类的加载能浪费多长时间,再说浪费资源,就算在启动的时候不去初始化,也要在使用的时候初始化,只是先后的问题。另外这种加载方式解决了线程安全问题,也避免了第一次访问加载慢的问题(第一次惩罚)。
4. 枚举模式
这种模式大家一直在用,有时候我们并没有注意它就是单例模式的一种,比如订单的几种状态枚举、设备的运行状态等。虽然说对于这个类来说,有几种状态就有几个实例,但是就单个状态来说,它就是单例的,而且是线程安全的。
代码实现如下:
public enum EnumSingleton {
INSTANCE
}
实现是如此的简单,其他的就不多做介绍啦。
5. 单例模式的不安全分析
就上面的几种模式来说,除了枚举,其他的几种都是表面上的安全,其实都是可以通过反射的手段生成实例的。因为反射可以获取私有的构造方法,并通过构造方法来构建实例。下面来看一下懒汉模式的反射实现代码:
//单例获取
LazySingleton instance = LazySingleton.getInstance();
System.out.println("直接获取的Instance:" + instance);
//反射创建
Constructor<LazySingleton> constructor = LazySingleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
LazySingleton reflectInstance = constructor.newInstance();
System.out.println("反射后的Instance:" + reflectInstance);
这两种方式获取单例后,最终打印出来的结果是不一样的,也就是单例模式被破。对于懒汉模式是这样的,饿汉模式、静态内部类模式都是这样,反射使用的代码也是没有变化的。那为什么说枚举是可以的呢?枚举的反射代码和上面的反射过程是一样,就不单独贴出了,但是在执行的时候会抛出NoSuchMethodException
异常信息。信息如下:
Exception in thread "main" java.lang.NoSuchMethodException: com.itcrud.single.EnumSingleton.<init>()
at java.lang.Class.getConstructor0(Class.java:3082)
at java.lang.Class.getDeclaredConstructor(Class.java:2178)
at com.itcrud.single.MainTest.enumSingleton(MainTest.java:59)
at com.itcrud.single.MainTest.main(MainTest.java:15)
从而可以看出来枚举是不可被反射创建对象的。这就是为什么日常使用的时候都是使用枚举的原因,但是这个原因大多数人估计都是不知道的。
6. 源代码
码云(gitee):https://gitee.com/itcrud/itcrud-note/tree/master/itcrud-note-1-5