← 返回题目列表

双重检查锁为什么必须配合 volatile?

高频 困难 第 16 / 26 题 更新于 2026/07/28
单例模式双重检查锁volatileJava内存模型

简化版

双重检查锁中的实例字段必须声明为 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 都可能破坏正确性。
更合适的替代方案无运行时参数时优先静态内部类;简单固定实例可用静态字段或枚举。

设计模式的保证必须带作用域。把“某个类初始化边界只创建一次”说成“整个系统永远只有一个”,或把“安全发布引用”说成“对象所有方法都线程安全”,都会把不同层次的问题混在一起。

八、工程落地时的验证方法

  1. 为构造过程增加可观察计数,使用 32 个并发任务同时获取实例,构造计数应符合本题保证。
  2. 比较引用身份时使用 ==,不要让重写后的 equals() 掩盖实际存在的多个对象。
  3. 若涉及类加载器,记录 instance.getClass().getClassLoader(),同时比较类对象和加载器身份。
  4. 若涉及序列化,至少执行一次“序列化 → 反序列化”,并核对返回引用及外部副作用。
  5. 若涉及 Spring,分别检查 Bean 名称、ApplicationContext 身份和作用域,而不只检查 Java 类名。
  6. 对共享可变字段另做并发测试;实例创建正确并不能替代业务状态的原子性测试。
  7. 对初始化失败、重复调用和资源关闭分别设计用例,确认生命周期语义可解释。

记忆钩子:两次检查管“几次创建”,volatile 管“别人看见的是不是完整对象”。

九、常见误区与追问

  • 误区:有 synchronized 块就不需要 volatile。 快速路径没有获取同一把锁,仍需要安全发布关系。
  • 误区:volatile 能保证实例内部 count++ 原子。 它只提供该字段的可见性与排序语义,不组合读改写操作。
  • 误区:第二次检查只是性能优化。 它是正确性条件,用来阻止已经等待锁的线程再次创建。
  • 追问:局部变量 result 的作用是什么? 减少稳定路径上的 volatile 读取次数,不是 DCL 正确性的必要核心。
  • 追问:Java 5 之前为什么不推荐 DCL? 旧内存模型下 volatile 语义不足以提供现代安全发布保证。
  • 追问:构造器中把 this 注册到全局集合会怎样? 对象可能在构造完成前逸出,volatile 发布 instance 也无法修复更早的泄漏。

十、加强记忆

两次检查分别解决“稳定阶段不加锁”和“等待锁的线程不重复创建”,volatile 解决锁外读与发布写之间的可见性和顺序。缺少任何一环,都不能称为正确的双重检查锁。