资讯动态

双重校验锁DCL与volatile:从单例懒加载到缓存防击穿的并发实践

发布时间:2026/9/30 10:43:32 来源:尧图企业网站定制
1. 从单例懒加载说起DCL到底解决了什么问题DCLdouble-checked locking双检锁/双重校验锁在Java并发编程圈子里属于入门必踩、进阶必懂的经典话题。它最初被提出来解决一个问题如何在多线程环境下既保证单例对象的唯一性又保证创建时机足够“懒”。先看一个最朴素的懒加载单例写法public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance null) { instance new Singleton(); } return instance; } }这段代码在单线程环境下完全没问题第一次调用时实例化之后直接返回。但放在多线程环境里就是一颗定时炸弹。两个线程可能同时走进if (instance null)的判断然后各自new出一个对象单例就变成了双例。更麻烦的是即便只差几个时钟周期一个线程看到了非空的instance也不能保证它已经完整初始化。解决并发问题最直接的办法是加锁。把getInstance()方法整体用synchronized修饰安全倒是安全了但代价是每次调用都要经过锁的竞争。而实际业务中getInstance()的调用频率往往极高拿一个已经初始化好的对象根本不需要同步这种每次都锁的方案在高并发场景下性能损耗非常明显。于是有人想了一个折中的办法先无锁检查一次如果对象已经存在就直接返回只有发现对象确实还没创建时才进入同步块再检查一次并完成创建。这个思路就是 DCL 的核心——外面那层检查用来挡掉绝大多数“已经初始化”的调用里层那层检查用来防止多个线程同时进入同步块后重复创建。这也是“双重校验锁”这个名字的由来。这一段历史放在今天看很多同学可能觉得有点“远古”。但DCL的思想并没有过时它代表的是一类典型的并发优化模式用无锁快速路径fast path挡住大部分请求只在少数真正需要竞争的情况下才付出锁的代价。这种“先判断、再同步、再判断”的节奏后来被广泛用在缓存加载、连接池初始化、配置中心拉取等场景里。理解了DCL你其实就理解了这类并发控制的通用方法论。2. 双重校验到底在检查什么核心代码逐行拆解先给出一段经典的、正确的 DCL 写法我们基于它逐行分析。public class Singleton { // volatile 是关键后面单独讲 private static volatile Singleton instance; private Singleton() { // 私有构造防止外部 new } public static Singleton getInstance() { // 第一重检查无锁快速路径 if (instance null) { // 只有第一次进入时才会走到这里 synchronized (Singleton.class) { // 第二重检查进入临界区后重新确认 if (instance null) { instance new Singleton(); } } } return instance; } }2.1 第一重检查无锁检查的作用第一重if (instance null)没有任何锁保护它的唯一目的是过滤。绝大多数情况下单例对象在程序启动早期就已经被创建好了后续成千上万次调用只需要return instance;这一行。无锁读在JVM层面几乎不耗费额外成本比每次进入synchronized块要快得多。这里需要纠正一个直觉第一重检查不保证“看到的是最新值”。就算某个线程刚刚在同步块里完成了赋值另—个线程的第一重检查也未必马上感知到。不过这个“不保证”其实不影响正确性因为最坏的情况只是这个线程看到null然后走进同步块在第二重检查时拿到最新状态。它能产生的最严重后果是“多走几步路”而不会破坏单例的唯一性。2.2 第二重检查锁内检查的意义第二重检查是真正的保险丝。假设线程A和线程B同时通过了第一重检查都进入了synchronized块。但同步块是互斥的线程A先拿到锁创建了对象并赋值给instance然后释放锁。线程B拿到锁之后进入临界区如果这里没有第二重检查线程B会再new一个对象把线程A刚赋好的引用覆盖掉单例被破坏。有了第二重检查线程B会发现instance ! null直接放弃创建。所以第一重检查优化的是“绝大多数调用”的性能第二重检查保证的是“并发进入临界区时”的正确性。这两层分工完全不同谁也替代不了谁。2.3 为什么同步块要锁住 Singleton.class这里锁的是类对象也就是这个类对应的Class对象。在JVM里每个类有且只有一个Class实例天然具备全局唯一的特性拿它当锁对象能保证所有线程在进入同步块时竞争的是同一把锁。很多人问“能不能用this或者字符串”不行——getInstance()是静态方法根本没有this字符串常量虽然可能相等但依赖字符串字面量做锁本质上是对锁语义的滥用很容易在后续维护中埋坑。3. volatile这条防线指令重排是怎么毁掉DCL的如果你在搜索引擎里翻DCL的历史会发现一个很惊人的事实DCL在早期是被广泛批判的甚至被一些大牛称为“broken pattern”。问题不是出在双重检查的逻辑上而是出在instance new Singleton()这一行。3.1 new 一个对象到底经历了什么Java中new一个对象在JVM层面大致分为三步分配一块内存空间在内存上执行构造函数初始化成员变量把对象引用赋值给instance问题在于CPU和编译器为了提升执行效率可能对这三步进行重排序。在单线程环境里重排序不影响最终结果因为不管先执行第2步还是第3步最终这个线程自己看到的效果是一致的——这就是所谓as-if-serial语义。但在多线程共享内存的情况下问题就出现了。3.2 没有 volatile 时会发生什么假设线程A进入同步块执行instance new Singleton()时第3步先于第2步完成内存里有了一个非空但尚未完成构造的instance。此时线程B恰好走到第一重检查发现instance ! null直接返回了这个半成品对象。线程B接下来访问对象内部字段时读到的可能是默认值0、false、null甚至直接抛NullPointerException。这就是著名的“半初始化对象”问题。它非常隐蔽因为重排序不一定每次都发生可能跑几万次才出现一次而且出现的位置完全不固定排查起来极其痛苦。我之前帮人看过一个偶发性空指针问题就是这种场景服务启动很慢多线程刷页面偶发报错最后定位到单例对象内部的一个Map还没初始化就被别的地方拿走了。3.3 volatile 在这里干了什么被volatile修饰的变量具有两层语义可见性一个线程对volatile变量的写入会立即对其他线程可见有序性禁止重排序volatile变量的读写操作前后编译器、JIT、CPU都不会跨越读写顺序做危险的重排序具体到DCL场景volatile让new Singleton()的构造过程不会“跨界”到引用赋值之后。线程B即使恰好在赋值后检查引用看到的也一定是一个已经完成构造的对象不会拿到半成品。3.4 JDK 5之前为什么 DCL 是坏的这里要说一个历史背景volatile在JDK 5之前的语义是不完整的。旧的内存模型下volatile只保证可见性但不严格禁止重排序。换句话说在JDK 5之前就算你写了volatile也有可能拿到半初始化对象。这也是当年DCL被群嘲的根本原因。JDK 5之后Java内存模型JSR-133重定义了volatile加入了“禁止重排序”的语义同时强化了synchronized的可见性保证。从那时起上面那段写法才成为真正可靠的标准答案。所以现在的DCL是够用的但如果你在看老古董代码或者老技术文章发现有人吐槽DCL有坑一定要先确认年代别被误导。一句话总结DCL正确性的关键不在双检而在 volatile。少了这层防线双检只解决了“重复创建”的问题却解决不了“拿到半成品”的问题。4. 现代Java里DCL的新活法从单例到懒加载机制很多人觉得DCL只跟“单例”绑定其实它的思想已经被JVM和各大框架消化吸收演化成了更底层的机制。这部分内容能帮你从“会写DCL”升级到“理解整个懒加载并发体系”。4.1 静态内部类方案干掉了 volatile在Java生态里有一种被广泛认为比手写DCL更优雅的懒加载单例写法Initialization-on-demand holder idiom按需初始化占位类模式。public class Singleton { private Singleton() { } private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }这段代码没有volatile没有synchronized却能保证线程安全和懒加载。它的原理利用了JVM内部的类加载锁class initialization lock当多个线程同时第一次访问Holder.INSTANCE时JVM会确保Holder类只被初始化一次并且所有线程在初始化完成后才能继续执行。这个锁是JVM在类加载层面提供的不需要你去写synchronized。这种方案的优点非常明显代码更短没有锁竞争也不存在指令重排的坑。它其实可以理解为“JVM帮你实现了一遍DCL”——第一次调用触发类加载类加载过程天然具备“仅在需要时初始化”的懒加载语义而且这一过程由JVM保证线程安全。所以在现代Java里如果只是实现一个懒加载单例静态内部类方案通常是比手写DCL更优的选择。DCL真正的价值更多在于它还适合那些“初始化逻辑更复杂、需要等待外部条件满足”的延迟加载场景比如动态配置、带条件的初始化流程这时候你没法简单靠类加载来触发。4.2 JDK 9 之后Class 初始化语义强化JDK 9之后Java对类加载过程的语义做了进一步梳理Class对象的初始化过程在不同平台和不同JVM实现上更加统一。这意味着基于Class初始化的懒加载方案比如静态内部类在不同环境下的行为一致性更可预测。另外java.lang.invoke和MethodHandles体系中也有ClassValue这类工具本质上也是利用Class级别的生命周期管理实现线程安全的“延迟计算缓存”。如果大家去看ClassValue的JDK源码实现会发现它在底层确实处理了类似“一个Class对应一个值、多线程同时访问时保证只计算一次”的语义——这跟DCL想要解决的问题本质上是同一个。4.3 枚举单例最硬核的“防御型单例”除了静态内部类枚举单例也是一个值得说的变体public enum Singleton { INSTANCE; public void doSomething() { } }枚举单例天然线程安全、天然序列化安全还能防反射攻击。很多Java并发书籍把它推荐为“最完美的单例实现”。但它也有一个争议点枚举常量是在类加载时初始化的严格意义上并不“懒”——只要你引用了这个枚举类INSTANCE就会立刻创建。所以枚举单例和DCL各有各的适用场景。追求绝对安全选枚举追求真正的延迟加载用静态内部类或者DCL这个选择本身没有标准答案取决于你的业务场景。4.4 DCL思想在现代框架里的投影DCL思想不只存在于手写单例中它在很多框架里都被升华了。比如 Spring 的SingletonBeanRegistry在获取单例Bean时会先尝试无锁获取getSingleton没拿到再上锁初始化——这个流程和DCL的第一二重检查神似。MyBatis、Netty、Guava等框架里的延迟缓存、连接池初始化、模板引擎加载等场景大量使用“先检查、后加锁、再检查”的模式。理解了DCL你在阅读这些框架源码时会觉得很多地方似曾相识这也是我为什么愿意把这么老的一个话题翻出来长篇大论它看上去只是一个小技巧但背后关联的其实是并发编程最核心的几个概念——原子性、可见性、有序性以及对这些特性的系统性取舍。5. DCL 的实战场景哪些地方你真该用它我知道很多同学看DCL教程最大的困惑是 我平时写业务代码好像根本不需要手写单例啊 确实如果你用的是Spring这类IoC容器Bean的生命周期管理早就替你把这些事干完了。但DCL的应用场景远不止“手写单例”这一处。5.1 需要严格控制初始化时机的工具类/SDK在写基础组件、SDK、工具库的时候我们经常遇到“某个资源比较重希望第一次真正使用时才初始化”的场景。比如数据库连接池的初始化、分布式锁工厂的创建、耗时统计上报器的启动等。这些场景通常没有Spring容器帮你管理生命周期需要你手写延迟加载逻辑DCL就是最自然的方案。我在做一个内部RPC框架时需要维护一个“客户端连接管理器”每个目标服务地址对应一个连接池。连接池的创建成本很高而且一个进程里同一地址只应该有一个连接池。这里我用的就是典型的 DCL 结构先从ConcurrentHashMap里查查不到再加锁创建创建时再查一遍防止并发重复创建。这其实和DCL的双重检查在本质上一模一样。5.2 缓存加载时的“防击穿”设计在缓存领域有一个经典问题叫“缓存击穿”某个热点key的缓存恰好过期一瞬间有成百上千个请求同时打到数据库。此时如果没有保护机制数据库压力会瞬间暴涨。常规做法是加锁加双重检查public Object getData(String key) { Object data cache.get(key); if (data null) { lock.lock(); try { data cache.get(key); if (data null) { data queryFromDB(key); cache.put(key, data); } } finally { lock.unlock(); } } return data; }这个模式是不是特别眼熟先无锁查缓存没查到再锁住锁里再查一次还没有才真正查库回填。能挡住同一时刻的并发穿透请求。这其实就是DCL思想在缓存场景中最标准的应用只不过锁的对象从Singleton.class变成了每个key对应的锁。5.3 代理对象和AOP中的延迟初始化在一些动态代理框架里被代理的目标对象往往不是启动时就创建好的而是第一次调用某个方法时才初始化真正的目标对象。如果用DCL可以保证在多个线程同时首次调用代理方法时底层目标对象只被构造一次。5.4 Netty 等网络框架中的资源懒加载Netty的Channel创建、EventLoopGroup启动、Bootstrap绑定端口等操作在许多封装中都存在“首次访问时才真正建立”的语义。这种只初始化一次且线程安全的要求同样适合DCL模式。你在阅读网上的Netty封装工具类时经常能看到类似if (channel null) { synchronized(...) { ... } }的结构。6. 避坑清单DCL 最容易踩的 5 个雷区这部分是今天最有价值的部分全是我见过别人踩过的坑和自己踩过的坑。DCL虽然代码只有几行但如果没有真正理解背后的原理很容易在不经意间写出“看起来对、实际上错”的代码。6.1 雷区一忘了加 volatile我可以非常肯定地说这是DCL里最普遍的错误。代码逻辑看着完全正确第一重检查、第二重检查、同步块一个都不少唯独漏了volatile。少了 volatile 可能不一定会报错但一旦发生指令重排就是极其诡异的偶发问题而且极难复现。判断标准很简单如果你看到没有volatile的DCL单例不管代码是谁写的都可以直接把“存在半初始化对象风险”这句话甩过去。6.2 雷区二把第一重检查当成线程安全的判断有人会用if (instance null)的结果去做业务分支认为“既然instance不为空那对象一定创建好了”。这在加了volatile后基本成立但仍有一下需要补充的细节volatile保证了这个字段在不同线程间的可见性但如果你没有正确使用同步机制或者没有正确处理类的其他状态还是可能遇到“实例有了但实例内部引用的其他对象还没初始化”的情况。比如instance引用的对象持有的某个字段是普通非volatile引用而这个字段被另一个线程在无同步保护下修改读线程依然可能看到旧值。这个坑更多属于JMM基础知识但放在DCL场景里容易被忽略。6.3 雷区三锁的不是同一个对象比如一个类有两种单例字段有人图省事写了两个DCL但锁对象都用String字面量private static final String LOCK_A LOCK; private static final String LOCK_B LOCK;两个锁对象其实是同一个字符串实例字符串常量池引用同一个对象结果A的同步块和B的同步块互相阻塞性能下降是小事如果逻辑上依赖两个锁各自独立还会引发死锁或者资源竞争。这属于锁对象选型上的经典失误。6.4 雷区四在锁内做耗时操作把数据库查询、远程调用、大对象初始化放在synchronized块内部导致所有等待线程被长时间阻塞。DCL适合“初始化动作尽量短”的场景如果初始化逻辑很重比如要加载一个大目录、预热几百个缓存条目你需要评估持锁时间必要时把初始化步骤拆分成多阶段或者改用CountDownLatch、CompletableFuture等更灵活的并发工具。6.5 雷区五直接拿 DCL 当性能银弹有些同学在网上听说DCL性能好于是代码里但凡有懒加载就往上套。但DCL适用的场景是“读取远多于创建”的情况如果你创建对象的频率和读取频率差不多每次都得折腾锁性能反而更差。要根据实际场景做选择别为用而用。7. 常见问题速查与排查思路现象可能原因排查/解决建议偶发NullPointerException加日志也不稳定复现缺少volatile拿到半初始化对象加上volatile检查对应字段的JMM可见性单例对象被创建了两次第二重检查缺失或锁对象不一致确认同步块内检查确认锁对象是同一个类的Class实例多线程调用性能下降明显锁被无关代码共用或锁内做耗时操作检查锁对象唯一性缩短临界区代码无法实现真正的懒加载使用了枚举或静态字段初始化静态内部类或DCL按需创建并发调用时出现多副本资源如连接池初始化与put操作不是原子流程用putIfAbsent或锁保护完整流程编译期没问题但不同环境表现不一致依赖了旧的volatile语义或特定JVM行为升级到JDK 5明确基于JSR-133模型理解问题排查DCL问题时有个实用技巧先在本地用高并发压测工具模拟大量线程同时首次调用getInstance()如果问题无法稳定复现就故意去掉volatile跑几轮对比往往能快速判断问题是否和内存可见性相关。我之前做过一次实验同样的代码在低并发下怎么跑都没事一旦把线程数拉到100以上半小时内必然出问题。还有一个更隐蔽的场景很多同学在单测里永远不会触发问题因为单测通常是单线程顺序执行。所以写DCL相关代码后建议务必安排一个多线程并发创建的测试用例而且最好让每个线程都强制进入第一重检查的null分支才能把“重复创建”或“半初始化”这类问题暴露出来。8. 写出高质量 DCL 的实操清单最后分享一套我自己写DCL时默认遵守的清单直接照着抄能少踩很多坑。确定instance字段是private static volatile三者缺一不可第一重检查不加锁保持极薄逻辑不要在里面做任何可能抛异常的运算同步块锁对象用当前类的Class对象不要用实例字段或者字符串字面量第二重检查务必保留并写在锁内部这是防止并发重复创建的最后一道闸临界区代码保持精简如果初始化逻辑很重拆出去单独方法构造方法私有化防止外部直接new如果要防序列化破坏单例补充readResolve()方法如果项目里已经有Spring等IoC容器优先交给容器管理能不用手写DCL就不手写如果只是要个懒加载单例优先考虑静态内部类方案代码更少也更安全写完检查一遍有没有volatile、锁是不是同一个、第二重检查在不在锁里我在实际排查中确实遇到过一个很典型的案例某服务凌晨定时任务和用户请求并发触发同一个工具类的初始化偶发出现“部分用户拿到的配置不全”的现象。最初以为是配置中心问题后来抓线程dump才发现是单例的Map字段在构造中被重排加了一个volatile之后问题彻底消失。这种坑一旦碰上没有JMM底子靠肉眼很难看穿但理解DCL的原理之后定位起来很快。DCL这条技术线看起来只是几行代码的事但它背后牵扯的内存模型、锁语义、类加载机制放到任何Java进阶面试里都能追问出一大片内容。这也是它历久弥新的原因——它不是“一个单例写法”而是一扇理解Java并发底层的大门。希望这篇梳理能帮你把这扇门推开而不是仅仅记住一个模板代码。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