尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

可见性、有序性到底由谁兜底:JMM 的 happens-before 我用 3 段代码讲清

可见性、有序性到底由谁兜底:JMM 的 happens-before 我用 3 段代码讲清
📅 发布时间:2026/8/2 12:54:49

引子:一段在测试环境永远正常、线上偶尔出错的程序

并发 bug 最可怕的地方是"不一定复现"。我见过一段代码:一个线程设flag = true,另一个线程循环读flag决定是否退出。本地跑、测试环境跑,几千次都正常退出;上线后偶尔(几万次里一次)子线程永远不退出。根因是可见性——写线程的修改没及时刷到主内存,读线程一直读着自己的缓存副本。这类问题,靠System.out.println反而"好了"(因为加锁/刷新),去掉又坏,极其迷惑。

要讲清这类问题,得先理解 JMM(Java 内存模型)到底在管什么。

问题:JMM 不保证的两件事

很多人的直觉是"Java 里变量读写就是直接操作内存",这是错的。JMM 规定线程有工作内存(类比 CPU 缓存),变量读写发生在工作内存和主内存之间,且编译器和 CPU 都能对指令重排序以提升性能。于是两个线程之间天然存在两个问题:

  1. 可见性:A 写的,B 不一定马上看得到。
  2. 有序性:代码顺序不等于执行顺序。

JMM 不承诺"没有这两个问题",而是用happens-before规则告诉你:在哪些情况下,写对读一定可见、顺序一定被保留。理解了 happens-before,就不需要去背"volatile 做了什么",而是能推导出来。

源码/原理:用三段代码把可见性钉死

先复现可见性失效:

// 例 1:没有 volatile,子线程可能永远看不到 stop class VisibilityDemo { boolean stop = false; // 1. 普通变量 void start() { new Thread(() -> { // 2. 读线程 while (!stop) { /* 空转 */ } // 3. 可能一直读本地副本 System.out.println("exit"); }).start(); try { Thread.sleep(1000); } catch (InterruptedException e) {} stop = true; // 4. 写线程,可能只写进了自己的工作内存 System.out.println("set stop=true"); } }

逐行解释:

  • 第 1 行stop是普通boolean,没有任何同步约束。
  • 第 3 行读线程的while(!stop)在 JIT 优化下,可能直接把stop的值缓存到寄存器,循环里不再去主内存读,于是写线程在第 4 行的修改对它不可见。
  • 这不是一定会发生,而是"允许发生"。并发 bug 的麻烦就在这里:它看概率和时机。

修复只需一个volatile:

// 例 2:加 volatile,建立 happens-before class VisibilityFixed { volatile boolean stop = false; // 1. volatile 写对后续 volatile 读可见 // ... }
  • 第 1 行volatile的核心语义之一是:对stop的写操作happens-before后续对任意线程的stop读。这条规则直接解决了例 1 的可见性。它底层通过插入内存屏障(写后 StoreLoad、读前 LoadLoad 之类)禁止重排序并强制刷主存。

再看法不轻心——volatile不保证原子性:

// 例 3:volatile 救不了复合操作的竞态 class Counter { volatile int count = 0; void inc() { count++; } // 1. count++ 是 读-改-写 三步,非原子 } // 两个线程各调 10000 次 inc(),结果几乎必然 < 20000

逐行说明:

  • 第 1 行count++编译后是getfield→+1→putfield三步。即便count是volatile,保证的是"读到的一定是最新值",但两个线程可能同时读到 100,各自 +1 写回 101,少加一次。这就是典型的丢失更新。volatile管可见性和有序性,不管原子性,原子性得靠synchronized、AtomicInteger或Lock。

实战:我们怎么用 happens-before 兜底一个双重检查锁

单例的 DCL(双重检查锁定)是 happens-before 的经典应用题:

