双重检查锁为什么必须配合 volatile?
简化版
双重检查锁中的实例字段必须声明为 volatile,因为无锁快速路径需要获得对构造结果的安全发布保证。volatile 写与随后读取之间建立 happens-before 关系,使其他线程看到引用时也能看到构造前完成的写入,并约束可能导致提前发布的重排序。
详细版
正确写法如下:
private static volatile Service instance;
public static Service getInstance() {
Service result = instance;
if (result == null) {
synchronized (Service.class) {
result = instance;
if (result == null) {
result = new Service();
instance = result;
}
}
}
return result;
}
第一次检查避免实例已经创建后的加锁;第二次检查防止多个等待锁的线程依次创建对象。仅有同步块还不够,因为第一次读取发生在锁外:若字段不是 volatile,这条读取没有与发布线程建立所需的跨线程可见性关系。
volatile 保证对 instance 的写先行发生于其他线程随后对它的读。局部变量 result 可以减少 volatile 读取次数,但不是正确性的核心。
完整版教学
一、为什么要检查两次
假设线程 A 和 B 都在第一次检查时看到 null。A 先获得锁并创建实例;B 随后获得锁。如果没有第二次检查,B 仍会再创建一次。第二次检查必须位于同一个同步块内,才能根据 A 已经完成的初始化决定不再创建。
二、危险来自锁外读取
构造对象可以抽象成分配内存、执行初始化写入、发布引用。编译器和处理器可以在不破坏单线程语义的前提下重排操作;更关键的是,普通字段的跨线程读写没有自动建立可见性顺序。另一个线程可能观察到非空引用,却没有获得构造线程此前写入状态的完整保证。
同步块能保护锁内的线程,但快速路径没有加同一把锁。因此需要让发布写和快速路径读取通过另一种同步动作建立 happens-before。
三、volatile 如何完成安全发布
Java 内存模型规定,对一个 volatile 字段的写先行发生于后续对该字段的读。构造线程在写入 instance 之前完成的操作,经由这条关系对读取到该值的线程可见。
构造字段写入
↓ 程序次序
volatile 写 instance
↓ happens-before
volatile 读 instance
↓ 程序次序
使用对象字段
这既提供可见性,也禁止破坏安全发布所需顺序的重排序。
四、常见错误
以下做法都有问题:
- 忘记
volatile,锁外读取得不到安全发布保证; - 只检查一次,把每次获取都放进锁,虽正确但不再是双重检查;
- 第二次检查放在同步块外,仍可能重复创建;
- 锁住可能变化的对象,例如
instance本身; - 认为
volatile能让实例内部的复合操作自动线程安全。
volatile 只解决引用发布以及该字段的可见性语义,并不会把 count++、集合修改等复合操作变成原子操作。
五、现代 Java 中的定位
自 Java 5 修订内存模型后,正确声明 volatile 的双重检查锁是可用的。若没有额外初始化参数或重试控制,静态内部类通常更短、更不容易写错;理解双重检查锁的价值主要在于掌握安全发布和 happens-before。
六、两线程交错下三道防线分别做什么
DCL 的三道防线各有独立职责:第一次检查优化稳定路径,锁保证初始化临界区互斥,第二次检查阻止等待锁的线程重复创建,volatile 则把构造写入安全发布给锁外读者。缺一项都不能用另外一项代替。
T1: 第一次读 instance == null
T2: 第一次读 instance == null
T1: 获得 Service.class 监视器
T1: 第二次检查 null,构造对象并 volatile 写
T1: 解锁
T2: 获锁后第二次检查已非 null,不再构造
T3: volatile 读非 null,看到构造前全部写入
这条时序必须同时检查“谁负责创建”和“其他线程或调用方从哪里取得实例”。只看到最终引用相同还不够;若构造副作用执行了两次、发布了半初始化状态,或者作用域边界之外又创建了一份,就已经违反了本题讨论的保证。
七、保证范围与失效边界
| 评审维度 | 本题结论 |
|---|---|
| 唯一性/正确性保证 | 互斥保证只执行一次有效初始化,volatile 写/读建立安全发布的 happens-before。 |
| 作用域边界 | 仅覆盖该静态字段的发布和同一类加载边界;实例内部状态需另行同步。 |
| 仍未解决的问题 | 遗漏 volatile、锁对象不稳定、第二次检查移出锁外或构造中泄漏 this 都可能破坏正确性。 |
| 更合适的替代方案 | 无运行时参数时优先静态内部类;简单固定实例可用静态字段或枚举。 |
设计模式的保证必须带作用域。把“某个类初始化边界只创建一次”说成“整个系统永远只有一个”,或把“安全发布引用”说成“对象所有方法都线程安全”,都会把不同层次的问题混在一起。
八、工程落地时的验证方法
- 为构造过程增加可观察计数,使用 32 个并发任务同时获取实例,构造计数应符合本题保证。
- 比较引用身份时使用
==,不要让重写后的equals()掩盖实际存在的多个对象。 - 若涉及类加载器,记录
instance.getClass().getClassLoader(),同时比较类对象和加载器身份。 - 若涉及序列化,至少执行一次“序列化 → 反序列化”,并核对返回引用及外部副作用。
- 若涉及 Spring,分别检查 Bean 名称、
ApplicationContext身份和作用域,而不只检查 Java 类名。 - 对共享可变字段另做并发测试;实例创建正确并不能替代业务状态的原子性测试。
- 对初始化失败、重复调用和资源关闭分别设计用例,确认生命周期语义可解释。
记忆钩子:两次检查管“几次创建”,volatile 管“别人看见的是不是完整对象”。
九、常见误区与追问
- 误区:有 synchronized 块就不需要 volatile。 快速路径没有获取同一把锁,仍需要安全发布关系。
- 误区:volatile 能保证实例内部
count++原子。 它只提供该字段的可见性与排序语义,不组合读改写操作。 - 误区:第二次检查只是性能优化。 它是正确性条件,用来阻止已经等待锁的线程再次创建。
- 追问:局部变量 result 的作用是什么? 减少稳定路径上的 volatile 读取次数,不是 DCL 正确性的必要核心。
- 追问:Java 5 之前为什么不推荐 DCL? 旧内存模型下 volatile 语义不足以提供现代安全发布保证。
- 追问:构造器中把 this 注册到全局集合会怎样? 对象可能在构造完成前逸出,volatile 发布 instance 也无法修复更早的泄漏。
十、加强记忆
两次检查分别解决“稳定阶段不加锁”和“等待锁的线程不重复创建”,volatile 解决锁外读与发布写之间的可见性和顺序。缺少任何一环,都不能称为正确的双重检查锁。