🔒 DCL 双重检查锁

Double-Checked Locking — 高并发下单例模式的经典实现

并发编程 单例模式 volatile 线程安全

📖 一、什么是 DCL(双重检查锁)

DCL(Double-Checked Locking)是一种在多线程环境下实现延迟初始化(Lazy Initialization)的优化技术。 它的核心思想是:在获取锁之前和之后各进行一次检查,从而在保证线程安全的同时,最大限度地减少同步带来的性能开销。

核心公式

第1次检查(无锁)→ 加锁 → 第2次检查(有锁)→ 初始化

两次检查 + 一次加锁 = 线程安全的懒加载

🎯 设计目标

在保证线程安全的前提下,让大多数线程无需获取锁即可直接返回已创建的实例,从而提高性能。

📍 典型场景

单例模式(Singleton)、延迟加载配置对象、数据库连接池初始化等需要"只创建一次"的场景。

🏗️ 所属领域

并发编程、Java 内存模型(JMM)、多线程同步机制。最早由 Douglas Schmidt 等人于 1997 年提出。

⚡ 二、为什么需要 DCL?—— 问题背景

在实现单例模式时,我们需要同时满足两个需求:懒加载(用到时才创建)和线程安全(只创建一个实例)。 下面对比三种实现方案的演进过程:

方案一:不加锁(线程不安全 ❌)

Java
public class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {           // 线程A 和 线程B 同时通过检查
            instance = new Singleton();  // 两个线程各自创建实例 → 单例被破坏!
        }
        return instance;
    }
}

💥 问题

当两个线程同时判断 instance == null 为 true 时,会各自创建一个实例,单例模式被彻底破坏。

方案二:synchronized 全程加锁(安全但低效 ⚠️)

Java
public class Singleton {
    private static Singleton instance;

    public static synchronized Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

⚠️ 问题

每次调用 getInstance() 都需要获取锁,但实际上只有第一次调用才需要同步。 实例创建后,99% 的调用只是读取操作,不应该被锁阻塞。性能损耗严重。

方案三:DCL 双重检查锁(安全且高效 ✅)

Java
public class Singleton {
    private static volatile Singleton instance;  // ① 必须使用 volatile

    public static Singleton getInstance() {
        if (instance == null) {                    // ② 第一次检查(无锁,快速路径)
            synchronized (Singleton.class) {       // ③ 加锁
                if (instance == null) {             // ④ 第二次检查(有锁,防止重复创建)
                    instance = new Singleton();    // ⑤ 创建实例
                }
            }
        }
        return instance;
    }
}

✅ 优势

实例创建后,后续所有调用都走第一次检查直接返回的快速路径,无需加锁,性能接近无锁方案。

🔄 三、DCL 执行流程详解

STEP 1
线程调用 getInstance()
进入方法
STEP 2 — 第一次检查
if (instance == null) ?
🔓 无锁检查 · 如果 instance 不为 null → 直接返回(快速路径)
STEP 3 — 加锁
synchronized (Singleton.class)
🔒 获取内置锁 · 只有 instance 为 null 时才需要加锁
STEP 4 — 第二次检查
if (instance == null) ?
🔒 有锁检查 · 防止多个线程排队后重复创建实例
STEP 5 — 创建实例
instance = new Singleton()
分配内存 → 初始化对象 → 将引用指向内存
STEP 6
释放锁 & 返回实例
后续线程进入时,第一次检查 instance != null → 直接返回

💡 两次检查各自的作用