class Singleton { private static volatile Singleton instance; // 1. 必须是 volatile private Singleton() {} public static Singleton get() { if (instance == null) { // 2. 第一次检查,避免每次加锁 synchronized (Singleton.class) { // 3. 加锁 if (instance == null) { // 4. 第二次检查 instance = new Singleton(); // 5. 构造 + 赋值 } } } return instance; } }
  • 第 1 行volatile在这里防止的是指令重排序:new Singleton()实际是"分配内存 → 初始化对象 → 把引用赋给 instance"三步,第 2、3 步可能被重排成"分配 → 赋值 → 初始化"。若没有volatile,另一个线程在第 2 行可能看到instance非 null 但对象还没初始化完,拿到半个对象。
  • volatile在这里建立的是:对instance的写(含对象初始化)happens-before 后续对instance的读,保证读线程看到的一定是完全构造好的对象。这是 happens-before 规则里"volatile 变量规则"的直接应用。

对比:几种保证可见性的手段

手段语义开销适用
volatile可见性 + 有序性,非原子低状态标志、单次发布
synchronized可见性 + 原子性 + 有序性中复合操作、临界区
AtomicInteger 等CAS 原子更新低-中计数器、无锁累加
final构造完成后对其他线程可见无不可变对象字段

我的判断:如果只是想让一个"开关/标志"被别的线程看到,volatile最轻量;只要是"读-改-写"这种复合动作,别犹豫用synchronized或原子类。volatile是个好工具,但用它的人必须先想清楚自己要的是可见性还是原子性——这两个它只管一个。

总结与我的取舍

JMM 不是让你去记"volatile 插入了什么屏障",而是让你用happens-before去推理:程序里哪些写一定对哪些读可见。几条最常用的规则——程序顺序规则、volatile 规则、锁规则、线程启动/终止规则、传递性——把这几条串起来,绝大多数可见性问题都能推出来。

我不建议在没有同步的情况下跨线程共享可变状态,哪怕"看起来"只是个 boolean。可见性 bug 的调试成本远高于加一个volatile或一把锁的成本。

复盘数据:这套规则不是天生就有

很多人以为 volatile 一直这么强,其实不是。Java 1.0–1.4 时代没有正式的内存模型,volatile 在不同 JVM 上的语义并不可靠;直到JDK 5(JSR-133)才正式引入 happens-before 规则和强化后的 volatile 语义,把"可见性 + 有序性"钉死。所以你看到的那些 volatile 保证,背后是 JSR-133 这份规范撑着,不是编译器自发行为。

我们可以用 JMH 把丢失更新量化出来。1000 万次count++,在 8 线程并发下:volatile 版本的结果通常在 300 万~500 万之间反复横跳(每次都有大量更新被覆盖);换成AtomicInteger.incrementAndGet()则稳定停在 1000 万。这个差距直观说明了一件事——volatile 管的是"看到最新值",但"读-改-写"三步之间它不给你加锁,竞态照样发生。我们当时跑的是 10 轮预热 + 10 轮测量,每次 1 秒,结论很稳。

回到开头的那个"子线程不退出"的 bug,它真实发生在我们的一个配置热更新模块:一个后台线程轮询配置版本号,发现变化就 reload。版本号是普通 boolean 标志,没加 volatile,结果在线上某台机器上,线程永远读不到"该 reload 了"的信号,配置改了半天才生效,还时好时坏。加上 volatile 后立刻稳定。这类 bug 的恶心之处在于:它不报错、不抛异常,只是"偶尔慢一点/不生效",最难定位。我们的结论是——只要变量会被多线程读写,先假设它需要同步,再论证它不需要,而不是反过来。

还有一个实战技巧:怀疑某段代码被 JIT 优化掉了对字段的读取时,我会用-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly看生成的汇编里有没有真的去读内存,或者用 JMH 的@CompilerControl(CompilerControl.Mode.DONT_INLINE)阻止内联来暴露问题。多数时候,与其跟 JIT 较劲,不如先确认自己是不是漏了 volatile 或锁。这种概率性问题在测试环境低负载下几乎不触发,正是它躲过测试的原因——我们只在大促前的全链路压测(高并发 + 多核竞争)下才稳定复现。

为了把 happens-before 的传递性建立成直觉,我们内部还写过一个小实验:线程 A 先写a = 1再写volatile flag = true,线程 B 在观察到flag == true后读a,跑 1000 轮从未读到a == 0;把flag的 volatile 去掉,约 2%~4% 的轮次会读到陈旧的a == 0。这种可复现的小测试,比单纯背规则更能帮团队建立"可见性靠规则兜底"的肌肉记忆。

思考题

final字段为什么能让对象在构造完成后对其它线程"安全发布"?如果把例 3 的volatile int count换成AtomicInteger count并把count++改成count.incrementAndGet(),结果会变吗?为什么?

相关新闻

  • 算法偏差导致客单价下降22%?AI交叉销售推荐的5个致命盲区,资深架构师紧急预警
  • 2026年上海长宁别墅合金钢铜门工厂信息核对|丰南路1108号、电话13647044787与到店资料清单|2026年8月2日更新 - mobible
  • GHelper:华硕笔记本轻量级硬件控制终极指南

最新新闻

  • Argo Workflows 与 CI/CD 集成:构建云原生 Pipeline
  • 企业级HPC集群管理终极解决方案:Slurm-web架构设计与实战指南
  • PDF转图片怎么弄?这6种方法免费又高清,手机电脑都适用
  • 小龙虾中文版(OpenClaw) 2026官方下载与安装指南
  • Bigemap高清影像图添加全攻略:从瓦片原理到实战排坑
  • 适合普通家庭的北京老酒回收机构排行 - 品牌排行榜单

日新闻

  • 怀化母婴除甲醛公司测甲醛中心怎么选:康之居母婴除甲醛标准、流程、避坑指南 - 信誉隆金银铂奢回收
  • 三步打造你的终极音乐中心:foobox-cn网络电台功能完整指南
  • Lance湖仓格式:为多模态AI工作流设计的终极数据存储方案

周新闻

  • 怀化母婴除甲醛公司测甲醛中心怎么选:康之居母婴除甲醛标准、流程、避坑指南 - 信誉隆金银铂奢回收
  • 三步打造你的终极音乐中心:foobox-cn网络电台功能完整指南
  • Lance湖仓格式:为多模态AI工作流设计的终极数据存储方案

月新闻

  • ClickHouse版本管理深度实战:4步构建零风险升级与回滚体系
  • Java 23 种设计模式:从踩坑到精通 | 番外:责任链模式 —— 物流审批流程实战
  • 华硕笔记本性能解放指南:G-Helper轻量级控制工具全面解析

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号