双重检查锁定(DCL) 单例模式中,如果没有使用 volatile 修饰实例变量,可能会因为指令重排序导致其他线程获取到未完全初始化的对象,从而引发可见性问题。
问题复现
java
public class Singleton {
private static Singleton instance; // 缺少 volatile
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 问题所在
}
}
}
return instance;
}
}
instance = new Singleton(); 这一行在底层并非原子操作,它大致包含三个步骤:
- 分配内存 -- 为
Singleton对象分配堆内存。 - 初始化对象 -- 调用构造器,对成员变量赋初值。
- 将引用指向内存地址 -- 让
instance指向这块内存。
指令重排序的影响
在没有 volatile 保证有序性的情况下,编译器或 CPU 可能会将第 2 步(初始化)和第 3 步(引用赋值)颠倒顺序。于是实际执行顺序变为:
-
- 分配内存
-
- 将
instance指向内存地址(此时内存中的对象还未初始化)
- 将
-
- 初始化对象
可见性问题的发生过程
- 线程 A 进入
getInstance(),发现instance == null,获得锁,执行instance = new Singleton();。
由于重排序,A 先分配内存并让instance指向该地址(但尚未调用构造器初始化)。此时 A 可能被挂起或时间片用完。 - 线程 B 调用
getInstance(),第一次检查instance。
instance已经不为 null(因为 A 已经赋值了地址),于是 B 直接返回这个"半成品"对象。 - 线程 B 使用该对象,访问其成员变量时,由于对象尚未完成初始化,可能读取到默认值(如 0、null) 而非构造器中赋予的值,造成程序行为异常,甚至崩溃。
解决方案
使用 volatile 修饰 instance 变量 ,禁止指令重排序,确保 instance 引用的赋值发生在对象完全初始化之后。
java
private static volatile Singleton instance;
结论
DCL 导致的可见性问题本质是指令重排序 使未完全初始化的对象对其他线程可见。通过
volatile的内存屏障 来禁止这种重排序即可修复。在现代 Java 中,更推荐使用静态内部类 或枚举方式实现单例,它们更简洁且天然避免此类问题。