一个经典案例深入剖析Java并发中的“可见性”陷阱
“你以为程序按顺序执行,但CPU和JVM说:不,我们有自己的想法。”
一起来解剖一段看似简单、实则暗藏玄机的Java代码。它只有20行,却浓缩了多线程编程中最经典、最易被忽视的陷阱——可见性(Visibility)问题与指令重排序(Reordering)。
它来自《Java并发编程实战》(JCIP)的经典示例,也是无数面试题的源头。
🔍 代码原貌:平静下的风暴
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
@Override
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; // 再“通知”
}
}
程序的“预期”逻辑很简单:
- 启动一个线程
ReaderThread,它不断检查ready是否为true; - 主线程将
number设为 42,再将ready设为true,表示“数据已就绪”; - 读线程看到
ready == true后,打印number,理应输出 42。
❓但现实呢?
多次运行,你可能会看到:
42✅(幸运时刻)0⚠️(高频出现!)- 或者……程序永远卡住不退出(需手动
Ctrl+C)💥
🤔 这段代码没有
synchronized,没有锁,没有异常——它“语法正确”,却“语义错误”。问题出在哪?
🌪️ 问题根源:Java内存模型(JMM)的三重“背叛”
1️⃣ 缓存不一致:可见性缺失
现代CPU为提升性能,每个线程都有自己的工作内存(高速缓存)。对共享变量的读写,可能只发生在本地缓存,不立即同步到主内存。
- 主线程修改了
ready = true,但这个值可能还“躺”在它的缓存里; ReaderThread的缓存里ready仍是false→ 无限循环;- 即便它看到了
ready == true,它的缓存里number可能还是初始值0→ 打印0。
⚠️
Thread.yield()只是建议线程让出CPU时间片,并不触发缓存刷新!它无法解决可见性问题。
2️⃣ 编译器与CPU的“自作聪明”:指令重排序
为优化性能,JVM 和 CPU 在不改变单线程语义的前提下,允许重排指令顺序:
// 你写的:
number = 42;
ready = true;
// 实际执行的,可能是:
ready = true; // 先执行!
number = 42; // 后执行!
对主线程自己来说,结果一样;但对 ReaderThread 而言,它可能在 ready 变成 true 的瞬间跳出循环,此时 number 还没被写入——于是读到 0。
📌 重排序是合法的,只要你没用同步机制“约束”它。
3️⃣ 缺乏“happens-before”保证
Java 内存模型用 happens-before 规则定义操作间的可见性顺序。若操作 A happens-before 操作 B,则 A 的结果对 B 一定可见。
而上述代码中:
number = 42与ready = true之间 没有 happens-before 关系;- 主线程写
ready与读线程读ready之间 也没有 happens-before 关系。
结果就是:一切皆有可能(0、42、死循环)——典型的竞态条件(Race Condition)。
✅ 正确解法:建立“因果律”
要让 ReaderThread 在看到 ready == true 时 必然 看到 number == 42,我们必须建立明确的 happens-before 边界。
✅ 方案一:volatile —— 最简洁优雅(推荐!)
private static volatile boolean ready; // ← 只需加在这里!
private static int number; // number 可以不加 volatile
为什么有效?
Java 内存模型规定:
“对一个 volatile 变量的写操作 happens-before 后续对这个 volatile 变量的读操作。”
这意味着:
- 主线程执行
ready = true(volatile 写); ReaderThread执行if (!ready)(volatile 读)并看到true;- 根据 happens-before 规则:
number = 42→(程序顺序)→ready = true(volatile写)
→(volatile规则)→ready读取为true
⇒ 所以number = 42happens-before 读取number!
✅ number 即便不是 volatile,也能被正确看到为 42!
🌟 这就是
volatile的“内存可见性传递性”:一个 volatile 写,能“捎带”它之前所有普通写操作的可见性 。
✅ 方案二:synchronized —— 重量级但通用
private static final Object lock = new Object();
// ReaderThread 中:
while (!ready) {
synchronized (lock) { } // 空同步块,只为建立同步边
Thread.yield();
}
// main 中:
synchronized (lock) {
number = 42;
ready = true;
}
synchronized 天然提供:
- 互斥访问(此处非必需);
- 进入/退出同步块时的内存屏障,刷新缓存,禁止重排序;
- 明确的 happens-before:释放锁 happens-before 获取同一把锁。
✅ 方案三:AtomicBoolean / AtomicInteger
private static final AtomicBoolean ready = new AtomicBoolean(false);
private static final AtomicInteger number = new AtomicInteger(0);
// main:
number.set(42);
ready.set(true);
// ReaderThread:
while (!ready.get()) {
Thread.yield();
}
System.out.println(number.get());
AtomicXxx 的 get()/set() 默认具有 volatile 语义(除 lazySet),同样满足 happens-before 。
🧪 实验验证:眼见为实
你可以在本地反复运行原版代码:
for i in {1..10}; do java NoVisibility; done
# 很可能混杂着 0 和 42,甚至卡住
再运行修复版(加 volatile):
for i in {1..10}; do java FixedNoVisibility; done
# 稳定输出 42!
💡 提示:在服务器模式(
-serverJVM)或某些CPU架构(如ARM)上,问题更容易复现。
📚 深层思考:由此学到了什么?
| 误区 | 真相 |
|---|---|
| “变量赋值是原子的,所以没问题” | 原子性 ≠ 可见性。boolean/int 赋值是原子的,但其他线程看不到! |
“Thread.yield() 能让线程‘同步’” |
yield() 是线程调度提示,无内存语义,不能替代同步。 |
| “代码顺序 = 执行顺序” | 编译器、CPU、JIT 都会重排序——除非你用 volatile/synchronized 禁止。 |
| “单核CPU不会有这问题” | 单核也可能缓存不一致!且现代基本都是多核。 |
🎯 关键总结:
- 共享可变状态 必须考虑线程安全;
volatile不只是“防重排序”,更是建立 happens-before 的轻量级工具;- 一个
volatileflag,可带动一批普通变量的可见性——这是高效并发设计的基石; - 测试多线程bug不能靠“跑几次没事”,而要靠理论保证。
📖 延伸阅读
- 📘 《Java Concurrency in Practice》第3章 “Sharing Objects”
- 📜 JSR-133: Java Memory Model and Thread Specification
- 🌐 Java Language Specification §17.4.5. Happens-before Order


浙公网安备 33010602011771号