volatile 修饰符在双检锁单例模式中的作用
在实现一个双检锁单例的时候,IDEA 提示我要给INSTANCE
实例加上volatile
修饰符。当时并不明白为啥,所以选择相信 IDE。但是还是那句话,不能知其然不知其所以然啊,自己写的代码,不能自己心里没底不是。于是乎我一顿网上冲浪,终于整明白了为啥双检单例必须要用volatile
修饰符。
代码示例
这个单例类没什么好说的,就是一个平平无奇的双检锁单例实现。
1 | public class Singleton { |
而 IDEA 在外层的if
上标了一个警告,并且建议我给INSTANCE
变量加上volatile
修饰符。
如果不加volatile
会有什么问题
上面的代码,乍一看非常严谨,在发现INSTANCE
是null
的时候,就对其加锁并再检查一次,还是null
的话就为它创建一个新的实例,最后返回它。但是看了一些文章之后发现,在多线程场景下,有可能出现虽然成功获取到INSTANCE
,但在调用其中的方法时仍然抛出空指针异常的诡异情况。
比如有这样一个场景,Thread 1
和Thread 2
同时请求了Singleton#getInstance()
方法,Thread 1
执行到了第 8 行,开始实例化这个对象;而Thread 2
执行到了第 5 行,开始检查INSTANCE
是否为null
。这个时候,有一定几率,虽然Thread 2
检查到INSTANCE
并不是null
,但是调用Singleton#doSomething()
方法的时候却会抛出空指针异常。
造成这个问题的原因就是 Java 的指令重排。
在搞清楚Thread 2
看到INSTANCE
虽然不是null
,却在方法调用的时候会抛空指针异常的原因之前,先要搞清楚实例化对象的时候,JVM 到底干了什么。
JVM 实例化一个对象的过程,大致可以分为这几步:
- JVM 为这个对象分配一片内存
- 在这片内存上初始化这个对象
- 将这片内存的地址赋值给
INSTANCE
变量
因为把内存地址赋值给INSTANCE
是最后一步,所以Thread 1
在这一步执行之前,Thread 2
对INSTANCE == null
的判断一定为true
,进而因为拿不到Singleton
类的锁而被阻塞,直到Thread 1
完成对INSTANCE
变量的实例化。
但是,上面这三步它不是个原子操作,并且 JVM 可能会进行重排序,也就是说上面这三步可能被重排成
- JVM 为这个对象分配一片内存
- 将这片内存的地址赋值给
INSTANCE
变量 - 在这片内存上初始化这个对象
你看,这问题就来了,如果在Thread 1
做完第二步但没做第三步的时候,Thread 2
开始检查INSTANCE
是不是null
就会得到false
,然后就走到return
,得到一个不完整的INSTANCE
对象。这时候,虽然INSTANCE
不是null
,但同时它也没有完成初始化,所以Thread 2
在调用Singleton#doSomething()
方法的时候,就会抛出空指针异常。
这个问题的解决方案就是volatile
修饰符,因为它可以禁止指令重排,所以在给INSTANCE
加上volatile
之后,JVM 就会老老实实的先初始化好这个对象,再为INSTANCE
赋值,这样多线程场景下每个线程得到的INSTANCE
实例都会是一个初始化好了的Singleton
对象。