一起来解剖一段看似简单、实则暗藏玄机的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 = 42 happens-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!
提示:在服务器模式(-server JVM)或某些CPU架构(如ARM)上,问题更容易复现。
深层思考:由此学到了什么?
|
误区 |
真相 |
|
“变量赋值是原子的,所以没问题” |
原子性 ≠ 可见性。boolean/int 赋值是原子的,但其他线程看不到! |
|
“Thread.yield() 能让线程‘同步’” |
yield() 是线程调度提示,无内存语义,不能替代同步。 |
|
“代码顺序 = 执行顺序” |
编译器、CPU、JIT 都会重排序——除非你用 volatile/synchronized 禁止。 |
|
“单核CPU不会有这问题” |
单核也可能缓存不一致!且现代基本都是多核。 |
关键总结:
- 共享可变状态 必须考虑线程安全;
- volatile 不只是“防重排序”,更是建立 happens-before 的轻量级工具;
- 一个 volatile flag,可带动一批普通变量的可见性——这是高效并发设计的基石;
- 测试多线程bug不能靠“跑几次没事”,而要靠理论保证。
深入解析Java并发可见性陷阱
203

被折叠的 条评论
为什么被折叠?



