前言
单例模式有6种写法,一直不太理解为什么DCL单例要加上volatile,昨晚看JVM中的内存模型那章时,突然有了灵感,记录一下。
普通单例
最简单的单例:
public class Singleton {
private static Singleton INSTANCE = new Singleton();
public static Singleton getInstancec() {
return INSTANCE;
}
}
单例模式就是获取一个类的实例,希望不管获取多少次都能获取到唯一的这个实例。以上这种写法是最简单的,但会有一个问题:如果 new Singleton()这个操作是一个业务操作,执行时间很长或者我们不希望他在初始化就执行,怎么办?就出现了第二种饿汉式的单例写法
饿汉式单例
public class Singleton {
private static Singleton INSTANCE = null;
public static Singleton getInstancec() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
饿汉式会值在需要的时候才会去加载实例,但还是有很大的问题:多线程环境下会出现创建多个实例的场景,而且这种场景很容易产生。因为在并发环境下多个线程是可以同时进入if语句块的,每一个进入的线程都会重新创造一个新的实例。所以就有了线程同步的单例。
线程同步单例
public class Singleton {
private static Singleton INSTANCE = null;
public synchronized static Singleton getInstancec() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
直接在方法上加上synchroized锁,任何线程得先拿到锁才能执行内容,锁释放后就会把实例写入主存,其他线程得到锁进入方法后,此时实例已经给被初始化,其他线程拿到的也就是被实例化的值。
不过使用synchronized直接锁方法的形式未免过于粗暴,锁优化中有一个叫细粒度,这个锁太粗了,因为我们并不是所有的地方都需要上锁。可以尝试只锁住实例化部分的代码块。
普通检查锁
public class Singleton {
private static Singleton INSTANCE = null;
public static Singleton getInstancec() {
if (INSTANCE == null) {
synchroized (Singleton.class) {
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
}
可是这个真的能锁得住么?
仔细想想,此时有两个线程同时进入了代码块,他们还是可以执行自己的new Singleton(),所以如果只是锁实例化部分是一种错误写法,于是有了双重检查锁写法(DCL,Double Check Lock)。
双重检查锁(DCL)
public class Singleton {
private static Singleton INSTANCE = null;
public static Singleton getInstancec() {
if (INSTANCE == null) {
synchroized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
如果此时两个线程同时进入代码块,T1获取到了对象锁,T2只能干等着,T1发现没有被实例化过,所以实例化了一遍,然后T1释放锁,并把数据写回到主存中。T1释放后T2就可以获取到锁了,然后进入了同步块,去主存中看实例是否被初始化过,发现已经初始化了,所以返回这个实例。
至此为止,单例的写法就完成了,但是一般会在INSTANCE中加上一个volatile,虽然不加,在实际运行产生问题的概率也是很低的,但是也是有这种情况存在,接下来便是本文的重点,也是我这2天的研究重点。
DCL是否要加volatile?
先说结论,必须要加!!!
终极写法如下:
public class Singleton {
private volatile static Singleton INSTANCE = null;
public static Singleton getInstancec() {
if (INSTANCE == null) {
synchroized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
为了探究volatile正真的作用,我得从底层字节码开始说起。
指令重排序
new Singleton()时会触发类加载,因为会被编译成new指令,我上一篇文章提到过类加载的时机。
指令重排序是CPU的指令的一种优化手段,CPU的运行速度比内快100多倍,如果有条指令需要从内存中读数据,那么如果等这条指令执行完毕后再执行后续指令,那么CPU就得等99个时钟周期。为了省时间,CPU让后面的指令先行,只保证最终结果的一致性就行,所以指令的顺序可能会发生变化。
还有一种可能性就是两个毫无关联的代码,也存在并行优化的可能性,最终执行的顺序谁也说不准。
我为什么要说这个点呢,因为new一个对象的是要分很多步的,而且也没什么关联,所以就会有出现重排序的可能性。
我们从一个类的创建过程开始分析
创建一个对象
简单代码如下:
@AllArgsConstructor
public class Test {
private int i;
public static void main(String[] args) {
Test test = new Test(8);
System.out.println(test);
}
}
@AllArgsConstructor是lomnok中全参构造方法的写法,我为了省空间让大家看的清楚所以这样写。
字节码如下:
0: new #2 // 在堆中创建一个Test类,引用入栈
3: dup // 复制一份栈顶元素
4: bipush 8 // 将8入栈
6: invokespecial #3 // 调用Test的构造函数,此时会将堆顶的8传过去
9: astore_1 // 将堆顶元素放到变量槽1中,此时堆顶还有一个Test
10: getstatic #3 // 获取System.out
13: aload_1 // 读取变量槽1中的内容,也就是test变量
14: invokevirtual #4 // 打印test
10: return
new和dup一定是发生在后面两条指令的前面,如果堆中实例都没出来那么你构造方法或者赋值就没有意义,所以 发生重排序也只可能在invokespecial和astore_1上。
正常的执行顺序
根据字节码我们可以简化思路为,new -> invokespecial -> astore_1
new指令会执行对象的类加载,具体就是在堆中给类分配内存、初始化等,这个初始化并非是构造参数的初始化,而是正真意义上的初始化,比如int i就会在内存中初始化为0,这个状态我们可以称为"半初始化"。
半初始化之后,会执行invokespecial,调用类的构造方法实现正真的初始化。
然后通过astore_1建立引用,比如上文代码就是和局部引用test建立连接。
画了一份PPT动画,正常情况下对象的创建过程如图:
指令重排序后
如果发生了指令重排序,那么执行顺序就会变成这样: new -> astore_1 -> invokespecial
类半初始化后就和局部引用建立了连接,然后初始化后再返回全初始化的类。
这一步其实是没什么问题的,因为即便是半初始化就建立了连接,但是最终的类还是完全初始化的。
但可以思考这种情景:
- 两个线程同时调用getInstance()方法
- 线程A一直执行到了new Sigletion(),线程B还没有开始
- 线程A调用了new Sigletion(),此时发生了指令重排序。
- 线程A此时会先执行new指令类加载,然后再INSTANCE引用建立了连接,此时的INSTANCE是半初始化状态。
- 这个时候,线程A的时间片用完了,线程B开始进入getInstance()方法。
- 线程B发现INSTANCE并不为空,然后返回了此时的INSTANCE。
我省略了一步dup,可以看一下我做的动画,线程B此时返回的是dup创建的那个类,而最终的类,从结果上来看会完全初始化,但是这个完全初始化的类会在操作数栈里随着栈帧的销毁而销毁。
就这样线程B返回了一个半初始化的类。
volatile语义
java中的volatile有两个语义
一者就是保证共享对象对其他对象的可见性,或者说缓存一致性,当这个变量被修改写入主存时,其他缓存过该变量的缓存都会失效,会重新从主存中读最新的值,保证了一致性。
二者就是禁止指令重排序,volatile底层是用到了lock指令,lock指令上下的代码是不能重排序的,因为有一层内存屏障。
给INSTANCE加上volatile是为了防止其在初始化完成一半的时候完成了赋值
单核CPU是不会产生指令重排的,多核条件下,指令重排序是一种对并行代码的优化手段。
后记
DCL单例即便不加volatile也很难重现那个半初始化状态,但是高并发的系统特别是电商之类,比如淘宝京东,并发量上去的时候,出问题的可能性是存在并且很大的,如果真线上出问题,那么走人可能是最轻的惩罚了=_=
总结一下,单例虽然花里胡哨有很多推导的写法,但是用到的就两种,即普通单例和DCL单例。普通单例简单粗暴而且不怎么会犯错,DCL单例需要花费一点功夫去理解volatile的作用,但也不难。
我记得《effect java》中建议使用枚举的写法,但我不太适应这种只有一个INSTANCE还用枚举。
(之前写的有点错误的地方,订正了一下。可惜浪费了我画的PPT动画,还画了挺多时间的==。)
参考
- 《深入理解Java虚拟机-JVM高级特性与最佳实践》周志明