
title: volatile 加了标志位还是停不下来一次主循环不退出的排查把 happens-before 讲明白date: 2026-09-25tags: [Java, JMM, volatile, happens-before, 并发]2024 年做订单状态机 worker我们写了一个用volatile boolean running true控制循环退出的任务。本地测试没问题上线后有一天要重启服务发完停止指令后其中一个 worker 线程死活不退出。JIT 优化后那个线程像是看不到running变成了 false。这个问题最终用volatile解决但中间我们踩了一个更隐蔽的坑另一个字段没有加volatile导致标志位变了但依赖这个字段的状态还是旧的。这篇文章把 JMM 的可见性和 happens-before 源码级地讲清楚。一、事故现场worker 不退出代码大致如下public class OrderWorker implements Runnable { private volatile boolean running true; public void shutdown() { running false; } Override public void run() { while (running) { processOneOrder(); } } }本地和测试环境调用shutdown()后线程都能退出。线上有一个 worker 线程在运行 3 天后调用shutdown()没有退出。我们 dump 线程发现它卡在while (running)循环里。二、为什么 volatile 在这里是必要的JMM 规定没有同步的情况下一个线程修改的变量对另一个线程不一定是立即可见的。CPU 缓存、JIT 编译器的指令重排、寄存器优化都可能导致一个线程一直读到自己缓存里的旧值。volatile的两个语义1. 可见性一个线程对 volatile 变量的修改对其他线程立即可见。2. 禁止指令重排编译器和 CPU 不能对 volatile 写前后的指令做特定类型的重排。看 JVM 对 volatile 的实现在 x86 上volatile 写会插入lock addl $0x0, (%rsp)指令把当前 CPU 缓存行刷新到主内存同时让其他 CPU 的缓存失效。这就是可见性的硬件基础。三、最小复现JIT 优化让线程看不到变化下面这段代码在没有 volatile 时可能永远循环public class VolatileDemo { private boolean running true; public void shutdown() { running false; } public void loop() { while (running) { // do nothing } System.out.println(exit); } public static void main(String[] args) throws Exception { VolatileDemo demo new VolatileDemo(); new Thread(demo::loop).start(); Thread.sleep(1000); demo.shutdown(); } }在 server 模式下运行JIT 可能把while (running)优化成只读一次寄存器后续不再从主内存读取。这样即使shutdown()把running改成 false子线程也看不到。加上volatile后private volatile boolean running true;每次循环都会从主内存读取最新值线程就能正常退出。这里有一个值得注意的细节volatile保证的是可见性不是原子性。如果running是int或者long并且多个线程同时修改仍然需要AtomicInteger或者synchronized。但对于 boolean 标志位来说读写都是原子的所以volatile boolean足够。三、最小复现JIT 优化让线程看不到变化下面这段代码在没有 volatile 时可能永远循环public class VolatileDemo { private boolean running true; public void shutdown() { running false; } public void loop() { while (running) { // do nothing } System.out.println(exit); } public static void main(String[] args) throws Exception { VolatileDemo demo new VolatileDemo(); new Thread(demo::loop).start(); Thread.sleep(1000); demo.shutdown(); } }在 server 模式下运行JIT 可能把while (running)优化成只读一次寄存器后续不再从主内存读取。这样即使shutdown()把running改成 false子线程也看不到。javac编译后的字节码里while (running)对应的是getfield指令。但 JIT 在 C2 编译后可能把running的值缓存到寄存器里因为编译器认为没有其他线程会修改这个字段。加了volatile后JIT 知道每次循环都必须重新从主内存读取就不会做这个优化。可以用-XX:PrintAssembly或者-XX:UnlockDiagnosticVMOptions -XX:PrintCompilation观察 JIT 后的汇编差异。对于普通 boolean汇编里可能只有一次movzbl读取对于 volatile boolean每次循环都有lock addl $0x0或者mfence类似的内存屏障指令。四、第二个坑volatile 只能保证自身可见不能保证依赖字段四、第二个坑volatile 只能保证自身可见不能保证依赖字段标志位问题修复后我们又遇到一个诡异的问题running已经变成 false但 worker 处理的最后一个订单状态是错的。代码类似这样public class Worker { private volatile boolean running true; private OrderConfig config new OrderConfig(); public void updateConfig(OrderConfig newConfig) { config newConfig; } public void shutdown() { running false; } public void run() { while (running) { process(config); } } }调用shutdown()后running的修改对其他线程可见。但config字段没有 volatile也没有同步。主线程调用updateConfig()后worker 线程可能读到旧的 config或者读到部分构造的 config半初始化问题。这就是 JMM 中 happens-before 规则的意义volatile的写 happens-before 后续对同一 volatile 变量的读。但它只能保证这个 volatile 变量本身的可见性不能自动保证其他非 volatile 字段的可见性。如果希望config的修改也对 worker 可见有两个选择把config也声明为volatile。用synchronized或ReentrantReadWriteLock保护 config 的读写。五、happens-before 的八条规则JMM 定义了 happens-before 关系不需要完全理解硬件细节只要满足这些规则就能保证可见性程序次序规则同一个线程中前面的操作 happens-before 后面的操作。锁定规则synchronized解锁 happens-before 后续对同一锁的加锁。volatile 规则volatile 写 happens-before 后续对同一 volatile 的读。线程启动规则Thread.start()happens-before 线程内的每个动作。线程终止规则线程内的所有动作 happens-before 其他线程检测到该线程终止。中断规则对线程interrupt()happens-before 被中断线程检测到中断。对象终结规则构造函数执行 happens-beforefinalize()。传递性如果 A happens-before BB happens-before C那么 A happens-before C。第四条和第五条经常被忽略。比如主线程调用thread.start()start 之前的所有变量修改对子线程都是可见的。子线程执行完毕后主线程调用thread.join()返回后子线程里的所有修改对主线程可见。我用一个例子说明传递性的重要性public class HappensBeforeDemo { private volatile int x 0; private int y 0; public void writer() { y 1; // 1 x 1; // 2: volatile 写 } public void reader() { int r1 x; // 3: volatile 读 int r2 y; // 4 System.out.println(r2); } }根据 happens-before 规则- 语句 1 happens-before 语句 2程序次序规则。- 语句 2 happens-before 语句 3volatile 规则。- 语句 3 happens-before 语句 4程序次序规则。- 由传递性语句 1 happens-before 语句 4。这意味着如果 reader 线程读到x 1那么它一定能看到y 1不会读到y 0。这就是 volatile 的发布效果volatile 写之前的所有普通写对 volatile 读之后的普通读可见。但反过来说如果语句 1 在 volatile 写之后那它就不受保护了。所以正确的写法一定是先把要发布的数据写好再做 volatile 写。六、源码volatile 的 happens-before 如何被 JVM 保证HotSpot JVM 在解释执行和 JIT 编译时对 volatile 变量的访问会插入内存屏障。以 volatile 写为例void MacroAssembler::volatile_store_memreg(...) { // x86 下会用 lock 前缀或 lock addl 指令 lock(); // 执行 store }lock指令的作用是1. 将当前处理器的缓存行写回到主内存。2. 使其他处理器缓存了该内存地址的数据失效。这就是 volatile 写对其他线程可见的硬件保证。七、DCL 单例volatile 另一个经典场景双重检查锁定DCL单例是 volatile 的另一个经典用例public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }如果instance不加 volatilenew Singleton()可能会发生指令重排先分配内存、赋值给引用、再执行构造函数。这样其他线程可能拿到一个还没构造完成的对象。volatile 禁止了这种重排。八、我的取舍判断单纯的状态标志位用volatile boolean足够。如果标志位变化后还要保证其他字段的可见性必须配合 synchronized、AtomicReference 或把相关字段也 volatile。不要迷信 volatile它不是锁不能保证原子性。volatile仍然不是线程安全的。对于复杂状态优先用AtomicReferenceState或ReentrantReadWriteLock不要写一堆 volatile 字段。九、复盘真实数字线上 worker 不退出影响重启耗时从 30 秒增加到 8 分钟根因定位JIT 优化导致非 volatile 字段不可见修复running 加 volatileconfig 改用 AtomicReference验证压测 72 小时循环退出和状态一致性均正常当时的完整修复代码public class OrderWorker implements Runnable { private volatile boolean running true; private final AtomicReferenceOrderConfig configRef new AtomicReference(new OrderConfig()); public void shutdown() { running false; } public void updateConfig(OrderConfig newConfig) { configRef.set(newConfig); } Override public void run() { while (running) { process(configRef.get()); } } private void process(OrderConfig config) { // 业务处理 } }用AtomicReference包装OrderConfig有两个好处一是set和get都是原子操作二是AtomicReference内部用了 volatile 语义保证了引用的可见性。但要注意的是OrderConfig对象本身如果会被修改内部字段仍然需要 volatile 或者不可变设计。十、思考题你的项目里有没有用 boolean 标志位控制线程退出有没有加 volatile欢迎在评论区分享。