title: 一个没加 volatile 的单例,让 1% 的请求拿到了半初始化的对象
tags: Java,JMM,volatile,happens-before,内存模型
category: Java
事故:1% 的 NPE,查了三天
前年我们接手一个老服务,偶发 NPE:单例配置对象AppConfig的字段在某些请求里是null,频率约 1%(高并发下)。诡异的是,对象明明在静态方法里new出来了,getInstance()返回的也不该是 null,却没人能稳定复现。
最后定位到经典的"双重检查锁定"少了一个volatile:
public class AppConfig { private static AppConfig instance; // ← 这里没有 volatile private final Map<String, 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 到底在约束什么
JMM(Java Memory Model,JSR-133,JDK 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 B,B 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在单例里不可替代的原因——它同时解决了可见性和禁止重排序。
不是所有共享变量都要 volatile
volatile解决不了原子性。下面这种计数在并发下仍然错:
private volatile int counter = 0; public void increment() { counter++; // 读-改-写三步,volatile 不保证这三步原子,竞态依旧 }counter++编译成getfield → iadd → putfield,两个线程可能同时getfield拿到 5,都加一写成 6,丢了一次。这种情况该用AtomicInteger(CAS)或synchronized:
private final AtomicInteger counter = new AtomicInteger(0); public void increment() { counter.incrementAndGet(); // 底层 Unsafe.compareAndSwapInt,单次原子完成 }AtomicInteger内部也用volatile(它的value字段是volatile),但它把"读-改-写"封装成一个 CAS 原子指令,所以既能可见又能原子。换句话说,AtomicXxx=volatile+ 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 先写普通变量x=1、再写volatile y=1,线程 B 读volatile y==1之后读x,B 一定看到x==1吗?如果把x也声明成volatile,结论会变吗?欢迎在评论区聊聊你对 happens-before 传递性的理解。