承接的问题
2.3 用 JCStress 复现了计数器丢更新,那里的问题是两个线程的读和写交错,属于原子性。
但并发 bug 还有另一类:两个线程根本没有同时读写同一时刻,代码却仍然出错。最典型的症状是一个线程已经把 running 改成 false,另一个线程的 while (running) 却永远不退出。加一句 System.out.println 就好了,去掉又坏;在开发机上跑一百次都正常,上线后偶发卡死。
这一节回答三个问题:
- 一行
running = false之后,另一个线程为什么可能看不见? - 编译器、CPU、缓存各自做了什么,谁该负责?
- 为什么
volatile能修好它,而Thread.sleep、打印日志不能作为修复手段?
第 3.2 节会把这些现象收敛成 happens-before 规则,第 3.3 节处理对象发布。本节先把「看不见」这件事拆到足够具体。
三个独立但常一起出现的问题
「重排序」「可见性」「有序性」经常被混着说,其实是三个不同层次的机制。
| 层次 | 谁在做 | 表现 | 典型手段 |
|---|---|---|---|
| 编译器重排序 | javac、C1、C2 | 读被提到循环外、死代码被删 | 不依赖顺序的优化 |
| CPU 乱序 | 流水线、Store Buffer | 写先进入缓冲区,读越过写 | 内存屏障指令 |
| 缓存可见性 | 每个核的缓存、寄存器 | 一个核写了,另一个核还读旧值 | 缓存一致性协议 + 屏障 |
编译器重排序
javac 的优化很保守,真正激进的是 JIT。C2 在方法被调用足够多次后,会做内联、循环不变量外提、公共子表达式消除、死存储消除。
对单线程来说,这些优化必须保持「好像按源代码顺序执行」的结果,这叫 as-if-serial。问题在于,这个「好像」只对当前线程成立。
int a = 1; // 写 A
int b = 2; // 写 B
int c = a + b; // 读 A、B
A 和 B 谁先写,单线程都看不到区别,所以编译器可以交换。但如果另一个线程在观察这两个变量,交换就会变成可观测的差异。
更隐蔽的是循环里的读。下面这段代码:
while (running) {
i++;
}
如果循环体没有写 running,也没有方法调用或同步点,C2 可以判断「每次读到的值都一样」,把读提升到循环外,只读一次。循环于是变成 while (true) i++。这不是编译器有 bug,而是它被允许这样做,因为原始程序存在数据竞争,JMM 不保证结果。
CPU 乱序与 Store Buffer
即使编译器不重排,CPU 也可能让写「迟到」。现代处理器为了填满流水线,会把写先放进 Store Buffer,等总线有空再刷出去;读则可以越过尚未提交的写,从缓存或寄存器直接取。
x86 是较强的内存模型(TSO),只允许 StoreLoad 一种重排;ARM、Power 是弱内存模型,四种重排都可能发生。Apple M1 Pro 属于 ARM 体系,这也是同一段 Java 代码在 M1 和 x86 服务器上表现不同的原因之一。
JMM 不绑定具体硬件,它只规定「允许看到什么结果」。实现层面用四类屏障兜底:
| 屏障 | 约束 | 常见位置 |
|---|---|---|
| LoadLoad | 前面的读先于后面的读 | volatile 读之后 |
| StoreStore | 前面的写先于后面的写 | volatile 写之前 |
| LoadStore | 前面的读先于后面的写 | volatile 读写两侧 |
| StoreLoad | 前面的写先于后面的读 | volatile 写之后,最贵 |
HotSpot 在 x86 上会把大部分屏障编译成 no-op,只在 volatile 写后保留 lock addl;在 ARM 上则要生成真实的 dmb ish 一类指令。所以「加 volatile 会变慢」在 ARM 上更明显,但两种架构上的语义完全相同。
缓存可见性
每个核有自己的 L1/L2,共享 L3 和内存。MESI 协议保证缓存行最终一致,但「最终」不等于「立刻」。一个核写入后,另一个核的缓存行要收到失效消息才会重新加载。如果读线程把变量缓存在寄存器里,连缓存行都不会再读。
这里要区分两件事:
- 缓存一致性解决的是「同一地址的多个副本何时统一」;
- 可见性解决的是「一个线程的写,何时对另一个线程的读可见」。
后者不是硬件自动给出的,需要程序用同步动作建立顺序。JMM 把这件事交给 happens-before,而不是交给 CPU。
as-if-serial 只对单线程负责
as-if-serial 的准确说法是:不管怎么重排,单线程执行的结果必须和完全按源代码顺序执行一致。
它保护的是:
double pi = 3.1415926;
double r = 2.0;
double area = pi * r * r; // 必须读到 pi 和 r 的赋值结果
area 依赖 pi、r,存在数据依赖,不能被提到赋值之前。没有依赖的语句则可以交换。
它不保护的是:
// 线程 A
data = 42;
ready = true;
// 线程 B
if (ready) {
System.out.println(data);
}
线程 A 内部,data = 42 和 ready = true 没有数据依赖,编译器可以交换;CPU 也可能让 ready 的写先被另一个核看到。线程 B 看到 ready == true 时,data 可能仍是 0。
ready 是普通变量,两个线程之间没有任何同步动作,这段程序存在数据竞争。JMM 对数据竞争程序的承诺只有一句:行为不确定,但不会凭空创造值。它不承诺你想要的顺序。
一句话记:as-if-serial 保证「我自己看自己」永远正确,不保证「别人看我」也正确。单线程测试、main 里的顺序执行、加了日志的调试运行,都可能完全正常;多线程视角的顺序必须由锁、volatile、线程启动/终止等同步动作建立。
停机标志:一个必须亲手跑的实验
下面是可以直接编译运行的最小程序。它故意把「读标志」和「写标志」放在两个线程里,中间只有 Thread.sleep,没有任何同步。
public class StopFlagPlain {
private static boolean running = true;
public static void main(String[] args) throws Exception {
Thread worker = new Thread(() -> {
long i = 0;
while (running) {
i++;
}
System.out.println("worker stopped, i=" + i);
}, "worker");
worker.setDaemon(true);
worker.start();
Thread.sleep(1000);
running = false;
System.out.println("main set running=false");
worker.join(3000);
System.out.println("worker alive after join(3000): " + worker.isAlive());
}
}
编译并运行,用 10 秒看门狗兜底,避免真的挂死:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
$JAVA_HOME/bin/javac -d . StopFlagPlain.java
( $JAVA_HOME/bin/java -cp . StopFlagPlain & pid=$!; \
( sleep 10; kill -9 $pid 2>/dev/null ) & \
wait $pid )
本机一次运行的输出(Apple M1 Pro,10 核,32 GB,macOS 26.3,Temurin 25.0.5+8):
main set running=false
worker alive after join(3000): true
worker alive after join(3000): true 说明主线程等了三秒,工作线程仍在跑。它不是因为 join 失效,而是因为工作线程根本没看到 running 已经变成 false。
为什么它会停不下来
JIT 在 worker 方法热起来之后,常见做法是把 running 的读提升到循环外。对 worker 线程来说,循环体只改局部变量 i,没有任何东西能让它怀疑 running 会变,于是循环退化成死循环。具体是 C1 还是 C2 做的、在哪一刻做,取决于运行情况;能确定的是这段代码存在数据竞争,JMM 允许这种优化。
注意 worker.setDaemon(true) 的作用:它让 JVM 在主线程结束后能退出,否则这个程序会一直挂着。这是实验保护,不是修复。生产代码不能靠 daemon 线程「反正进程会退出」来掩盖标志位不生效。
加上 volatile 的对照
只改一个修饰符:
public class StopFlagVolatile {
private static volatile boolean running = true;
public static void main(String[] args) throws Exception {
Thread worker = new Thread(() -> {
long i = 0;
while (running) {
i++;
}
System.out.println("worker stopped, i=" + i);
}, "worker");
worker.setDaemon(true);
worker.start();
Thread.sleep(1000);
running = false;
System.out.println("main set running=false");
worker.join(3000);
System.out.println("worker alive after join(3000): " + worker.isAlive());
}
}
同样的运行方式,本机输出:
main set running=false
worker stopped, i=1711024757
worker alive after join(3000): false
i 的具体数值每次不同,取决于这一秒里循环跑了多少次,不必对齐。
volatile 做了两件事:
- 每次读都从主内存/缓存一致性协议允许的最新副本读,编译器不能把读提出循环;
- 写
running = false与后续读running之间建立 happens-before,写之前的动作对读到新值的线程可见。
它没有做的一件事是保证 i++ 原子。本实验里 i 只有一个线程写,所以没问题;如果两个线程都写 i,volatile 仍然会丢更新。
这个实验不能承诺稳定复现
必须把话说清楚:可见性 bug 是否出现,取决于 JIT 是否已经编译、编译到哪一层、CPU 架构、核数、运行时长、是否有其他同步动作。
同一个程序可能出现这些结果:
| 运行条件 | 可能结果 |
|---|---|
解释执行(-Xint) | 通常能正常退出,因为每次都真的读 |
| C1 编译 | 可能退出,也可能不退出 |
| C2 编译后 | 更容易把读提出循环 |
循环里有 println | 打印是同步点,通常会退出 |
| 单核机器 | 调度切换可能让写可见,现象减弱 |
所以:
- 本机复现了,不代表你的机器一定复现;
- 本机没复现,不代表代码是对的;
- 不要用「我跑了一百次都没事」作为并发正确性的证据。
如果在一台机器上没复现,可以尝试 -Xcomp 强制编译、延长运行时间、把循环体改得更纯粹(去掉方法调用),但依然不能保证。正确做法不是想办法复现,而是用 3.2 的规则证明它为什么错。
在 libraryd-conc 里落地
把停机标志放回借阅服务:库存巡检线程每隔一段时间扫描低库存图书,主程序关闭时需要让它停下来。
错误写法是把 running 写成普通字段:
private boolean running = true; // 错误:跨线程读写却没有同步
巡检线程被 JIT 编译后,running 的读可能被提出 while。Thread.sleep(200) 是方法调用,通常会阻止最激进的优化,但它不是同步动作,JMM 不保证它建立可见性。换句话说,它可能「碰巧能跑」,但不是正确。
正确版本只改两处:字段加 volatile,stop 之后再补一个 join 让调用方确认线程真的结束。
package com.plume.conc.core;
import java.util.List;
import java.util.Optional;
public final class LowStockMonitor implements Runnable {
private final BookRepository repository;
private final List<String> isbns;
private final int threshold;
private volatile boolean running = true;
private volatile Thread worker;
public LowStockMonitor(BookRepository repository, List<String> isbns, int threshold) {
this.repository = repository;
this.isbns = List.copyOf(isbns);
this.threshold = threshold;
}
public synchronized void start() {
if (worker != null) {
throw new IllegalStateException("monitor already started");
}
worker = Thread.ofPlatform().name("low-stock-monitor").daemon(true).start(this);
}
public void stop() throws InterruptedException {
running = false;
Thread current = worker;
if (current != null) {
current.join(1000);
}
}
@Override
public void run() {
while (running) {
for (String isbn : isbns) {
Optional<Book> found = repository.findByIsbn(isbn);
found.filter(book -> book.stock() < threshold)
.ifPresent(book -> System.out.println("low stock: " + book.isbn()
+ " left=" + book.stock()));
}
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
System.out.println("monitor stopped");
}
}
这里依然保留 volatile,因为 stop() 和 run() 在两个线程里。start() 用 synchronized 避免重复启动,worker 本身是 volatile,stop() 才能可靠读到启动后的引用;join(1000) 是给调用方的确认,不是可见性的替代品。
如果巡检逻辑更复杂,比如需要「停止时把当前批次处理完」,就不该只靠一个布尔值,而要用第 6 章的两阶段终止:volatile 标志负责「不再接新任务」,interrupt() 负责唤醒阻塞,join() 负责等待收尾。三个动作各司其职。
常见错误与边界
| 写法 | 为什么不对 | 正确做法 |
|---|---|---|
普通 boolean 做跨线程标志 | 读可能被提出循环 | volatile boolean |
用 Thread.sleep 代替同步 | sleep 不建立 happens-before | 加 volatile 或锁 |
循环里 System.out.println | 打印是同步点,掩盖 bug | 去掉打印后再验证 |
volatile int i 然后 i++ | 复合操作不原子 | AtomicInteger 或锁 |
volatile int[] a 然后改元素 | 只保证引用可见,不保证元素 | AtomicIntegerArray 或锁 |
Thread.stop() 停线程 | JDK 25 调用抛 UnsupportedOperationException | 标志位 + interrupt + join |
什么时候不要用 volatile
- 需要「读-改-写」的复合操作,比如库存扣减、计数器自增;
- 需要多个字段一起变化,比如
(x, y)必须同时更新,volatile只能分别保证两个字段的可见性,不能保证组合的原子性; - 已经在一把锁的保护范围内,再加
volatile只会增加屏障开销,不增加正确性; - 需要阻塞等待条件成立,应该用
wait/notify或Condition(第 5 章),而不是忙等一个volatile标志。
另外两个容易忽略的边界:
第一,不要用 volatile 修数据竞争。 如果两个线程都写同一个变量,volatile 只保证每次读能看到「某个」最新写,不保证不丢更新。2.3 的计数器就是例子。
第二,可见性问题不会在短命程序里稳定出现。 一个只跑几十毫秒的测试,JIT 可能还没编译到 C2,读每次都真的发生,测试通过;线上跑几小时后才卡死。这类 bug 的测试策略不是「多跑几遍」,而是代码评审时检查每个跨线程共享的可变字段有没有同步动作。
小结
- 重排序来自编译器、CPU 和缓存三个层次,
as-if-serial只保证单线程结果,不保证多线程观察到的顺序。 - 可见性丢失的典型症状是「写已经发生,读线程还在用旧值」,停机标志是最小复现。
- 本机用 Temurin 25.0.5+8 在 M1 Pro 上复现了无
volatile版本不退出,加volatile后正常退出;但复现与否依赖 JIT、CPU 和运行时长,不能承诺稳定复现。 volatile解决可见性和禁止特定重排,不解决复合操作的原子性。- 判断并发正确性要靠 3.2 的 happens-before 规则推导,不能靠「跑了很多次没事」。
阅读导航:上一节 2.3 用 JCStress 复现计数器竞态 | 下一节 3.2 happens-before 八条规则
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。