双重检查锁单例为什么必须加 volatile?
结论先行:因为 instance = new Singleton() 不是一步完成的,它包含"分配内存 → 初始化对象 → 把引用赋给 instance"三步,后两步可能被重排序。若没有 volatile,别的线程可能看到一个引用非空、但字段还没初始化的对象,直接拿去用就会踩到"半初始化"状态。
1. 指令重排的隐患
理想顺序: 可能的重排:
① 分配内存 ① 分配内存
② 执行构造器(字段赋值) ③ instance = 引用(先挂上去)
③ instance = 引用 ② 执行构造器(后执行)
重排后,另一个线程在 if (instance == null) 判断时看到引用已非空,直接返回并访问字段——此时构造器还没跑完,字段全是默认值(0、null)。
线程 A 线程 B
──────────────────────────── ────────────────────────────
① 分配内存
③ instance = 引用(重排提前)
if (instance == null) → false
拿到 instance,访问 field → 0 / null ❌
② 执行构造器,field = 100
2. 正确写法
public class Singleton {
// volatile 禁止 ② 与 ③ 重排,并保证写对读可见
private static volatile Singleton instance;
private Singleton() { }
public static Singleton getInstance() {
if (instance == null) { // 第一次检查:避免每次都加锁
synchronized (Singleton.class) { // 锁住类对象
if (instance == null) { // 第二次检查:防止重复创建
instance = new Singleton();
}
}
}
return instance;
}
}
两次检查各有职责:外层是为了"已初始化后不再加锁"(性能),内层是为了"多个线程同时通过外层检查时只创建一个"(正确性)。synchronized 负责互斥创建,volatile 负责安全发布,两者缺一不可。
3. 五种单例写法对比
| 写法 | 线程安全 | 懒加载 | 防止反射破坏 | 防止反序列化破坏 | 评价 |
|---|---|---|---|---|---|
| 饿汉式 | 是(类加载保证) | 否 | 否 | 否 | 简单,但不能延迟加载 |
| 懒汉式(方法加锁) | 是 | 是 | 否 | 否 | 每次调用都加锁,慢 |
| 双重检查锁 | 是(需 volatile) | 是 | 否 | 否 | 兼顾性能与懒加载 |
| 静态内部类 | 是(类加载保证) | 是 | 否 | 否 | 最推荐,实现最简洁 |
| 枚举 | 是 | 否 | 是 | 是 | 《Effective Java》推荐 |
// 静态内部类:利用"类加载时初始化 + 只加载一次"的语义
public class HolderSingleton {
private HolderSingleton() { }
private static class Holder {
private static final HolderSingleton INSTANCE = new HolderSingleton();
}
public static HolderSingleton getInstance() { return Holder.INSTANCE; }
}
// 枚举:JVM 保证实例唯一,且反射无法创建枚举实例
public enum EnumSingleton {
INSTANCE;
public void doSomething() { }
}
4. 常见追问
追问一:为什么静态内部类不需要 volatile?
因为它把"延迟加载"交给了 JVM:Holder 类只有在第一次访问 Holder.INSTANCE 时才被加载,而类加载由 JVM 保证只执行一次且对所有线程可见。没有手写检查,也就没有重排窗口。
追问二:DCL 里的 synchronized 能省吗?
不能。volatile 只保证可见性和有序性,不保证"判断 + 创建"的原子性。没有锁,两个线程可能同时判定为 null,各自创建一个实例。
追问三:JDK 5 之前为什么 DCL 是错的?
JDK 5 之前 volatile 的语义不完整(没有禁止重排的保证),JMM 也未修正,所以 DCL 在那个年代确实有 bug。JSR-133 修订后(JDK 5 起)才成立。
追问四:枚举单例真的无懈可击吗?
几乎。反射 newInstance 对枚举会抛 IllegalArgumentException,反序列化也由 JVM 保证返回同一实例。唯一缺点是不能懒加载,且继承场景受限(枚举不能继承其他类)。
追问五:Spring 的单例 Bean 为什么不需要 DCL?
Spring 的单例在容器启动时由 DefaultSingletonBeanRegistry 配合 synchronized 创建(并且用 singletonObjects 缓存),创建发生在容器刷新阶段,通常是单线程串行的,不走懒加载的 DCL 路径。
小结
DCL 的关键不是"双重检查"这个技巧,而是"检查与创建之间必须有一次安全发布"。volatile 提供这次发布;如果不愿背这个细节,就用静态内部类或枚举——它们把同步问题交给了类加载机制。