一个经典案例深入分析Java并发中的“可见性”陷阱 [复制链接]
发表于 2025-11-7 07:18:44 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×
“你以为步伐按序次实行,但CPU和JVM说:不,我们有自己的想法。”
一起来解剖一段看似简单、实则暗藏玄机的Java代码。它只有20行,却浓缩了多线程编程中最经典、最易被忽视的陷阱——可见性(Visibility)题目与指令重排序(Reordering)
它来自《Java并发编程实战》(JCIP)的经典示例,也是无数口试题的源头。
🔍 代码原貌:寂静下的风暴
  1. public class NoVisibility {
  2.     private static boolean ready;
  3.     private static int number;
  4.     private static class ReaderThread extends Thread {
  5.         @Override
  6.         public void run() {
  7.             while (!ready) {
  8.                 Thread.yield(); // 礼貌地让出CPU
  9.             }
  10.             System.out.println(number);
  11.         }
  12.     }
  13.     public static void main(String[] args) {
  14.         new ReaderThread().start(); // 启动读线程
  15.         number = 42;                // 先赋值
  16.         ready = true;               // 再“通知”
  17.     }
  18. }
复制代码
步伐的“预期”逻辑很简单:


  • 启动一个线程 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 在不改变单线程语义的条件下,允许重排指令序次:
  1. // 你写的:
  2. number = 42;
  3. ready = true;
  4. // 实际执行的,可能是:
  5. ready = true;   // 先执行!
  6. 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 —— 最轻巧优雅(保举!)
  1. private static volatile boolean ready; // ← 只需加在这里!
  2. 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 —— 重量级但通用
  1. private static final Object lock = new Object();
  2. // ReaderThread 中:
  3. while (!ready) {
  4.     synchronized (lock) { } // 空同步块,只为建立同步边
  5.     Thread.yield();
  6. }
  7. // main 中:
  8. synchronized (lock) {
  9.     number = 42;
  10.     ready = true;
  11. }
复制代码
synchronized 天然提供:

  • 互斥访问(此处非必须);
  • 进入/退出同步块时的内存屏蔽,革新缓存,克制重排序;
  • 明确的 happens-before:开释锁 happens-before 获取同一把锁。
✅ 方案三:AtomicBoolean / AtomicInteger
  1. private static final AtomicBoolean ready = new AtomicBoolean(false);
  2. private static final AtomicInteger number = new AtomicInteger(0);
  3. // main:
  4. number.set(42);
  5. ready.set(true);
  6. // ReaderThread:
  7. while (!ready.get()) {
  8.     Thread.yield();
  9. }
  10. System.out.println(number.get());
复制代码
AtomicXxx 的 get()/set() 默认具有 volatile 语义(除 lazySet),同样满足 happens-before 。
🧪 实验验证:眼见为实

你可以在当地反复运行原版代码:
  1. for i in {1..10}; do java NoVisibility; done
  2. # 很可能混杂着 0 和 42,甚至卡住
复制代码
再运行修复版(加 volatile):
  1. for i in {1..10}; do java FixedNoVisibility; done
  2. # 稳定输出 42!
复制代码
💡 提示:在服务器模式(-server JVM)或某些CPU架构(如ARM)上,题目更轻易复现。
📚 深层思索:由此学到了什么?

误区本相“变量赋值是原子的,以是没题目”原子性 ≠ 可见性。boolean/int 赋值是原子的,但其他线程看不到!“Thread.yield() 能让线程‘同步’”yield() 是线程调理提示,无内存语义,不能更换同步。“代码序次 = 实行序次”编译器、CPU、JIT 都会重排序——除非你用 volatile/synchronized 克制。“单核CPU不会有这题目”单核也大概缓存差别等!且今世根本都是多核。🎯 关键总结:


  • 共享可变状态 必须思量线程安全
  • volatile 不但是“防重排序”,更是创建 happens-before 的轻量级工具;
  • 一个 volatile flag,可动员一批平常变量的可见性——这是高效并发筹划的基石;
  • 测试多线程bug不能靠“跑频频没事”,而要靠理论包管
📖 延伸阅读

✍️ 结语

这段20行的代码,像一面镜子,照出了并发编程的本质:我们不是在写“指令”,而是在界说“变乱之间的因果关系”
下次当你写下 flag = true 想“关照”另一个线程时,请先问自己:
“它真的能瞥见吗?它瞥见时,别的数据预备好了吗?”
假如答案不确定——那就加个 volatile 吧。毕竟,在并发的天下里,显式的左券,赛过机密的假设

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表