  • 第一次检查(无锁):避免实例已创建后,所有线程仍然排队获取锁。这是性能优化的关键。
  • 第二次检查(有锁):当两个线程同时通过第一次检查后,第一个线程创建完实例释放锁,第二个线程进入同步块后需要再次检查,避免重复创建。

🔥 四、为什么必须加 volatile?—— 指令重排问题

这是 DCL 中最容易出错、也是最核心的知识点。instance = new Singleton() 在字节码层面并非原子操作,它包含三个步骤:

❌ 没有 volatile — 可能发生指令重排

1 memory = allocate() — 分配内存
2 instance = memory — 引用指向内存 ⚡(提前!)
3 ctor(memory) — 初始化对象 (被推迟!)
⬇️ 2 和 3 可能被重排序 ⬇️

💥 灾难性后果

线程B 在第一次检查时发现 instance != null(因为步骤2已执行), 但对象尚未初始化(步骤3未执行),拿到的是一个半初始化对象!

✅ 有 volatile — 禁止指令重排

1 memory = allocate() — 分配内存
2 ctor(memory) — 初始化对象
3 instance = memory — 引用指向内存
🔒 volatile 保证顺序执行

✅ 安全

只有对象完全初始化后,引用才会被赋值。其他线程看到的要么是 null,要么是完整初始化的对象。

🎬 多线程灾难场景复现(无 volatile)

线程 A
线程 B
时间1
1 进入 getInstance(),第一次检查 instance == null ✓
1 进入 getInstance(),第一次检查 instance == null ✓
时间2
2 获取锁,进入 synchronized 块
⏳ 等待获取锁...
时间3
3 第二次检查 instance == null ✓
⏳ 等待获取锁...
时间4
4 执行 new:分配内存
⏳ 等待获取锁...
时间5
4 ⚡ 指令重排:instance 指向内存(未初始化!)
—
时间6
释放锁
2 获取锁成功!
时间7
(对象初始化中...)
3 第二次检查 instance != null → 跳过创建
时间8
(对象初始化完成)
💥 返回半初始化的 instance → 使用崩溃!

🛡️ volatile 作用一:禁止指令重排

通过插入内存屏障(Memory Barrier),确保 new 操作的三个步骤严格按照 分配内存 → 初始化 → 赋值引用 的顺序执行。

👁️ volatile 作用二:保证可见性

当一个线程修改了 instance 的值,其他线程能立即看到最新值,而不是从 CPU 缓存中读取过期的旧值。

🧠 五、Java 内存模型(JMM)视角

理解 DCL 离不开对 Java 内存模型的理解。JMM 定义了线程与主内存之间的交互规则:

🧵 线程 A 工作内存

instance 的副本
其他变量...
⇄

💾 主内存(共享)

instance(真实值)
其他共享变量...
⇄

🧵 线程 B 工作内存

instance 的副本
其他变量...

🔑 volatile 在 JMM 中的语义

  • 写 volatile 变量时:JMM 会把该线程工作内存中的值刷新到主内存,并在写操作前插入 StoreStore + StoreLoad 屏障。
  • 读 volatile 变量时:JMM 会把该线程工作内存中的值置为无效,必须从主内存重新读取,并在读操作后插入 LoadLoad + LoadStore 屏障。

💻 六、完整代码实现

Singleton.java — 完整 DCL 实现
public final class Singleton {

    /**
     * volatile 关键字是必须的!
     * 1. 禁止指令重排序(防止返回半初始化对象)
     * 2. 保证多线程间的可见性(一个线程写入后其他线程立即可见)
     */
    private static volatile Singleton instance;

    // 私有构造函数,防止外部 new
    private Singleton() {
        // 防止反射攻击(可选)
        if (instance != null) {
            throw new RuntimeException("Use getInstance() to create.");
        }
    }

