单例模式–双重检验锁真的线程安全吗
单例模式是我们最熟悉不过的一种设计模式,用来保证内存中只有一个对象的实例。虽然容易,但里面的坑也有很多,比如双重检验锁模式 (double checked locking pattern) 真的是线程安全的吗?
起因
在对项目进行 PMD 静态代码检测时,遇到了这样一个问题
Partially created objects can be returned by the Double Checked Locking pattern when used in Java. An optimizing JRE may assign a reference to the baz variable before it calls the constructor of the object the reference points to.
Note: With Java 5, you can make Double checked locking work, if you declare the variable to be volatile.
大概意思是,使用双重检验锁模式,可能会返回一个部分初始化的对象。可能大家有些疑虑,什么是部分初始化的对象,我们下面继续分析
什么是双重检验锁模式
1 | public static Singleton getSingleton() { |
我们看到,在同步代码块的内部和外部都判断了 instance == null,这时因为,可能会有多个线程同时进入到同步代码块外的 if 判断中,如果在同步代码块内部不进行判空的话,可能会初始化多个实例。
问题所在
这种写法看似完美无缺,但它却是有问题的,或者说它并不担保一定完美无缺。主要原因在于 instance = new Singleton(); 并不是原子性的操作。
创建一个对象可以分为三部:
1 | 1.分配对象的内存空间 |
但是,2、3 步之间,可能会被重排序,造成创建对象顺序变为 1-3-2. 试想一个场景:
线程 A 第一次创建对象 Singleton,对象创建顺序为 1-3-2;
当给 instance 分配完内存后,这时来了一个线程 B 调用了 getSingleton() 方法
这时候进行 instance == null 的判断,发现 instance 并不为 null。
但注意这时候 instance 并没有初始化对象,线程 B 则会将这个未初始化完成的对象返回。那 B 线程使用 instance 时就可能会出现问题,这就是双重检查锁问题所在。
使用 volatile
对于上述的问题,我们可以通过把 instance 声明为 volatile 型来解决
1 | public class Singleton { |
有些人认为使用 volatile 的原因是可见性,也就是可以保证线程在本地不会存有 instance 的副本,每次都是去主内存中读取。但其实是不对的。使用 volatile 的主要原因是其另一个特性:禁止指令重排序优化。也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障,读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的“后面”是时间上的先后顺序.
但是特别注意在 Java 5 以前的版本使用了 volatile 的双检锁还是有问题的。其原因是 Java 5 以前的 JMM (Java 内存模型)是存在缺陷的,即时将变量声明成 volatile 也不能完全避免重排序,主要是 volatile 变量前后的代码仍然存在重排序问题。这个 volatile 屏蔽重排序的问题在 Java 5 中才得以修复,所以在这之后才可以放心使用 volatile。
必须在 JDK5 版本以上使用。
静态内部类
1 | public class Singleton { |
这种写法是目前比较推荐的一种写法,采用静态内部类的方式,即实现了懒加载又不会出现线程安全问题。而且减少了 synchronized 的开销。
Learn more
双重检查锁定与延迟初始化
PMD-DoubleCheckedLocking
Double-checked locking: Clever, but broken