你是否遇到过这样的场景:你正在学习《Java并发编程实战》这样的经典书籍,满怀期待地将一个“会出错”的并发示例代码在自己的电脑上运行时,却发现……它每次都能完美运行!书上言之凿凿的无限循环、数据错乱等问题,在你强大的 i7/i9 处理器和最新的IDE面前,消失得无影无踪。

这究竟是书本理论过时了,还是我们的认知出现了偏差?

本文将以《Java并发编程实战》中经典的 NoVisibility 程序清单为例,深入探讨这个现象。这不仅是一个关于代码的问题,更是一次深入理解Java内存模型(JMM)、指令重排序和内存可见性的绝佳机会。

梦开始的地方:经典的 NoVisibility

让我们先回顾一下这段经典的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 程序清单3-1 from "Java Concurrency in Practice"
public class NoVisibility {
private static boolean ready;
private static int number;

private static class ReaderThread extends Thread {
public void run() {
while (!ready) {
Thread.yield(); // 让出CPU,但并不能保证可见性
}
System.out.println(number);
}
}

public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}

代码意图: main 线程启动 ReaderThread 后,准备数据(number = 42),然后将标志位 ready 设为 trueReaderThread 则不断地循环,等待 ready 变为 true 后,打印出 number 的值。

理论上的“幽灵” Bug:
根据 JMM 的规范,由于缺少任何同步措施(如 synchronizedvolatile),这段代码存在两个致命风险:

  1. 可见性(Visibility)问题main 线程对 readynumber 的修改可能仅仅停留在自己所在CPU核心的缓存中,没有及时写回主内存。导致 ReaderThread 所在的CPU核心一直无法“看到”ready 已经变为 true,从而陷入无限循环。
  2. 重排序(Reordering)问题:为了优化性能,编译器和处理器可能会对指令进行重排序。main 线程中的 ready = true; 可能会被重排到 number = 42; 之前执行。如果 ReaderThread 恰好在重排序后、number 被赋值前读取数据,它会看到 readytrue,跳出循环,但打印出的 number 却是其默认初始值 0

然而,当我们在自己的现代化开发环境(例如 Windows 11 + IntelliJ IDEA + JDK 8以上)中运行时,几乎每次得到的输出都是正确的 42。这是为什么?

揭开“幸运”的面纱:为何Bug没有出现?

理论风险真实存在,但它在我们日常的简单测试中被一系列“现代优势”给掩盖了。

1. 过于强大的现代CPU和缓存一致性协议

现代多核CPU(Intel Core, AMD Ryzen等)都内置了非常高效的缓存一致性协议(例如 MESI)。该协议会尽力确保一个核心的缓存被修改后,这个修改能尽快地对其他核心可见。虽然Java内存模型(JMM)在理论上允许数据不一致,但硬件层面的“努力”使得在执行时间极短的简单程序里,数据能够快速同步。

2. “慢半拍”的线程调度

这是最关键的原因之一。请看 main 线程的操作:
new ReaderThread().start(); -> number = 42; -> ready = true;

这三步操作对于现代CPU来说,快得惊人,可能在微秒甚至纳秒级别就完成了。

new ReaderThread().start() 之后,操作系统需要进行线程的创建、初始化和调度,再到 ReaderThreadrun() 方法真正在某个CPU核心上跑起来,这个过程的耗时通常远大于 main 线程完成两句赋值语句的时间。

结果就是,绝大多数情况下,当 ReaderThread 开始执行 while 循环时,main 线程早已将一切准备就绪,并且数据变更也通过缓存一致性协议对所有核心可见了。 竞争条件(Race Condition)依然存在,但 main 线程每次都以压倒性优势“获胜”。

3. “保守”的JVM和JIT编译器

对于这种运行时间极短、调用次数极少的“玩具程序”,HotSpot JVM 可能都懒得启动它强大的即时编译器(JIT)进行深度优化。代码很可能是在解释模式下执行的,其行为更贴近我们编写的顺序,发生激进重排序的概率大大降低。

如何请出“幽灵”:正确的并发之道

这本书的重点,从来不是教我们如何去复现Bug,而是警告我们:永远不要编写依赖“运气”的并发代码!

在开发测试环境中的“每次都成功”,恰恰是这类并发Bug最阴险的地方。它们如同潜伏的幽灵,在低负载下悄无声息,一旦到了高负载、硬件环境复杂的生产服务器上,就可能在某个意想不到的时刻突然现身,造成难以追踪的错误。

正确的修复:使用 volatile

要根除这个隐患,最简单的方式就是为共享变量 ready 加上 volatile 关键字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class VisibilityFixed {
// 使用 volatile 保证可见性和一定的有序性
private static volatile boolean ready;
private static int number;

// ... ReaderThread 和 main 方法不变 ...
private static class ReaderThread extends Thread {
public void run() {
while (!ready) {
Thread.yield();
}
System.out.println(number);
}
}

public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}

volatile 做了什么?

  1. 保证可见性:当一个线程写入一个 volatile 变量时,JMM会强制将该变量的修改立刻刷新回主内存。当另一个线程读取这个 volatile 变量时,JMM会强制它从主内存中读取,而不是依赖本地缓存。
  2. 保证有序性(通过内存屏障)volatile 会阻止其前后的指令发生重排序。具体到本例,对 volatile 变量 ready 的写入 (ready = true),与它之前的普通变量写入 (number = 42) 之间会形成一道“屏障”,禁止 number = 42 被重排到 ready = true 之后。这确保了只要 ReaderThread 看到了 readytrue,那么 number 的值也一定已经被正确设置为 42

总结

你的电脑没有骗你,书本的知识更是真理。你观察到的现象完美地诠释了并发Bug的本质:它们是概率性的,而非确定性的。

我们从这次的探索中应该得到的深刻教训是:

在编写并发程序时,我们的目标不是“它在我电脑上能跑”,而是“它在理论上无懈可击”。

只要存在多个线程共享可变数据,就必须使用正确的同步机制(如 volatile, synchronizedjava.util.concurrent 包下的工具)来明确定义线程间的执行顺序和数据可见性。这才是专业开发者告别“侥幸”,写出健壮并发程序的唯一途径。