ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

第8章:Java 内存模型(JMM)入门——可见性、有序性、原子性

第8章:Java 内存模型(JMM)入门——可见性、有序性、原子性 1. 项目背景业务场景某社交平台的在线状态模块有一个简单的设计——用boolean isOnline false;标志位标记用户登录状态。用户登录后主线程设isOnline true后台心跳线程循环检查这个标志位。奇怪的是在某些高负载的机器上心跳线程看不到isOnline被设为true——即使登录成功了用户状态一直显示离线。开发反复检查代码逻辑确认没有 bug怀疑是 JVM 的 bug。痛点可见性盲区Java 内存模型JMM规定不同线程之间对共享变量的修改不保证立即可见——一个线程写入了值另一个线程可能永远读不到。这不是 bug而是 JMM 允许编译器、JIT 和 CPU 为了性能做的优化寄存器缓存、CPU Store Buffer、指令重排等。指令重排的反直觉开发写了a 1; b 2;CPU 可能实际执行的顺序是b 2; a 1;。在单线程下毫无影响但在多线程下另一个线程可能看到b 2时a还是旧值——这就是有序性被破坏的经典案例。volatile的误用很多人知道volatile能解决可见性但对它的第二个作用——“禁止指令重排”——知之甚少。以为给所有字段都加volatile就万事大吉结果性能一塌糊涂。本章聚焦 JMM 的三个核心性质——可见性、有序性、原子性——通过编写刻意暴露问题的代码让你亲眼看到无同步下的写丢失“指令重排导致的怪异行为”再用volatile、锁和AtomicInteger逐个修复。2. 项目设计小胖瞪着自己的代码——isOnline true明明在第一行就执行了心跳线程却永远读不到。小胖大师这不可能啊我isOnline true就写在心跳线程启动之前怎么可能它读不到这不科学难道 Java 还能把我的赋值语句给吞了大师淡定地喝了口咖啡Java 没有吞你的赋值语句——是你的心跳线程的 CPU 核心在吞。来我给你画一张图看完你就懂了。┌──────────────────────┐ ┌──────────────────────┐ │ CPU Core 1 │ │ CPU Core 2 │ │ ┌──────────────┐ │ │ ┌──────────────┐ │ │ │ 寄存器 │ │ │ │ 寄存器 │ │ │ │ isOnlinetrue│ │ │ │ isOnline??? │ │ │ └──────┬───────┘ │ │ └──────┬───────┘ │ │ │ │ │ │ │ │ ┌──────▼───────┐ │ │ ┌──────▼───────┐ │ │ │ L1/L2 Cache │ │ │ │ L1/L2 Cache │ │ │ │ isOnlinetrue│ │ │ │ isOnline? │ ← 可能还是旧值 │ └──────┬───────┘ │ │ └──────┬───────┘ │ │ │ │ │ │ │ └─────────┼────────────┘ └─────────┼────────────┘ │ │ ┌────▼───────────────────────────────▼───┐ │ 共享内存 (Main Memory) │ │ isOnline ???? │ └────────────────────────────────────────┘现代 CPU 为了性能每个核心有自己的 L1/L2 缓存和 Store Buffer。Core 1 写入isOnline true后这个值可能暂时待在 Core 1 的 L1 缓存里没有立刻同步到主内存。与此同时Core 2 上的心跳线程读取的是自己 L1 缓存里的旧值——于是它看不到更新。技术映射CPU 核心的私有缓存 ↔ 每个员工手里的小本本写了他们各自认为的库存数量主内存 ↔ 仓库门口的大公告牌。小本本上的数字改了但没更新公告牌——另一个员工路过公告牌看到的还是旧数字。小白那volatile就是强制更新公告牌的手段咯大师没错但volatile做的远不止更新公告牌。它实际上做了两件事保证可见性对volatile变量的写操作会立即刷新到主内存读操作总是从主内存读。在 x86 架构上这对应一条lock前缀的汇编指令——它会刷写 Store Buffer写屏障并使其他核心的 L1 缓存失效读屏障。禁止指令重排volatile变量的读写前后会插入内存屏障Memory Barrier阻止编译器和 CPU 把volatile写之前的指令重排到之后以及把volatile读之后的指令重排到之前。// 经典的双重检查锁定 (DCL) 单例publicclassSingleton{privatestaticvolatileSingletoninstance;// ← 必须有 volatilepublicstaticSingletongetInstance(){if(instancenull){synchronized(Singleton.class){if(instancenull){instancenewSingleton();// ← 这行代码分三步执行// 1. 分配内存// 2. 初始化对象 (调用构造函数)// 3. 将引用赋值给 instance}}}returninstance;}}如果没有volatile步骤 2 和步骤 3 可能被重排——另一个线程在步骤 3 之后、步骤 2 之前读到instance ! null拿到的是一个半初始化的对象。这个 bug 极其隐蔽只在特定 CPU 架构和运行条件下出现可以在线上潜伏几个月。技术映射volatile↔ 公告牌警卫。更新公告牌时警卫确保之前所有相关的修改先上牌读取公告牌时警卫确保之后的操作看到的是最新内容。小胖那volatile能解决所有并发问题吗我是不是可以把synchronized全部换成volatile性能还好大师果断地不能。volatile只解决可见性和有序性不能解决原子性。看这个例子volatileintcount0;// 线程 A 线程 Bcount;// 三步骤 count; // 三步骤count虽然只有一行代码但在字节码层面是三条指令getfield读→iadd加→putfield写。如果线程 A 读完值0在还没来得及写回值1时线程 B 也读了值0然后两个线程各写回 1——两个操作结果只加了 1。这就是原子性被破坏。volatile保证不了读-改-写的原子性。要解决原子性有三个选择synchronized重量级但保证互斥访问。AtomicIntegerCAS轻量级适合简单计数。LongAdder适合极高并发计数。技术映射volatile↔ 公告牌保证你看到的是最新库存但如果两个人同时看到 10 个 → 各拿 1 个 → 都更新为 9实际库存该是 8 而不是 9。要解决这个问题你需要在拿货时加一把锁或者用原子操作的自动售货机。小白那 JMM 中的happens-before规则又是什么它跟volatile有什么关系大师在白板上写下几条核心规则happens-before是 JMM 对 Java 程序员提供的秩序保证——只要你的代码满足某条happens-before规则JVM 就保证前一个操作的结果对后一个操作可见。核心规则包括程序顺序规则同一个线程中前面的操作 happens-before 后面的操作但仅限单线程volatile 变量规则对一个volatile变量的写 happens-before 后续对这个变量的读锁规则一个锁的unlockhappens-before 后续的lock传递性如果 A hb BB hb C则 A hb C线程 start/join 规则thread.start()hb 线程内的任何操作线程内的任何操作 hbthread.join()返回happens-before是你写并发代码的安全网——只要你能证明代码满足其中一条规则JVM 就保证不乱序。技术映射happens-before↔ 火车的轨道调度系统。如果没有调度两列火车可能相撞并发 bug。有了调度你只需要按照调度逻辑开车——调度系统保障了次序。3. 项目实战3.1 环境准备组件版本用途JDKOpenJDK 21运行示例JMH可选微基准测试hsdis可选JDK 自带反汇编查看内存屏障指令3.2 分步实现步骤一复现可见性失败目标证明没有volatile时一个线程的写入对另一个线程可能永远不可见。// VisibilityDemo.java —— 可见性失败复现publicclassVisibilityDemo{// 注意没有 volatile 修饰privatestaticbooleanflagfalse;privatestaticintvalue0;publicstaticvoidmain(String[]args)throwsInterruptedException{System.out.println(开始演示可见性问题可能需要几秒到几分钟...);// 写线程修改 flag 和 valueThreadwriternewThread(()-{value42;// 步骤 1flagtrue;// 步骤 2 没有 volatileCPU 可能重排 1↔2},Writer);// 读线程不停检查 flag期望读到 value42ThreadreadernewThread(()-{intiterations0;while(!flag){// ← 没有 volatile可能永远读不到 trueiterations;// 注意此处不能有 println 或 sleep它们隐含 synchronized会刷缓存}System.out.println(Reader 在 iterations 次循环后读到: flagflag, valuevalue);// 如果 value ! 42说明发生了指令重排或可见性问题if(value!42){System.out.println(ERROR: valuevalue 但预期42 (重排或可见性失败));}},Reader);reader.start();Thread.sleep(100);// 确保 reader 先进入循环writer.start();reader.join(5000);// 最多等 5 秒if(reader.isAlive()){System.out.println(Reader 在 5 秒内没读到 flagtrue —— 可见性问题确认);reader.interrupt();}}}运行结果Reader 可能在 5 秒超时后仍未读到flagtrue。步骤二用 volatile 修复可见性目标给flag加volatile验证写入立即对读线程可见。// VisibilityFixed.java —— volatile 修复可见性publicclassVisibilityFixed{privatestaticvolatilebooleanflagfalse;// ← 加 volatileprivatestaticintvalue0;publicstaticvoidmain(String[]args)throwsInterruptedException{System.out.println(使用 volatile写入应立即可见...);ThreadreadernewThread(()-{intiterations0;while(!flag){iterations;}System.out.println(Reader 在 iterations 次后读到 flagtrue);System.out.println(valuevalue (期望42)(value42? OK: FAIL));},Reader);ThreadwriternewThread(()-{value42;// volatile 写之前的普通写也对 reader 可见flagtrue;// volatile 写 → 触发 happens-before},Writer);reader.start();Thread.sleep(100);writer.start();reader.join(1000);System.out.println(Reader 是否结束: !reader.isAlive());}}关键点volatile写flagtrue不仅保证flag的可见性还保证value42在 volatile 写之前的所有操作也对读线程可见——这是 happens-before 的传递性 volatile 变量规则的联合效果。步骤三展示原子性缺失——volatile 的 count 陷阱目标证明volatile 不是原子操作。// AtomicityDemo.java —— volatile 无法保证原子性importjava.util.concurrent.CountDownLatch;publicclassAtomicityDemo{privatestaticvolatileintcount0;// volatile, 但不保证原子性privatestaticintcountSync0;// synchronized 保护privatestaticjava.util.concurrent.atomic.AtomicIntegercountAtomicnewjava.util.concurrent.atomic.AtomicInteger(0);// AtomicIntegerprivatestaticfinalintTHREADS10;privatestaticfinalintITERATIONS10000;publicstaticvoidmain(String[]args)throwsInterruptedException{// 方案 A: volatile 预期结果 THREADS * ITERATIONStest(volatile only,()-count);System.out.println(volatile 最终值: count (预期 THREADS*ITERATIONS, 丢失 (THREADS*ITERATIONS-count)));// 方案 B: synchronized 预期结果 THREADS * ITERATIONStest(synchronized,()-{synchronized(AtomicityDemo.class){countSync;}});System.out.println(synchronized 最终值: countSync (预期 THREADS*ITERATIONS));// 方案 C: AtomicInteger 预期结果 THREADS * ITERATIONStest(AtomicInteger,()-countAtomic.incrementAndGet());System.out.println(AtomicInteger 最终值: countAtomic.get() (预期 THREADS*ITERATIONS));}staticvoidtest(Stringname,Runnabletask)throwsInterruptedException{CountDownLatchlatchnewCountDownLatch(THREADS);for(inti0;iTHREADS;i){newThread(()-{for(intj0;jITERATIONS;j){task.run();}latch.countDown();}).start();}latch.await();System.out.print(name - );}}预期输出volatile only - volatile 最终值: 87345 (预期 100000, 丢失 12655) synchronized - synchronized 最终值: 100000 (预期 100000) AtomicInteger - AtomicInteger 最终值: 100000 (预期 100000)步骤四展示 volatile 的内存屏障——反汇编验证# 用 hsdis 反汇编查看 volatile 变量的读写指令java-XX:UnlockDiagnosticVMOptions\-XX:PrintAssembly\-XX:CompileCommandcompileonly,*VisibilityFixed.*\VisibilityFixed21|grep-A5lock如果能看到lock addl或lock cmpxchg等带lock前缀的指令这些就是 volatile 的写屏障。可能遇到的坑可见性实验总是成功如果在while (!flag)循环中调用了System.out.println()或Thread.sleep()这些方法内部有synchronized块——它会隐式地刷新 CPU 缓存导致自然可见。所以可见性实验的循环体中必须不能有任何同步操作。JIT 的死循环优化如果 JIT 发现while (!flag)中的flag不是volatile它可能直接把flag的值缓存在寄存器里并优化成一个无限循环while(true)——这确实会发生。这就是为什么有时候需要在flag上额外做一个空壳操作来抑制优化。javac编译优化如果flag是false且从未修改的字面量常量编译器可能直接优化掉整个while循环——确保flag在运行时可能被修改。不同 CPU 架构差异x86 是强内存模型TSO很多重排在 x86 上不会发生但 ARM 和 RISC-V 是弱内存模型——在 x86 上能跑通的并发代码在 ARM Mac 上可能立刻暴露 bug。跨平台验证很重要。3.3 测试验证验证点方法预期結果可见性失败运行 VisibilityDemoReader 5 秒内读不到 flagtruevolatile 修复运行 VisibilityFixedReader 立即可见value42原子性失败运行 AtomicityDemovolatile count 远小于 100000happens-before线程 start/join 验证join 后的线程操作必然可见指令重排效果多次运行无 volatile 的 VisibilityDemovalue 可能不是 42#!/bin/bashecho 1. 可见性失败演示 javaVisibilityDemoechoecho 2. volatile 修复验证 javaVisibilityFixedechoecho 3. 原子性验证 javaAtomicityDemoechoecho 4. volatile 传递性验证 (happens-before) # 编译并运行额外的验证volatile 写之前的非 volatile 写是否可见java-cp.VisibilityFixed# value42 在 volatile 写 flagtrue 之前 → 应对 reader 可见4. 项目总结4.1 优点与缺点维度优点缺点volatile轻量级读操作等同于普通读保证可见性有序性不保证原子性——读-改-写需要额外同步happens-before提供清晰的并发正确性判断框架规则较多8 条核心规则需要刻意记忆JMM 设计平衡了性能与安全性——不强求顺序一致性的所有开销理解门槛高——需要同时理解编译器优化 CPU 缓存 指令重排Atomic* 类无锁 CAS 操作极高并发下吞吐远超锁CAS 失败时自旋消耗 CPUABA 问题需要额外关注强/弱内存模型x86 的 TSO 让很多并发 bug 被隐藏——部署到 ARM 前可幸免在 x86 上测试通过的代码不等于正确——必须有意识地验证可见性和有序性对比技术volatilesynchronizedAtomicInteger (CAS)互斥性不支持支持不支持仅单变量原子操作性能读 ≈ 普通读写略慢竞争时最慢无竞争时极快高竞争时自旋浪费适用操作单变量读写任意代码块单变量的增减/CAS内存屏障读写各一屏障进出各一屏障一次 CAS 读写屏障4.2 适用场景状态标志位如boolean shutdown、boolean initialized——一个线程写、多个线程读用volatile完美解决。DCL 单例双重检查锁定中的instance必须volatile防止半初始化对象泄漏。无锁计数器AtomicLong、LongAdder在高并发计数场景下秒杀任何锁方案。轻量级读-写锁用volatile修饰状态变量配合 CAS 实现简易读写分离。并发框架基础理解 AQS、ConcurrentHashMap等高级并发工具必须先理解 JMM。不适用场景读-改-写的复合操作——volatile 不能保证原子性应选用Atomic*或锁。多个变量需要保持一致性——比如A 和 B 必须同时更新volatile 无法保证两个变量更新的原子性。单线程场景——没有可见性问题不加任何修饰即可。4.3 注意事项类型详细说明volatile 数组volatile int[] arr只保证数组引用本身的可见性不保证数组元素arr[i]的可见性——用AtomicIntegerArray替代64 位变量long和double的 64 位操作在 JMM 下被分为两次 32 位读写——非 volatile 的long可能读到半个旧值半个新值final 域安全发布构造函数中对final字段的写入 happens-before 构造函数返回——这是 JMM 提供的对象安全发布保障前提是不能让this在构造过程中逃逸VarHandleJDK 9 引入的VarHandle提供了比volatile更细粒度的内存顺序控制——支持getAcquire/setRelease/getOpaque/setOpaque四种模式4.4 常见踩坑经验案例 1双重检查锁定的半初始化炸弹某核心服务的配置管理器用 DCL 单例缓存配置。JDK 7 下运行了 3 年从未出错。迁移到 JDK 11 ARM 服务器后偶发性地读出config.get(key)返回null配置文件确定有这个 key。根因单例的instance字段没加volatileARM 的弱内存模型下指令重排导致分配内存 → 引用赋值 → 初始化的顺序被执行——别的工作线程看到了非空但未初始化的 instance。修复加volatile——private static volatile Config instance;。案例 2volatile的传递性误解某交易系统用volatile boolean orderSubmitted false;标志订单提交状态。提交线程顺序写order.setAmount(100);→orderSubmitted true;。处理线程读到orderSubmitted true后立刻读order.getAmount()期望拿到 100。结果在 ARM Mac 上测试时偶尔拿到 0。根因开发误以为 volatile 只有立即可见不了解它的传递序——orderSubmitted truevolatile 写确实让后续 volatile 读可见但后续 volatile 读之前发生了什么volatile 不保证正确顺序是orderSubmittedvolatile 写在order.setAmount之前。案例 3System.exit(0)触发 DCL 失败垃圾回收线程在 JVM 关闭前的最后一瞬间触发了finalize()调用了单例的getInstance()——此时构造函数刚好执行到一半还没退出synchronized块得到半初始化对象。根因System.exit()的特殊性与 DCL 交互的极端边缘条件。修复使用基于类的初始化Class Initialization Lock替代 DCLprivatestaticclassHolder{staticfinalSingletonINSTANCEnewSingleton();}publicstaticSingletongetInstance(){returnHolder.INSTANCE;}4.5 思考题进阶题volatile修饰的long变量在多线程中执行i是否线程安全如果不安全请分析字节码层面涉及了多少条指令并解释AtomicLong.incrementAndGet()的内部实现原理提示查看Unsafe.compareAndSwapLong的 native 实现。实战题你的系统从 x86 迁移到 ARM 服务器如 AWS Graviton后之前跑了 2 年的无 bug并发代码突然出现了间歇性的数据不一致。怀疑是指令重排导致。请设计一个最小可复现方案不依赖任何外部工具并给出修复建议。答案提示思考题 1 答案见本章步骤三 第 17 章 AQS/CAS 部分思考题 2 答案见第 23 章逃逸分析与锁优化。下一章预告第 9 章将进入 GC 的世界——堆分代模型、Serial/Parallel 垃圾收集器以及如何用 Unified Logging 绘制分配速率-停顿曲线。延伸阅读与资源Redis 8 实战精讲从 CRUD 到源码构建高可用缓存系统Redis 实战修炼与原理进阶Python 3实战精进从脚本到高并发订单引擎MongoDB 实战进阶与内核修炼python入门Rquests从菜鸟脚本到企业级SDK的网络实战圣经Milvus向量数据库实战修炼从 0 到 1精通向量检索与生产落地后端工程师的 AI 转型第一课Ollama 与私有化大模型实战10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析
返回列表