title: 一个没加 volatile 的单例让 1% 的请求拿到了半初始化的对象tags: Java,JMM,volatile,happens-before,内存模型category: Java事故1% 的 NPE查了三天前年我们接手一个老服务偶发 NPE单例配置对象AppConfig的字段在某些请求里是null频率约 1%高并发下。诡异的是对象明明在静态方法里new出来了getInstance()返回的也不该是 null却没人能稳定复现。最后定位到经典的双重检查锁定少了一个volatilepublic class AppConfig { private static AppConfig instance; // ← 这里没有 volatile private final MapString, String settings; private AppConfig() { this.settings new HashMap(); settings.put(timeout, 3000); settings.put(retry, 3); // 构造函数里还有一堆重活 } public static AppConfig getInstance() { if (instance null) { // 第一次检查 synchronized (AppConfig.class) { if (instance null) { // 第二次检查 instance new AppConfig(); // 问题在这行 } } } return instance; } }高并发下线程 A 执行instance new AppConfig()。对象的创建在 JVM 眼里不是原子的粗略拆成三步分配内存空间在内存上初始化AppConfig调用构造器填settings等字段把instance引用指向这块内存。由于指令重排序JIT 编译优化步骤 2 和 3 可能被调换先执行 3引用指向未初始化的内存再执行 2。此时线程 B 跑到第一次检查if (instance null)看到instance不是 null因为步骤 3 已经执行了直接return instance拿到一个构造还没跑完的对象——settings还是 null于是后续settings.get(...)直接 NPE。这个 bug 的教科书解法就是给instance加volatile。但为什么加了就好背后的happens-before才是这次事故真正值得讲清楚的东西。happens-before 到底在约束什么JMMJava Memory ModelJSR-133JDK 5 起生效要解决的问题只有两个可见性和有序性。它不保证你写的代码按你写的顺序执行那是顺序一致性模型太慢而是给出一组happens-before 规则——只要两个操作之间存在 happens-before 关系JMM 就保证前者对后者的可见性和顺序。happens-before是偏序关系核心规则有几条JLS 17.4.5程序次序规则单线程内代码顺序前面的操作 happens-before 后面的操作。监视器锁规则unlockhappens-before 后续对同一个锁的lock。这意味着synchronized块里写的东西对下一个拿到同一把锁的线程可见。volatile 变量规则对 volatile 变量的写 happens-before 后续对同一个volatile 变量的读。传递性A happens-before BB happens-before C则 A happens-before C。线程启动/终止规则Thread.start()happens-before 线程里的任何操作线程里的操作 happens-beforeThread.join()返回后的操作。注意一个常见误解happens-before 不是时间上先发生。它是一组如果成立则 JMM 保证可见性和顺序的契约。两个操作即便时间上 A 早于 B只要不在同一条 happens-before 链上JMM 就不保证 B 能看到 A——重排序和 CPU 缓存就可能导致 B 看到旧值。用三段代码把可见性钉死第一段没有 volatile可见性不保证public class VisibilityDemo { private boolean running true; // 没有 volatile public void start() { new Thread(() - { while (running) { // 可能永远看不到 false死循环 // do work } }).start(); } public void stop() { running false; // 主线程改了工作线程可能一直从自己的 CPU 缓存读旧值 true } }工作线程启动时把running读进自己的 CPU 缓存/寄存器没有 volatile 的语义约束JIT 可能直接把while(running)优化成while(true)因为循环体内没修改running编译器认为它不会变。stop()在主线程改了主存的值工作线程的缓存不失效于是死循环。这是可见性缺失最直白的例子。第二段加 volatile强制可见private volatile boolean running true; public void stop() { running false; // 写 volatile立即刷主存 让其他 CPU 的该变量缓存行失效 }volatile的写会插入一个StoreStore 屏障JDK 5 之后用lock前缀指令实现强制把写操作的结果刷到主内存并使其他处理器上该变量的缓存行失效。读volatile时会从主存重新加载。于是工作线程每次循环都看到最新值。volatile不保证原子性比如i还是竞态只保证单次读写的可见性和禁止特定重排序。第三段volatile 禁止重排序救了单例回到开头的单例。instance加volatile后private static volatile AppConfig instance; // new AppConfig() 的分配→初始化→赋值引用三步因为 volatile 的写屏障 // 不会被重排序到赋值引用之前对其它线程可见。volatile的写之前有一个StoreStore 屏障保证对instance的写这个动作之前所有前面的写包括对settings的初始化都已经对其他处理器可见。线程 B 看到instance ! null时读 volatile有 LoadLoad 屏障根据 volatile 规则 传递性它一定能看到 A 已经完成的settings初始化。半初始化对象的问题就此消失。这里把三条规则串起来了程序次序A 线程里 settings 写在 instance 写之前→ volatile 写规则instance 写对 volatile 读可见→ 传递性 → B 线程读 instance 后一定能看到 settings。这就是volatile在单例里不可替代的原因——它同时解决了可见性和禁止重排序。不是所有共享变量都要 volatilevolatile解决不了原子性。下面这种计数在并发下仍然错private volatile int counter 0; public void increment() { counter; // 读-改-写三步volatile 不保证这三步原子竞态依旧 }counter编译成getfield → iadd → putfield两个线程可能同时getfield拿到 5都加一写成 6丢了一次。这种情况该用AtomicIntegerCAS或synchronizedprivate final AtomicInteger counter new AtomicInteger(0); public void increment() { counter.incrementAndGet(); // 底层 Unsafe.compareAndSwapInt单次原子完成 }AtomicInteger内部也用volatile它的value字段是volatile但它把读-改-写封装成一个 CAS 原子指令所以既能可见又能原子。换句话说AtomicXxxvolatile CAS 循环这是它比裸volatile多出来的能力。JMM 的几个规则横向对比手段可见性有序性(禁止重排)原子性性能开销典型用途无修饰字段不保证不保证不保证(64位 long/double 在 32 位 JVM 可能撕裂)最低局部/单线程volatile保证保证(变量级)仅单次读写低内存屏障状态标志、单例synchronized保证保证(块级)保证(块级)中锁竞争复合操作、临界区AtomicXxx保证保证保证(CAS)低-中计数器、无锁更新final保证(构造结束即对其他线程可见)保证(构造内 final 写先发布)仅初始化最低不可变对象final那行容易被忽略正确发布不 this 逃逸的final字段在构造器结束后对所有线程可见不需要 volatile。我们AppConfig的settings字段其实可以声明为private final配合volatile instance语义最干净——final 保证字段本身构造完即发布volatile 保证引用发布的可见与有序。复盘数字事故频率高并发下约 1% 请求触发半初始化单例日均约 1.2 万次 NPE 告警但分散在不同机器单机难复现。定位耗时3 天前两天以为是下游配置服务偶发返回空第三天才用jstack看到多个线程卡在settings.get上结合代码审查发现缺volatile。修复在instance加volatile并在settings加final约 2 行改动。灰度后 NPE 归零。后续把双重检查锁定单例必须 volatile写进团队 code review 清单并推动单例统一改用enum或静态内部类天生线程安全无需 volatile。我的取舍判断能用不可变对象解决的状态共享优先用final 不可变而不是volatile 可变。final的发布语义是 JMM 里最便宜、最稳的构造完了就安全可见没有内存屏障的运行期开销。我们后来把AppConfig这类初始化一次、读多写零的配置对象全部改成不可变final字段 构造时一次性填好从根上消除了半初始化可见的可能。double-checked locking我基本不用了。它为了在单例 懒加载 高性能三个目标间妥协引入了对volatile语义的精确依赖而这点恰恰最容易被后人改坏删volatile、或把构造逻辑挪到发布之后。Java 5 实现懒加载单例我更推荐静态内部类public class AppConfig { private AppConfig() { /* 初始化 */ } private static class Holder { static final AppConfig INSTANCE new AppConfig(); // 类加载时线程安全地初始化 } public static AppConfig getInstance() { return Holder.INSTANCE; // 第一次调用才触发 Holder 类加载天然懒加载且无需 volatile } }JVM 保证类初始化clinit的线程安全静态内部类又实现了懒加载——零volatile、零synchronized运行时开销还不可能写错。至于volatile该用在哪里状态标志如volatile boolean shutdown、一次性安全发布如本例单例、独立观察读多写少且每次读写独立。凡涉及读-改-写复合语义的别用volatile老老实实用AtomicXxx或锁。把volatile当轻量锁用是新人最常见的误用。留个思考题volatile只禁止它自己附近的特定重排序并不保证所有内存操作的全局顺序。那么如果线程 A 先写普通变量x1、再写volatile y1线程 B 读volatile y1之后读xB 一定看到x1吗如果把x也声明成volatile结论会变吗欢迎在评论区聊聊你对 happens-before 传递性的理解。