☰
volatile 加了标志位还是停不下来:一次主循环不退出的排查,把 happens-before 讲明白
2026/9/26 2:42:39 网站建设 项目流程

title: volatile 加了标志位还是停不下来:一次主循环不退出的排查,把 happens-before 讲明白
date: 2026-09-25
tags: [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 可见,有两个选择:

  1. 把config也声明为volatile。
  2. 用synchronized或ReentrantReadWriteLock保护 config 的读写。

五、happens-before 的八条规则

JMM 定义了 happens-before 关系,不需要完全理解硬件细节,只要满足这些规则,就能保证可见性:

  1. 程序次序规则:同一个线程中,前面的操作 happens-before 后面的操作。
  2. 锁定规则:synchronized解锁 happens-before 后续对同一锁的加锁。
  3. volatile 规则:volatile 写 happens-before 后续对同一 volatile 的读。
  4. 线程启动规则:Thread.start()happens-before 线程内的每个动作。
  5. 线程终止规则:线程内的所有动作 happens-before 其他线程检测到该线程终止。
  6. 中断规则:对线程interrupt()happens-before 被中断线程检测到中断。
  7. 对象终结规则:构造函数执行 happens-beforefinalize()。
  8. 传递性:如果 A happens-before B,B 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 语句 3(volatile 规则)。
- 语句 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不加 volatile,new Singleton()可能会发生指令重排:先分配内存、赋值给引用、再执行构造函数。这样其他线程可能拿到一个还没构造完成的对象。volatile 禁止了这种重排。

八、我的取舍判断

  1. 单纯的状态标志位,用volatile boolean足够。
  2. 如果标志位变化后还要保证其他字段的可见性,必须配合 synchronized、AtomicReference 或把相关字段也 volatile。
  3. 不要迷信 volatile,它不是锁,不能保证原子性。volatile++仍然不是线程安全的。
  4. 对于复杂状态,优先用AtomicReference<State>或ReentrantReadWriteLock,不要写一堆 volatile 字段。

九、复盘真实数字

  • 线上 worker 不退出影响:重启耗时从 30 秒增加到 8 分钟
  • 根因定位:JIT 优化导致非 volatile 字段不可见
  • 修复:running 加 volatile,config 改用 AtomicReference
  • 验证:压测 72 小时,循环退出和状态一致性均正常

当时的完整修复代码:

public class OrderWorker implements Runnable { private volatile boolean running = true; private final AtomicReference<OrderConfig> 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?欢迎在评论区分享。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询