    public static Singleton getInstance() {
        // 第一次检查:无锁快速路径
        // 大多数调用在这里直接返回,性能最优
        if (instance == null) {

            // 加锁:只有第一次创建时才会进入
            synchronized (Singleton.class) {

                // 第二次检查:防止重复创建
                // 场景:线程A和B同时通过第一次检查
                //       线程A先获取锁创建实例
                //       线程B进入后必须再次检查
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    // 防止序列化破坏单例(可选)
    protected Object readResolve() {
        return getInstance();
    }
}

🔀 七、DCL 的替代方案

📦 静态内部类(推荐)

Java
public class Singleton {
    private Singleton() {}

    // 利用类加载机制保证线程安全
    private static class Holder {
        static final Singleton INSTANCE
            = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;
    }
}

✅ 利用 JVM 类加载机制保证线程安全,代码更简洁,无需 volatile。

🔢 枚举(最安全)

Java
public enum Singleton {
    INSTANCE;

    // 业务方法
    public void doSomething() {
        // ...
    }
}

✅ 天然线程安全,防反射、防序列化破坏。Joshua Bloch 推荐。

方案 线程安全 懒加载 防反射 防序列化 复杂度
DCL 双重检查锁 ✅ (需 volatile) ✅ ⚠️ 需额外处理 ⚠️ 需 readResolve 中等
静态内部类 ✅ ✅ ⚠️ 需额外处理 ⚠️ 需 readResolve 低
枚举 ✅ ❌ (类加载即创建) ✅ ✅ 最低
饿汉式 ✅ ❌ ⚠️ 需额外处理 ⚠️ 需 readResolve 最低

⚖️ 八、DCL 优缺点分析

✅ 优点

  • 高性能:实例创建后,后续调用无需加锁,走无锁快速路径
  • 懒加载:只在第一次使用时才创建实例,节省资源
  • 线程安全:正确实现后完全保证线程安全
  • 广泛使用:是经典并发模式,被大量框架和库采用
  • 粒度可控:锁的范围精确到初始化阶段

⚠️ 缺点 / 注意事项

  • 实现复杂:必须使用 volatile,遗漏则产生严重 bug
  • 理解门槛高:涉及 JMM、指令重排、内存屏障等底层概念
  • 反射/序列化风险:需额外代码防护
  • 不适用于所有场景:如果初始化不昂贵,饿汉式更简单
  • 调试困难:指令重排导致的问题难以复现和定位

🚨 九、常见错误与陷阱

陷阱 1:忘记 volatile

最常见的错误。没有 volatile,new 操作可能被重排序,导致其他线程获取到半初始化对象。这种 bug 在测试中极难复现,可能只在高并发生产环境下偶尔出现。

陷阱 2:省略第二次检查

如果去掉 synchronized 块内的第二次 if (instance == null) 检查,当两个线程同时通过第一次检查后,会各自创建一个实例,单例被破坏。

陷阱 3:锁对象不一致

如果 synchronized 锁的不是同一个对象(例如锁了 this 而不是 Class),不同线程可能进入不同的同步块,失去互斥效果。

陷阱 4:构造函数中抛出异常

如果构造函数抛出异常,instance 可能已被赋值(在 volatile 写之前),但对象未完全初始化。后续线程看到 instance 不为 null,直接使用了一个异常状态的对象。

陷阱 5:在 C/C++ 中套用 DCL

DCL 在 Java 5+ 中因 volatile 语义增强才正确。在 C/C++ 中,需要使用 std::atomic 和 memory_order 来保证类似的语义,不能直接照搬 Java 写法。

📋 十、总结

🔑 DCL 核心要点速记

  1. volatile 不可省略 — 禁止指令重排 + 保证可见性,是 DCL 正确性的基石
  2. 两次检查缺一不可 — 第一次检查优化性能(无锁快速路径),第二次检查保证安全(防重复创建)
  3. synchronized 锁 Class — 确保所有线程竞争同一把锁
  4. new 操作非原子 — 分配内存 → 初始化 → 赋值引用,三步可能被重排
  5. 考虑替代方案 — 静态内部类和枚举通常更简单、更安全

❌ 错误写法

  • 没有 volatile 修饰 instance
  • 只有第一次检查,没有第二次
  • synchronized 锁了 this 而非 Class
  • 用 DCL 初始化非单例资源
  • 在 C/C++ 中直接照搬 Java 写法
VS

✅ 正确写法

  • instance 用 volatile 修饰
  • 两次 null 检查,一次无锁一次有锁
  • synchronized 锁 Class 对象
  • 构造函数不抛异常
  • 必要时加 readResolve 防序列化