Double-Checked Locking — 高并发下单例模式的经典实现
DCL(Double-Checked Locking)是一种在多线程环境下实现延迟初始化(Lazy Initialization)的优化技术。 它的核心思想是:在获取锁之前和之后各进行一次检查,从而在保证线程安全的同时,最大限度地减少同步带来的性能开销。
两次检查 + 一次加锁 = 线程安全的懒加载
在保证线程安全的前提下,让大多数线程无需获取锁即可直接返回已创建的实例,从而提高性能。
单例模式(Singleton)、延迟加载配置对象、数据库连接池初始化等需要"只创建一次"的场景。
并发编程、Java 内存模型(JMM)、多线程同步机制。最早由 Douglas Schmidt 等人于 1997 年提出。
在实现单例模式时,我们需要同时满足两个需求:懒加载(用到时才创建)和线程安全(只创建一个实例)。 下面对比三种实现方案的演进过程:
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 线程A 和 线程B 同时通过检查
instance = new Singleton(); // 两个线程各自创建实例 → 单例被破坏!
}
return instance;
}
}
当两个线程同时判断 instance == null 为 true 时,会各自创建一个实例,单例模式被彻底破坏。
public class Singleton {
private static Singleton instance;
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
每次调用 getInstance() 都需要获取锁,但实际上只有第一次调用才需要同步。
实例创建后,99% 的调用只是读取操作,不应该被锁阻塞。性能损耗严重。
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 中最容易出错、也是最核心的知识点。instance = new Singleton() 在字节码层面并非原子操作,它包含三个步骤:
线程B 在第一次检查时发现 instance != null(因为步骤2已执行),
但对象尚未初始化(步骤3未执行),拿到的是一个半初始化对象!
只有对象完全初始化后,引用才会被赋值。其他线程看到的要么是 null,要么是完整初始化的对象。
通过插入内存屏障(Memory Barrier),确保 new 操作的三个步骤严格按照 分配内存 → 初始化 → 赋值引用 的顺序执行。
当一个线程修改了 instance 的值,其他线程能立即看到最新值,而不是从 CPU 缓存中读取过期的旧值。
理解 DCL 离不开对 Java 内存模型的理解。JMM 定义了线程与主内存之间的交互规则:
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();
}
}
public class Singleton {
private Singleton() {}
// 利用类加载机制保证线程安全
private static class Holder {
static final Singleton INSTANCE
= new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
✅ 利用 JVM 类加载机制保证线程安全,代码更简洁,无需 volatile。
public enum Singleton {
INSTANCE;
// 业务方法
public void doSomething() {
// ...
}
}
✅ 天然线程安全,防反射、防序列化破坏。Joshua Bloch 推荐。
| 方案 | 线程安全 | 懒加载 | 防反射 | 防序列化 | 复杂度 |
|---|---|---|---|---|---|
| DCL 双重检查锁 | ✅ (需 volatile) | ✅ | ⚠️ 需额外处理 | ⚠️ 需 readResolve | 中等 |
| 静态内部类 | ✅ | ✅ | ⚠️ 需额外处理 | ⚠️ 需 readResolve | 低 |
| 枚举 | ✅ | ❌ (类加载即创建) | ✅ | ✅ | 最低 |
| 饿汉式 | ✅ | ❌ | ⚠️ 需额外处理 | ⚠️ 需 readResolve | 最低 |
最常见的错误。没有 volatile,new 操作可能被重排序,导致其他线程获取到半初始化对象。这种 bug 在测试中极难复现,可能只在高并发生产环境下偶尔出现。
如果去掉 synchronized 块内的第二次 if (instance == null) 检查,当两个线程同时通过第一次检查后,会各自创建一个实例,单例被破坏。
如果 synchronized 锁的不是同一个对象(例如锁了 this 而不是 Class),不同线程可能进入不同的同步块,失去互斥效果。
如果构造函数抛出异常,instance 可能已被赋值(在 volatile 写之前),但对象未完全初始化。后续线程看到 instance 不为 null,直接使用了一个异常状态的对象。
DCL 在 Java 5+ 中因 volatile 语义增强才正确。在 C/C++ 中,需要使用 std::atomic 和 memory_order 来保证类似的语义,不能直接照搬 Java 写法。