去年在一次代码评审上一个同事提交了一段典型的双检锁单例代码群里很快就分成两派一派说这写法没用对象还是可能半初始化另一派说加了 volatile 就是标准答案。两边争了半小时最后发现其实有人把 volatile 写在了方法上有人写在字段上还有人根本没写。那场争论让我意识到DCL 这个被写进无数面试题的概念真正能把它讲透的人并不多。这篇内容我打算把 DCLdouble-checked locking双检锁/双重校验锁从根上讲清楚它到底要解决什么问题、经典写法为什么是坏的、volatile 是怎么把它救回来的、以及放到现代工程里我们到底该怎么选。不管你是刚学并发的小白还是写了几年 Java 的老手只要用过单例这篇文章都值得看完。1. DCL 要解决的问题懒加载与同步开销的权衡先从一个最日常的场景说起。系统里有一个全局配置管理器初始化时要读取配置文件、建连接池、加载一堆缓存成本很高。为了不拖慢启动速度我们不想在类加载时就创建它而是等第一次真正调用时才创建。这就是懒加载Lazy Initialization。但问题来了懒加载的多线程安全怎么保证1.1 懒加载单例的朴素写法与困境最直觉的写法是这样public class ConfigManager { private static ConfigManager instance; private ConfigManager() { // 读取配置文件、初始化连接池、加载缓存 } public static synchronized ConfigManager getInstance() { if (instance null) { instance new ConfigManager(); } return instance; } }给整个 getInstance 方法加 synchronized这是最简单也最安全的做法。但它有一个在极高并发下会被放大的问题当 instance 已经创建好之后每次调用 getInstance 依然要经过同步方法的入口检查。虽然 Java 的偏向锁和轻量级锁能化解一部分开销但一旦竞争激烈锁的获取、膨胀、释放都会消耗 CPU 周期。在高频调用的热点路径上这白白浪费的性能是真实存在的。如果把同步去掉instance 为 null 的临界判断在多线程下会出现竞态条件。两个线程同时看到 instance null同时执行 new结果一个实例被覆盖另一个线程拿到的引用可能还是只建了一半的对象。这就是著名的 check-then-act 竞态。1.2 从加锁到“先查再锁”DCL 原始的优化思路既然每次全量加锁太贵能不能先不加锁检查一次如果已经创建了就直接返回只有发现是 null 才进入同步块这就是 DCL 的核心思想——两层检查public class ConfigManager { private static ConfigManager instance; public static ConfigManager getInstance() { if (instance null) { // 第一次检查无锁 synchronized (ConfigManager.class) { // 只有为 null 才加锁 if (instance null) { // 第二次检查防止重复创建 instance new ConfigManager(); } } } return instance; } }这个设计的精妙之处在于一旦 instance 被成功创建后续所有调用走第一次检查就直接返回完全避开锁读路径是真正意义上的 lock-free。而第二次检查是为了防止一个微妙的情况——线程 A 拿到锁正在创建实例时线程 B 已经在第一次检查处发现 instance 为 null 并排队等锁。如果等锁进来后不再判断一次B 会再创建一个新实例覆盖掉 A 的结果。所以“双检锁”里面“双检”的含义很明确一检是为了避免重复抢锁二检是为了避免重复创建。1.3 DCL 的收益边界只有高频读时才有意义在深入原理之前先泼一盆冷水DCL 并不是一个普适的优化手段它有明确的使用边界。如果 getInstance 每天只被调用几十次加不加锁根本感知不到差异这点优化意义不大。如果构造函数本身要执行几十毫秒那锁竞争和构造开销相比也是九牛一毛。真正值得用 DCL 的场景是三个条件同时满足需要懒加载、getInstance 调用频率极高、绝大多数调用发生在实例创建之后。典型如框架里的 Bean 工厂、连接池管理器、配置中心客户端。在这些场景下高并发读路径上的每一次锁获取都是实打实的性能损耗。理解了收益边界你才能真正理解后面修复方案的取舍逻辑也能在评审代码时判断这段 DCL 到底该不该存在。2. 经典 DCL 为什么是坏的重排序、可见性与未初始化对象如果代码从未真正跑到一个“对象只建了一半”的状态DCL 看起来完全正常。这正是 DCL 最危险的地方——它的问题不是必然触发而是在特定时序下以极小概率触发一旦触发就非常难排查。要理解它为什么坏得从instance new ConfigManager()在 JVM 里的真实执行过程说起。2.1 new Singleton() 在 JVM 里实际干了三件事instance new ConfigManager();这一行代码在 Java 字节码层面并不是一个原子操作它可以被拆成三个核心步骤分配一块足够存放 ConfigManager 对象的内存空间在分配好的内存上执行构造函数完成对象初始化字段赋值、资源加载、状态初始化把这块内存的地址赋值给引用变量 instance从步骤 2 到步骤 3JIT 编译器和 CPU 都可能在不改变单线程语义的前提下做指令重排序。也就是说实际执行顺序可能变成 1 → 3 → 2先分配内存再把引用地址写入 instance最后才执行构造函数完成对象的真正初始化。2.2 重排序是怎么绕过锁的锁保护的边界在 DCL 的经典写法里虽然对象创建发生在 synchronized 块内部但 synchronized 只保证“进入代码块的线程之间互斥”它并不能保证代码块内部的指令不被重排序。这个要点很关键。new ConfigManager()这一步的三个子操作可能被重排成先写引用、再调用构造函数而这个重排序发生在同一个线程的临界区内锁本身是默许的。问题出在另一个线程身上。线程 A 执行完步骤 3、还没执行步骤 2 时instance 引用已经不再是 null 了。此刻线程 B 调用 getInstance第一次检查直接看到 instance ! null于是根本不进同步块直接返回了这个引用。而 B 拿到的这个对象构造函数可能还没执行完——内部成员变量是默认值配置根本没加载连接池也没初始化。B 在使用它时就会出现各种匪夷所思的 NullPointerException 或逻辑错误。计数器、状态位、缓存数组……这些字段在对象发布瞬间还是默认值但使用方的代码却认为它已经是一个完整的 ConfigManager。2.3 一个能复现 DCL 问题的压力测试思路网上很多说法是“DCL 明显是错的随便跑跑就能复现”但实际情况是这个 bug 在普通测试环境很难稳定复现因为重排序不是每次都发生触发它需要特定的 CPU 指令调度、JIT 编译结果和并发时序的配合。如果你真想快速看到效果可以从两个方向入手。第一加大构造函数的执行时间。构造函数内故意做大量任务或 sleep让步骤 2 与步骤 3 之间的窗口拉长重排序发生后未初始化对象被其他线程观察到的概率就会显著提升。第二用一个成员变量标记状态。构造函数内先给 status 赋值一个预先约定好的值比如 42然后在使用方检查拿到的对象 status 是不是 42。如果出现 0 或者 null就说明拿到了未完成初始化的对象。public class Singleton { private static Singleton instance; private int status; private Singleton() { try { Thread.sleep(100); // 扩大重排序窗口 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } this.status 42; } public int getStatus() { return status; } public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }然后用几百个线程同时循环调用 getInstance().getStatus()统计返回结果。一旦看到状态不是 42说明你成功踩到了重排序产生的坑。当然由于 JIT 行为不同这个测试在有的机器上可能跑很久也复现不了但不影响我们理解问题本质——后续所有修复方案都是围绕“如何阻止这个重排序”或者“如何让其他线程感知到这个对象还没有完成初始化”来展开的。3. volatile 修复 DCL 的深层原理内存屏障与 happens-beforeJava 5 之后给 instance 字段加上 volatile 修饰就成了修复 DCL 的标准方案public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这个 volatile 具体起到了什么作用一句话它改变了写操作的内存语义让构造函数中的普通写操作不会被重排序到 volatile 写操作之后。3.1 volatile 在 Java 5 之后的语义增强Java 5 之前的 volatile 语义很弱只保证可见性不保证重排序所以那时候的 DCL 即便加了 volatile 也还是错的。JSR-133 内存模型发布后volatile 的语义被显著增强加入了针对重排序的严格约束。从内存屏障的角度看volatile 写操作前面会插入 StoreStore 屏障禁止前面的普通写与后面的 volatile 写重排序volatile 写操作后面会插入 StoreLoad 屏障禁止 volatile 写与后面的 volatile 读/写重排序volatile 读操作后面会插入 LoadLoad 屏障和 LoadStore 屏障禁止后续读写操作越过 volatile 读在 DCL 场景里构造函数中的this.status 42等普通写操作属于“volatile 写之前的普通写”StoreStore 屏障确保它们不会被重排到 instance 赋值之后。内存分配、字段赋值、构造函数执行都完成后instance 的 volatile 写才会发生。另一个线程读到 instance 时看到的一定是初始化完整的对象。这就像发布一本书的流程所有章节内容都写完、校对完、排版好才把书号登记到公开书目上。外面的读者一旦在书目上看到这本书就一定能买到完整版而不是只打印了封面的半成品。3.2 happens-before 规则如何保证第二重检查成立内存屏障是 CPU 和 JVM 底层的实现细节而在语言层面Java 内存模型用一个更抽象的概念来描述这种正确性happens-before 规则。JSR-133 定义了对 volatile 字段的写操作 happens-before 后续对同一 volatile 字段的读操作。这个规则的意思是当线程 B 读取某个 volatile 字段时不仅能读到线程 A 对该字段的写入还能看到线程 A 在执行该写入之前发生的所有操作。把它套到 DCL 上线程 A 执行构造函数设置 status 42、加载配置等普通操作线程 A 执行 volatile 写 operation把 instance 赋值为新对象线程 B 执行 volatile 读发现 instance 不为 null根据 happen-before 传递性线程 A 对 status 的写入 happens-before 线程 B 对 status 的读取从结果来看线程 B 读到的 instance内部所有字段的赋值操作都已经对 B 可见就是那个“完整初始化”的对象。这一整套推理链条才是 DCL volatile 正确的理论根基。还有一个经常被忽略的点第二次检查本身也依赖 volatile 的可见性。线程 B 进入同步块后再次读 instancevolatile 能保证它读到的是最新写入的值不会因为本地缓存读到过期 null。3.3 volatile 的局限什么时候不该用它修 DCL不过 volatile 也不是万能的。它只适用于引用发布的场景如果对象里某些字段被发布到构造函数之外的其他对象或者在构造函数里把 this 引用传递出去逸出volatile 也保护不了。典型错误写法public class Singleton { private static volatile Singleton instance; private ListString cache; private Singleton() { this.cache new ArrayList(); GlobalRegistry.register(this); // this 逸出对象还没构建完成就发布 } }构造函数里把 this 传给外部容器时其他线程可能通过 GlobalRegistry 拿到这个尚未构造完整的对象volatile 对这种情况无能为力。所以 volatile 修 DCL 的前提是对象的初始化过程完全封装在构造函数内并且不发布 this 引用。另一个容易踩的坑是把 volatile 加在 getInstance 方法上而不是加在字段上。public static volatile Singleton getInstance() { ... } // 语法错误volatile 只能修饰成员变量不能修饰方法。如果有人把 volatile 写在方法声明上说明他对这个关键字的理解还停留在“跟并发有关”的模糊阶段。4. 现代 Java 下的正确解法和代码评审识别要点DCL volatile 是合法的但在这个时代它并不是唯一的解法甚至不是大多数情况下的最优解。Java 本身就提供了多种线程安全的懒加载单例方案不同方案在简洁性、防反射抗序列化、可测试性上各有千秋。本节把这些方案并排对比再总结一些代码评审时一眼识别错误 DCL 的经验。4.1 四套单例实现横向对比第一套静态内部类方式。public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }它的原理很巧妙JVM 在类初始化时会自动加锁保证同一个类加载器下一个类的静态初始化过程只被一个线程执行。外部类 Singleton 加载时不会触发 Holder 的初始化只有第一次调用 getInstance 时才会触发 Holder 类的加载和初始化由此实现懒加载。不需要手写任何同步代码干净、可靠、性能也高。第二套枚举方式。public enum Singleton { INSTANCE; // 业务方法 public void doSomething() { } }枚举单例是 Josh Bloch 在 Effective Java 里强烈推荐的方案。它天然线程安全、天然懒加载枚举类也是第一次使用时才初始化、天然防反射攻击和防序列化破坏。Java 运行时会禁止反射创建枚举实例而反序列化对枚举也有特殊处理无论如何都不会生成新实例。第三套饿汉式。public class Singleton { private static final Singleton INSTANCE new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } }类加载时立即创建实现最简单但没有懒加载能力。如果对象创建成本极高且启动阶段根本用不到这会拖慢启动时间。第四套DCL volatile也就是本文一直在分析的那套。来一张表格把这四套方案的关键维度对齐实现方式懒加载线程安全防反射防序列化破坏成瘾点饿汉式否是否否最简单静态内部类是是否否优雅且安全枚举是是是是功能最全DCL volatile是是否否可读性最差如果只是需要一个普通的单例我个人的选择顺序是能用枚举就用枚举不能用枚举比如需要继承某个父类就用静态内部类只有当确实需要极致的懒加载和零锁读路径时才考虑 DCL volatile。4.2 代码评审中识别“假 DCL”的 3 个指征在实际代码评审中我看到过很多“自以为写了 DCL”却存在隐患的代码。几个高频的典型问题instance 字段没有 volatile 修饰。这是最经典的错误无论有没有加锁字段本身不具备可见性约束别的线程可能读到过期 null也可能读到半初始化对象。见到这种代码基本可以当场驳回。构造函数中有耗时操作却没用局部变量承接。正确写法建议在同步块内先把对象赋给局部变量再整体发布给 instance即Singleton local new Singleton(); this.instance local;。这个写法的好处是即使 JVM 做了微妙的优化也不至于因为构造过程中的某些错误状态而让 instance 指向一个废弃对象。虽然理论上 volatile 已经防住了重排序但局部变量写法更稳妥也能让代码意图更清晰。除了 getInstance 之外还有别的地方直接给 instance 赋值。比如代码里有个 reset() 方法执行instance null以支持单例重置。这会破坏 volatile 的发布语义出现两个线程交替写入的风险。如果要支持重置应该考虑用 AtomicReference 或干脆换一个支持重新创建的容器而不是用 DCL 语义去硬扛。4.3 为什么很多团队干脆用枚举或静态内部类一个值得思考的问题是既然 DCL volatile 是正确的为什么很多团队还是不愿意用核心原因是心智负担。DCL 的正确性依赖一整套内存模型推理重排序、内存屏障、happens-before、volatile 写读语义……任何一环理解不到位代码在评审时就很容易被改坏。团队里只要有一个成员没搞清楚 volatile 的作用顺手把它删掉系统就会变成一个“大概率正常、极小概率出诡异 bug”的定时炸弹。这种 bug 线上很难排查复现难定位难还可能只在特定 CPU 或 JVM 版本上出现。静态内部类和枚举则完全不需要写锁也不需要理解内存模型把正确性交给 JVM 的类加载机制。这类代码哪怕是刚入职的初级工程师也能安全地维护。在工程实践里降低代码被后续维护者改坏的概率本身就是一个巨大的优点。所以如果团队没有极致的性能诉求更推荐用静态内部类或枚举。DCL volatile 可以作为一个原理题被讨论、被理解但不一定非要作为团队主推的写法。5. 跨语言视角与工程实战中的 DCL 取舍聊完 Java 里的 DCL再跳到其他语言里看一眼会发现一个有趣的现象同样是“懒加载单例”不同语言给出的解法完全不同。理解这些差异有助于我们跳出具体语言细节看到并发编程里更本质的东西。5.1 C 和 Go 里的对应物call_once、Meyers Singleton、sync.Once在 C11 之前线程安全的懒加载单例是出了名的麻烦因为语言标准没有定义多线程内存模型锁和 volatile 的配合在不同编译器上行为不一致。C11 之后标准库给了几个可靠工具。最推荐的是 Meyers Singleton也就是函数内局部静态变量class Singleton { public: static Singleton getInstance() { static Singleton instance; return instance; } private: Singleton() {} };C11 标准保证局部静态变量的初始化是线程安全的通常在底层用 guard 变量实现。第一次调用 getInstance 时才初始化之后每次调用直接返回引用没有锁开销和 Java 的静态内部类几乎同样的优雅。另一种是显式用 call_once 控制class Singleton { public: static Singleton getInstance() { std::call_once(initInstanceFlag, Singleton::initSingleton); return *instance; } private: static void initSingleton() { instance new Singleton(); } static std::once_flag initInstanceFlag; static Singleton* instance; };C 里很少有人手动实现 DCL因为 C 的 volatile 并不具备 Java volatile 的内存序语义单纯用 volatile 修 C 的 DCL 是修不对的。底层正确做法要上 std::atomic 和 acquire/release 内存序复杂度直线上升。语言已经给了更安全的工具没必要自讨苦吃。Go 的标准库也提供了成熟方案sync.Once。Once 内部本质上就是把 DCL 封装好了外部使用者不需要关心内存序和重排序只要调用 Do 方法注册初始化函数即可var ( instance *Singleton once sync.Once ) func GetInstance() *Singleton { once.Do(func() { instance Singleton{} }) return instance }这种设计思想是一致的把复杂的同步细节封装在低层库里让业务开发者在更高层的抽象上解决单例问题。Java 里的枚举单例、C 里的 Meyers Singleton、Go 里的 sync.Once本质都是“不要让每个开发者各自去推演内存模型”的思路。5.2 DCL 的最佳适用场景与工程判断结合上面的跨语言经验再回到 Java我总结出一套工程上的决策流程。当你在代码里需要“单例”时不需要懒加载直接用饿汉式简单粗暴不出错需要懒加载但 getInstance 调用频率不算高静态内部类就够了需要考虑反射攻击和序列化破坏直接上枚举既需要懒加载、又是超高频率的热点路径、实例创建成本又低才考虑 DCL volatile判断一个场景到底适不适合 DCL可以问自己三个问题。第一真的需要单例吗很多情况下一个静态工具类或依赖注入容器管理的 Bean 就够了。第二真的需要懒加载吗如果初始化成本可控饿汉式的简单性远胜一切。第三真的存在可测量的性能瓶颈吗如果没有压测数据支持不要为了优化而优化。5.3 序列化、反射与类加载器带来的单例挑战即便实现了线程安全的懒加载单例DCL 也可能被其他机制破坏。反射可以通过 setAccessible(true) 调用私有构造函数创建一个新实例直接绕过 getInstance序列化则会在反序列化时基于字节流重建一个对象同样不经过构造函数。对于反射DCL 和静态内部类都没有防御能力。对付这个问题有三种思路在构造函数里加防重复实例化的检查当 instance 已存在时抛出异常直接使用枚举单例Java 语言层面禁止反射创建枚举实例或者依赖 Spring 等容器管理类实例不手写单例。对于序列化如果类实现了 Serializable需要在类里定义 readResolve 方法让它返回同一个单例对象protected Object readResolve() { return getInstance(); }如果用了枚举这个动作可以省略因为枚举的反序列化机制默认返回同一个实例。另一个容易被忽略的问题是类加载器同一个类被不同的类加载器加载会产生完全不同的 Class 对象和 static 字段单例也就无从谈起。在应用服务器、OSGi 这类多类加载器环境里要特别注意这一点。5.4 如果真的要用 DCL我总结的最终代码模板最后把我个人认为最稳妥、最经得起评审的 DCL volatile 写法放在这里public class Singleton { private static volatile Singleton instance; private Singleton() { // 只允许在 instance 为 null 时调用 if (instance ! null) { throw new IllegalStateException(Singleton instance already exists); } // 初始化逻辑... } public static Singleton getInstance() { Singleton local instance; // 局部变量减少 volatile 读次数 if (local null) { synchronized (Singleton.class) { local instance; if (local null) { local new Singleton(); instance local; } } } return local; } // 序列化防护 protected Object readResolve() { return getInstance(); } }细节上有几个值得注意的地方。用局部变量 local 承接 instance减少对 volatile 字段的重复读取这在热心路径上更友好。第二次检查时把局部变量赋值为 instance 再判断语义清晰。构造函数里加上防重复创建检查能给反射调用者一个明确的失败信号。关于异常处理的实践心得如果构造函数中有可能抛出受检异常单一锁内创建实例的写法会显得笨拙有人会想先建对象再提交给 volatile 字段。这一点上 DCL 并没有特殊处理技巧异常发生时 instance 依旧保持 null下一次调用重新尝试即可。序列化和反射部分的设计相对独立代码里顺带写了 readResolve 和构造函数防重复检查。5.5 关于 DCL 争议的最终看法回头看 DCL 的历史它其实是一个很有意思的并发编程案例。一个想法看似正确实际在底层内存模型上“翻车”后来被语言标准的演进救活又逐渐被更高级的语法特性取代。这个过程的教训比 DCL 本身的代码更有价值并发正确性不能靠直觉要依据内存模型和语言规范来推导工程上优先选择那些把复杂性封装在底层、不容易被误改的方案性能优化必须建立在可测量的瓶颈之上而不是预先的脑内推测。另一个经验是给代码评审提意见时与其只说“这里该加 volatile”或者“这样写不安全”不如把这条链路从头到尾讲清楚——谁在什么时候可能读到什么状态为什么那个状态下对象的使用会有风险。只有当团队里更多人都理解这种推理那些表面正确、实际脆弱的并发代码才会越来越少。DCL 并不是一个过时的面试题它是理解 Java 内存模型的最佳教学案例。把这条链路吃透以后再看到任何加锁、volatile、原子类的问题你都会有一种“这里的水有多深我大概有数”的感觉。