volatile 修饰符在双检锁单例模式中的作用

在实现一个双检锁单例的时候,IDEA 提示我要给INSTANCE实例加上volatile修饰符。当时并不明白为啥,所以选择相信 IDE。但是还是那句话,不能知其然不知其所以然啊,自己写的代码,不能自己心里没底不是。于是乎我一顿网上冲浪,终于整明白了为啥双检单例必须要用volatile修饰符。

代码示例

这个单例类没什么好说的,就是一个平平无奇的双检锁单例实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Singleton {
private static Singleton INSTANCE;

public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
}
}

return INSTANCE;
}

public void doSomething() {
// Do something here
}
}

而 IDEA 在外层的if上标了一个警告,并且建议我给INSTANCE变量加上volatile修饰符。

如果不加volatile会有什么问题

上面的代码,乍一看非常严谨,在发现INSTANCEnull的时候,就对其加锁并再检查一次,还是null的话就为它创建一个新的实例,最后返回它。但是看了一些文章之后发现,在多线程场景下,有可能出现虽然成功获取到INSTANCE,但在调用其中的方法时仍然抛出空指针异常的诡异情况。

比如有这样一个场景,Thread 1Thread 2同时请求了Singleton#getInstance()方法,Thread 1执行到了第 8 行,开始实例化这个对象;而Thread 2执行到了第 5 行,开始检查INSTANCE是否为null。这个时候,有一定几率,虽然Thread 2检查到INSTANCE并不是null,但是调用Singleton#doSomething()方法的时候却会抛出空指针异常。

造成这个问题的原因就是 Java 的指令重排。

在搞清楚Thread 2看到INSTANCE虽然不是null,却在方法调用的时候会抛空指针异常的原因之前,先要搞清楚实例化对象的时候,JVM 到底干了什么。

JVM 实例化一个对象的过程,大致可以分为这几步:

  1. JVM 为这个对象分配一片内存
  2. 在这片内存上初始化这个对象
  3. 将这片内存的地址赋值给INSTANCE变量

因为把内存地址赋值给INSTANCE是最后一步,所以Thread 1在这一步执行之前,Thread 2INSTANCE == null的判断一定为true,进而因为拿不到Singleton类的锁而被阻塞,直到Thread 1完成对INSTANCE变量的实例化。

但是,上面这三步它不是个原子操作,并且 JVM 可能会进行重排序,也就是说上面这三步可能被重排成

  1. JVM 为这个对象分配一片内存
  2. 将这片内存的地址赋值给INSTANCE变量
  3. 在这片内存上初始化这个对象

你看,这问题就来了,如果在Thread 1做完第二步但没做第三步的时候,Thread 2开始检查INSTANCE是不是null就会得到false,然后就走到return,得到一个不完整的INSTANCE对象。这时候,虽然INSTANCE不是null,但同时它也没有完成初始化,所以Thread 2在调用Singleton#doSomething()方法的时候,就会抛出空指针异常。

这个问题的解决方案就是volatile修饰符,因为它可以禁止指令重排,所以在给INSTANCE加上volatile之后,JVM 就会老老实实的先初始化好这个对象,再为INSTANCE赋值,这样多线程场景下每个线程得到的INSTANCE实例都会是一个初始化好了的Singleton对象。