一个经典案例深入剖析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;               // 再“通知”
    }
}

程序的“预期”逻辑很简单:

  1. 启动一个线程 ReaderThread,它不断检查 ready 是否为 true
  2. 主线程将 number 设为 42,再将 ready 设为 true,表示“数据已就绪”;
  3. 读线程看到 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 = 42ready = true 之间 没有 happens-before 关系
  • 主线程写 ready 与读线程读 ready 之间 也没有 happens-before 关系

结果就是:一切皆有可能(042、死循环)——典型的竞态条件(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 变量的读操作。”

这意味着:

  1. 主线程执行 ready = true(volatile 写);
  2. ReaderThread 执行 if (!ready)(volatile 读)并看到 true
  3. 根据 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());

AtomicXxxget()/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不会有这问题” 单核也可能缓存不一致!且现代基本都是多核。

🎯 关键总结:

  1. 共享可变状态 必须考虑线程安全;
  2. volatile 不只是“防重排序”,更是建立 happens-before 的轻量级工具;
  3. 一个 volatile flag,可带动一批普通变量的可见性——这是高效并发设计的基石;
  4. 测试多线程bug不能靠“跑几次没事”,而要靠理论保证

📖 延伸阅读

posted @ 2025-11-07 09:19  愚生浅末  阅读(16)  评论(0)    收藏  举报