并发的“幽灵”:为何书中的并发错误在我电脑上无法复现?
你是否遇到过这样的场景:你正在学习《Java并发编程实战》这样的经典书籍,满怀期待地将一个“会出错”的并发示例代码在自己的电脑上运行时,却发现……它每次都能完美运行!书上言之凿凿的无限循环、数据错乱等问题,在你强大的 i7/i9 处理器和最新的IDE面前,消失得无影无踪。
这究竟是书本理论过时了,还是我们的认知出现了偏差?
本文将以《Java并发编程实战》中经典的 NoVisibility 程序清单为例,深入探讨这个现象。这不仅是一个关于代码的问题,更是一次深入理解Java内存模型(JMM)、指令重排序和内存可见性的绝佳机会。
梦开始的地方:经典的 NoVisibility
让我们先回顾一下这段经典的代码:
1 | // 程序清单3-1 from "Java Concurrency in Practice" |
代码意图: main 线程启动 ReaderThread 后,准备数据(number = 42),然后将标志位 ready 设为 true。ReaderThread 则不断地循环,等待 ready 变为 true 后,打印出 number 的值。
理论上的“幽灵” Bug:
根据 JMM 的规范,由于缺少任何同步措施(如 synchronized 或 volatile),这段代码存在两个致命风险:
- 可见性(Visibility)问题:
main线程对ready和number的修改可能仅仅停留在自己所在CPU核心的缓存中,没有及时写回主内存。导致ReaderThread所在的CPU核心一直无法“看到”ready已经变为true,从而陷入无限循环。 - 重排序(Reordering)问题:为了优化性能,编译器和处理器可能会对指令进行重排序。
main线程中的ready = true;可能会被重排到number = 42;之前执行。如果ReaderThread恰好在重排序后、number被赋值前读取数据,它会看到ready是true,跳出循环,但打印出的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() 之后,操作系统需要进行线程的创建、初始化和调度,再到 ReaderThread 的 run() 方法真正在某个CPU核心上跑起来,这个过程的耗时通常远大于 main 线程完成两句赋值语句的时间。
结果就是,绝大多数情况下,当 ReaderThread 开始执行 while 循环时,main 线程早已将一切准备就绪,并且数据变更也通过缓存一致性协议对所有核心可见了。 竞争条件(Race Condition)依然存在,但 main 线程每次都以压倒性优势“获胜”。
3. “保守”的JVM和JIT编译器
对于这种运行时间极短、调用次数极少的“玩具程序”,HotSpot JVM 可能都懒得启动它强大的即时编译器(JIT)进行深度优化。代码很可能是在解释模式下执行的,其行为更贴近我们编写的顺序,发生激进重排序的概率大大降低。
如何请出“幽灵”:正确的并发之道
这本书的重点,从来不是教我们如何去复现Bug,而是警告我们:永远不要编写依赖“运气”的并发代码!
在开发测试环境中的“每次都成功”,恰恰是这类并发Bug最阴险的地方。它们如同潜伏的幽灵,在低负载下悄无声息,一旦到了高负载、硬件环境复杂的生产服务器上,就可能在某个意想不到的时刻突然现身,造成难以追踪的错误。
正确的修复:使用 volatile
要根除这个隐患,最简单的方式就是为共享变量 ready 加上 volatile 关键字。
1 | public class VisibilityFixed { |
volatile 做了什么?
- 保证可见性:当一个线程写入一个
volatile变量时,JMM会强制将该变量的修改立刻刷新回主内存。当另一个线程读取这个volatile变量时,JMM会强制它从主内存中读取,而不是依赖本地缓存。 - 保证有序性(通过内存屏障):
volatile会阻止其前后的指令发生重排序。具体到本例,对volatile变量ready的写入 (ready = true),与它之前的普通变量写入 (number = 42) 之间会形成一道“屏障”,禁止number = 42被重排到ready = true之后。这确保了只要ReaderThread看到了ready为true,那么number的值也一定已经被正确设置为42。
总结
你的电脑没有骗你,书本的知识更是真理。你观察到的现象完美地诠释了并发Bug的本质:它们是概率性的,而非确定性的。
我们从这次的探索中应该得到的深刻教训是:
在编写并发程序时,我们的目标不是“它在我电脑上能跑”,而是“它在理论上无懈可击”。
只要存在多个线程共享可变数据,就必须使用正确的同步机制(如 volatile, synchronized 或 java.util.concurrent 包下的工具)来明确定义线程间的执行顺序和数据可见性。这才是专业开发者告别“侥幸”,写出健壮并发程序的唯一途径。


