对于单例的双重检锁,为何要对变量加上 volatile 修饰关键字?
Prerequisite:对象创建的过程
要理解一个对象的创建过程,需要从运行时数据区进行分析,首先需要对JVM运行时数据区布局有深入的理解,同时掌握类加载过程中各个阶段的行为。详见后续的《从JVM角度分析 new 一个对象的详细过程》。
本文无需深入分析这两个主题,只需要了解创建对象的总体流程即可。
核心:非原子操作、指令重排序
private static volatile Singleton instance;
public static Singleton getInstance() {
if(instance == null) {
synchronized(Singleton.class) {
if(instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
目的
避免重排序问题导致其他线程看到一个已经分配内存和地址,但是没有初始化的对象(对象还处于不可用状态),就被其他线程引用了(报异常)。
创建对象的指令重排序分析
下面代码在多线程环境下不是原子操作
instance = new Singleton();
这条指令坑被重排序,分如下两种可能的指令排序场景。
场景1:不重排序(正常步骤)
正常的底层执行顺序会分3步走:
1、给 instance 分配内存
2、调用实例 instance 的构造函数来初始化成员变量
3、将 instance 这个在栈中的引用,指向在步骤1和2中打包好的对象
无论线程A当前执行到1、2、3哪一步,对于线程B,可能看到的 instance 的状态只有两种:null 和 非 null。
步骤 1 和 2 中的 instance 对象都是 null 的,第3步看到的是非 null,对于正常顺序来说,这是没问题的。
场景2:重排序
如果线程A 在重排序的情况下,可能会变成 1,3,2,假如线程A执行到第二步“3”时,instance 虽然已经不是 null,但还没初始化,不可用。
此时CPU时间片切换,从线程A 切换到线程B,线程B来调用 double check 这个 getInstance 单例方法,那么第一个 null check 时,看到的 instance 引用由于已经被线程 A 指向了内存块,不为 null,则直接返回这个instance。
但是,当使用这个对象的某个字段时,由于还没被初始化,处于不可用状态,会导致异常发生。
解决方案:使用 volatile 修饰变量,禁止指令重排序
volatile 的原理
使用 volatile 修饰成员变量,那么在变量赋值时,会有一个内存屏障,也就是说只有执行完123步操作之后,其他线程读取操作时才能看到 instance 这个变量的值,不会造成误判,解决了对象状态不完整的问题。
同时,volatile 会强制将缓存中修改的数据刷新到主内存中,确保对其他线程的可见性。
此时,invalidate 其他CPU的缓存行,当其他CPU需要使用这个缓存行的变量时,就会去重新到主内存读取,保证数据是最新的。