十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

每 20 万次请求偶发一次 NPE:DCL 单例少一个 volatile 与 JMM 的 3 条边界

每 20 万次请求偶发一次 NPE:DCL 单例少一个 volatile 与 JMM 的 3 条边界 title: 每 20 万次请求偶发一次 NPEDCL 单例少一个 volatile 与 JMM 的 3 条边界tags: [Java, JMM, volatile, happens-before, 并发]一个复现不了的 NPE我们的营销规则引擎里有个RuleCompiler负责把 DSL 编译成可执行的规则对象。它是懒加载的单例用的是经典双重检查锁写法。这段代码在 2023 年写的跑了两年多没出过问题。2026 年 4 月我们把网关的压测流量从 3000 QPS 提到 12000问题冒出来了日志里开始出现极低频的 NPE报在RuleCompiler.compile()内部访问this.parserCache的那一行。频率大概是每 20 万次请求一次测试环境手工点根本复现不了。最先怀疑的是parserCache那个HashMap被并发修改。查了一圈它只在构造器里初始化、之后只读不可能是并发修改。第二个怀疑方向是热加载导致对象被替换也排除了——那台机器没做热部署。真正的线索来自把 NPE 的堆栈和调用方对齐之后发现抛 NPE 的线程拿到的RuleCompiler实例不为 null但它的字段是 null。也就是说它拿到了一个「构造函数还没跑完」的对象。出事的代码和它的字节码public class RuleCompiler { private static RuleCompiler instance; // 少了 volatile private final MapString, Parser parserCache; private final DslLexer lexer; private RuleCompiler() { this.parserCache new ConcurrentHashMap(256); this.lexer new DslLexer(); warmUp(); // 预热耗时约 80ms } public static RuleCompiler getInstance() { if (instance null) { // 第一次检查无锁 synchronized (RuleCompiler.class) { if (instance null) { // 第二次检查 instance new RuleCompiler(); } } } return instance; } }问题出在instance new RuleCompiler()这一行。它对应的字节码大致是三步new #2 RuleCompiler // 步骤 1分配内存字段置零值 dup invokespecial #3 RuleCompiler.init // 步骤 2执行构造器 astore_1 / putstatic #4 instance // 步骤 3把引用赋给 instanceJMM 不禁止步骤 2 和步骤 3 重排序。原因在于这两步之间没有数据依赖冲突需要程序顺序保证——从单线程视角看先赋引用再跑构造器和先跑构造器再赋引用结果没区别as-if-serial 只保证单线程内的语义等价。但对另一个线程就有区别了。重排序发生后的时序时刻线程 A线程 Bt1进入 sync 块分配内存字段全是 null/0—t2把引用写进instance步骤 3 提前—t3开始执行构造器还在warmUp()里第一次检查instance ! null直接 returnt4构造器还没跑完调compile()访问parserCache→null→ NPEwarmUp()那 80ms 就是这个窗口的宽度。QPS 3000 的时候服务启动后第一个请求触发初始化、后续请求大概率还没到QPS 12000 之后80ms 里有近 1000 个请求涌进来撞上窗口的概率就不再是零了。顺带说一句这个重排序在 x86 上其实很难由 CPU 层面产生x86 是 TSO 内存模型store-store 不重排实际更可能是 JIT 编译器做的。我们用-XX:PrintCompilation -XX:UnlockDiagnosticVMOptions -XX:PrintInlining看了一眼getInstance在 C2 里被内联进了调用点构造器也被内联展开——内联之后编译器就有充分的自由度去调整这些 store 的顺序了。这也是为什么这类 bug 只在跑了一段时间、方法被 JIT 热编译之后才出现解释执行阶段基本碰不到。volatile 到底加了什么改法只有一个字private static volatile RuleCompiler instance;volatile在 JMM 里给了两条保证可见性写 volatile 变量之后其他线程读它一定能看到最新值。实现上是写完插入 store 屏障、把 store buffer 刷出去读之前插入 load 屏障。禁止重排序volatile 写之前的所有操作不能重排到 volatile 写之后。这条正是 DCL 需要的——构造器里对parserCache、lexer的写入被钉死在「把引用赋给 instance」之前。在 x86 上HotSpot 对 volatile 写生成的是lock addl $0x0,(%rsp)一条带 lock 前缀的空操作。lock 前缀会锁总线现代 CPU 上是锁缓存行并起到全屏障作用。可以用 hsdis 反汇编验证0x00007f8a1c0d2a3f: mov %r10,0x68(%r11) ; putstatic instance 0x00007f8a1c0d2a43: lock addl $0x0,-0x40(%rsp) ; StoreLoad 屏障这条 lock 指令的成本在 20-40 个时钟周期量级。对 DCL 来说只在第一次检查那里有 volatile 读成本几乎为零x86 上 volatile 读不需要屏障指令只需要禁止编译器优化写只发生一次所以整体开销可以忽略。这就是我说「DCL 加 volatile 没有性能理由不加」的依据。happens-before 才是该记的东西我发现团队里很多人包括当年写这段代码的我自己对 JMM 的理解停留在「volatile 保证可见性」缺的是 happens-before 这个统一框架。JSR-133 里定义的几条规则实际写并发代码时用得最多的是这五条规则内容典型用法程序顺序单线程内前面的操作 hb 后面的操作单线程语义的基础监视器锁unlock hb 后续对同一锁的 locksynchronized 保证的可见性来源volatile对 volatile 的写 hb 后续对它的读状态标志位、DCL传递性A hb BB hb C则 A hb C组合推导的关键final 字段构造器内对 final 的写 hb 构造器结束前提this 不逸出不可变对象安全发布最后那条特别值得说因为它有个容易被忽视的前提。如果RuleCompiler的字段全是final上面代码里parserCache和lexer确实是 finalJSR-133 的 final 字段语义本应保证其他线程看到构造完成的对象。为什么还是出了 NPE因为 final 字段的语义保证的是「通过正确发布的引用读到的 final 字段一定是初始化后的值」。而这里的引用发布本身就不正确——instance不是 volatile读它的线程可能读到一个「已赋值但构造未完成」的中间状态。final 语义救不了发布本身有问题的场景。这是我们那次事故的核心教训final 字段不能替代安全发布。第二个被顺手挖出来的坑this 逸出排查过程中我扫了一遍其他单例找到一处更隐蔽的问题public class MetricsRegistry { private final ListMeter meters; private final ScheduledExecutorService reporter; public MetricsRegistry(ScheduledExecutorService pool) { this.meters new CopyOnWriteArrayList(); this.reporter pool; // 构造器里就把 this 交出去了 pool.scheduleAtFixedRate(this::flush, 0, 10, TimeUnit.SECONDS); this.tags loadTags(); // 这一行在上面之后 } private volatile MapString, String tags; private void flush() { // 定时任务线程可能在 tags 还是 null 时就跑进来 for (Meter m : meters) { report(m, tags); // tags 可能为 null } } }pool.scheduleAtFixedRate(this::flush, 0, ...)的 initialDelay 是 0任务几乎立刻在另一个线程跑起来而这时构造器还没执行到this.tags loadTags()。方法引用this::flush捕获了尚未构造完成的this这就是 this 逸出。注意这里tags已经是volatile了也救不了——volatile 保证的是「一旦写入就可见」不保证「读的时候一定已经写了」。这是两个不同的问题。修法是把「注册定时任务」从构造器里挪出去用一个显式的start()public class MetricsRegistry { private final ListMeter meters new CopyOnWriteArrayList(); private final ScheduledExecutorService reporter; private final MapString, String tags; // 改成 final private final AtomicBoolean started new AtomicBoolean(false); public MetricsRegistry(ScheduledExecutorService pool) { this.reporter pool; this.tags Map.copyOf(loadTags()); // 构造器只做纯初始化 } public void start() { if (started.compareAndSet(false, true)) { // 幂等防重复注册 reporter.scheduleAtFixedRate(this::flush, 10, 10, TimeUnit.SECONDS); } } }逐点说改动tags改成final并用Map.copyOf得到不可变 map配合「构造器内不逸出 this」final 字段语义就能真正生效start()单独暴露由 Spring 的PostConstruct或容器生命周期调用此时构造已完成AtomicBoolean加 CAS 保证start()重复调用只生效一次我们的集成测试里确实有重复调用的情况。这个改动我在评审时被问过一句「为什么不用PostConstruct直接标在原来的注册逻辑上」。可以但这个类还要在非 Spring 环境一个独立的 SDK jar里用所以留了显式start()Spring 侧再包一层调用。复盘数字NPE 频率12000 QPS 下约每 20 万请求 1 次即每小时 200 次左右全部落在服务重启后的头几秒。之所以两年没暴露是因为原来 3000 QPS 且有预热流量发布时先打 100 QPS 预热 3 分钟初始化在预热阶段就完成了。后来 CI 流程改了预热步骤被去掉问题才显形。修复只改了一个volatile关键字压测 4 小时、共 1.7 亿次请求NPE 归零。顺带扫出的 this 逸出有 2 处、非 volatile 的状态标志位有 5 处用 IDEA 的Non-atomic operation on volatile field和 SpotBugs 的IS2_INCONSISTENT_SYNC规则扫的。我的几个判断懒加载单例这件事绝大多数场景根本不需要。我们那个RuleCompiler初始化只要 80ms服务启动本身就要 20 秒用饿汉式静态字段直接 new或者静态内部类 Holder 模式一行代码解决还能靠类初始化的 JVM 锁天然保证线程安全。DCL 的价值只在「初始化成本极高且可能永远用不上」的场景这种场景在业务代码里我五年里只遇到过两次。如果非要写 DCLvolatile不是优化项而是正确性要求。网上还能搜到一些「用 volatile 会有性能损失所以省掉」的老文章那是 JDK 1.4 时代的说法JSR-133JDK 5修订了 volatile 语义之后这个写法才真正可用而现代 CPU 上一次 volatile 写的成本是几十个时钟周期。为这个省掉正确性账算不过来。构造器里不要做三件事注册回调、启动线程、把 this 传出去。这三件事都会导致 this 逸出而且逸出之后的 bug 通常表现为「偶发 NPE」或「字段是默认值」极难复现。我们把这条写进了代码规范SpotBugs 里对应的规则是SC_START_IN_CTOR和MC_OVERRIDABLE_METHOD_CALL_IN_CONSTRUCTOR。不可变对象是并发编程里性价比最高的手段。全 final 字段 构造器内不逸出 this 集合用Map.copyOf/List.copyOf这样的对象可以随意在线程间传递不需要任何同步。我现在设计新类的默认选择是先做成不可变的只有确实需要变才引入可变状态和同步。留个问题上面提到 x86 是 TSO 内存模型store-store 不会重排所以这类 DCL 重排更可能来自 JIT 而非 CPU。那么问题是如果你的服务跑在 ARM 架构上比如某些云厂商的 ARM 实例内存模型比 x86 弱得多同样一段少了 volatile 的 DCL 代码出错概率会有什么变化你会不会因此调整代码评审的关注点欢迎在评论区聊聊。
返回列表