资讯动态

双重检查锁单例在生产上跑出了两个实例——问题出在你看不见的指令重排

发布时间:2026/10/15 16:56:12 来源:尧图企业网站定制
双重检查锁单例在生产上跑出了两个实例——问题出在你看不见的指令重排去年我们在网关模块里埋了一个单例的限流器双重检查锁DCL写法教科书级别的代码。上线两个月后监控里出现了两个限流器实例各自的计数桶独立工作限流阈值形同虚设——等于限流没做。排查了整整一天最后确认不是代码写错了是 JVM 和 CPU 没有按照我们想象的方式执行这段代码。这篇文章把这次踩坑完整复盘一遍因为我发现网上讲 DCL 的文章绝大多数停在加 volatile 就对了不讲为什么会出问题、出了问题长什么样。先看那段教科书代码java public class RateLimiter { private static RateLimiter instance; private final AtomicLong counter new AtomicLong();private RateLimiter() {} public static RateLimiter getInstance() { if (instance null) { // 第一次检查 synchronized (RateLimiter.class) { if (instance null) { // 第二次检查 instance new RateLimiter(); } } } return instance; }} 这段代码的意图很清楚大部分请求走第一次检查直接返回只有初始化那一刻才有锁竞争。看起来天衣无缝。问题出在instance new RateLimiter();这一行。它不是原子操作在字节码层面以及 JIT 之后的机器码层面大致分三步分配内存空间调用构造函数初始化对象把 instance 引用指向这块内存关键在于JIT 和 CPU 都有权对这三步做指令重排只要保证单线程视角的执行结果不变。所以完全可能重排成 1 → 3 → 2引用先赋值构造函数后执行。事故是怎么发生的想象两个线程 A 和 B 的交错线程 A 进入同步块执行new指令重排后先做了第 3 步instance 已经不为 null构造函数还没执行完此刻线程 A 还在同步块里但线程 B 走到第一次检查——它不需要拿锁读到的 instance 已经非 null线程 B 直接返回一个半初始化的对象counter 还是 null 或者处于未构造状态在我们的场景里后果是限流器的配置字段读到默认值走了降级路径各自 new 出了独立的计数结构——这就是监控里两个实例的真相。严格说一个是正常实例一个是半成品对象上的降级副本。这也解释了为什么这个问题极难在测试环境复现它依赖指令重排 线程交错的精确时机压测八小时可能一次都不出现生产上跑两周必然撞上一次。并发 bug 的特点是越依赖具体 CPU 微架构的问题测试环境越安静。修复一行 volatile但要知道它修的是什么java private static volatile RateLimiter instance;volatile 在这里的作用不是可见性——很多人以为加 volatile 是让其他线程及时看到新值这只说对了一半。更关键的是它禁止了 volatile 写操作前后的指令重排instance new RateLimiter()的 volatile 语义保证构造函数的初始化写操作先于引用赋值对其他线程可见JMM 的 happens-before 规则volatile 写之前的所有写操作不能重排到 volatile 写之后。换句话说volatile 保证的是只要线程 B 读到非 null 的 instance线程 A 构造函数里的写入就一定已经完成。半成品对象不可能被读到。对比一下另外两条路以及为什么我们最终换了方案修完 volatile 之后我们回头评估了一下这个模块其实有三个更好的选择静态内部类Holder 模式java public class RateLimiter { private static class Holder { static final RateLimiter INSTANCE new RateLimiter(); } public static RateLimiter getInstance() { return Holder.INSTANCE; } }JVM 的类初始化机制本身保证了线程安全类加载锁没有指令重排问题没有锁竞争代码还更短。这是 Java 里手写单例的首选除非有特殊理由否则 DCL 就不该是第一选项。枚举Effective Java 的推荐天然防反射和反序列化破坏。缺点是不能懒加载、没法继承业务代码里用得少。直接交给 Spring我们最终的选择。这个限流器本来就是网关的组件注册成 Bean 之后 Spring 容器默认单例生命周期、销毁回调都有人管。在业务项目里手写单例多数时候是在重复容器已经做好的事还把销毁逻辑弄丢了——我们后来还发现原来那个手写单例在热更新时没清理定时任务泄漏了一个线程。这是这次事故的第二个收获单例模式在 Spring 项目里的正确形态往往就是一个 Bean。几句总结DCL 没错错的是不加 volatile 的 DCL——而后者恰恰是网上流传最广的版本。这次踩坑之后我们在代码评审里加了一条硬规则看到手写单例先问三个问题——为什么不用 Holder为什么不用 Bean这个单例真的需要懒加载吗三个问题问下来十次有九次手写单例都不该存在。顺带一提我在做一个用卡皮巴拉讲设计模式的微信小程序「爪爪代码冒险记」23 个设计模式用漫画和答题的方式讲正在开发中。觉得有意思可以搜一下或者等我后面的文章。

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

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

免费获取报价 →
↑