Java内存模型(JMM)深度解析:从volatile到happens-before的并发安全框架

发布时间:2026/10/2 19:22:02
Java内存模型(JMM)深度解析:从volatile到happens-before的并发安全框架 前阵子一个做后端的朋友找我查线上 Bug两个线程各执行一万次count然后主线程打印 count期望值是 20000实际却隔三差五少一些。代码里已经给 count 加了 volatile按理说该做的都做了。折腾到半夜最后我们把问题定位到 Java 内存模型JMM——准确说是把 volatile 当成“万能关键字”却没搞懂它到底保证了什么。这类问题我见过不止一次所以一直想写一篇能把 JMM 讲清楚的文章。它既不是面试八股文里那种背完就忘的定义也不是“双检锁要不要加 volatile”这种单一结论而是一套真正能帮你判断并发代码安不安全、Bug 出在哪层的分析框架。本文适合所有 Java 使用者想应付面试的、写多线程业务代码的、还有排查线上诡异问题的都能从中拿到一套直接可用的判断思路。1. 从一次并发 Bug 说起JMM 到底在解决什么问题很多人在网上搜“Java 内存模型”搜出来一堆“主内存”“工作内存”“原子性、可见性、有序性”的名词。背完能应付定义题但碰到真实 Bug 依然无从下手。我觉得最有效的切入角度是先看一个“肉眼可见”的并发异常然后顺着异常往回刨JMM 存在的意义自然就浮出来了。1.1 一个“肉眼可见”的并发异常现象写一个最简单的计数器public class Counter { private int count 0; public void increment() { count; } public int getCount() { return count; } }然后两个线程各调用increment()一万次主线程等它们结束后打印结果。你猜输出是多少我用笔记本上跑了十几遍很少看到 20000经常是 199xx偶尔更少。第一次遇到的人多半怀疑是count非原子导致的于是给 count 加上 volatileprivate volatile int count 0;结果更让人崩溃——数字还是不对。这说明什么说明 volatile 根本没能解决这个案例。它确实解决了一部分并发问题但“并发安全”这个要求其实是三个独立维度的叠加原子性、可见性、有序性。很多写成“万能关键字”的讲解恰恰害人在这里把 volatile 的可见性保证错当成了对原子性也有保证。这个 Bug 背后的完整链条是count在 JVM 里不是一个动作而是“读取旧值 - 加 1 - 写回新值”三步。两个线程可能同时读到旧值 100各自加 1 变成 101再同时写回最后 count 只是 101 而不是 102——这一步丢掉的增量和 volatile 没关系。理解了这一点你才有资格往下讨论 Java 内存模型因为接下来要解释的问题是为什么“只是读取一个普通变量”也会出错为什么加了 volatile 之后读到的一定是最新值1.2 CPU 缓存与指令重排序两个看不见的“捣蛋鬼”单核 CPU 时代所有线程共享一份内存一个变量同时只有一个副本读写顺序天然一致。多核时代完全变了每个 CPU 核心都有自己的寄存器、L1、L2 甚至 L3 缓存内存里的数据会被复制到各核心的高速缓存中。假设核心 A 上的线程把x 1写进了自己的 L1 缓存核心 B 上的线程此时读x读到的可能还是缓存里的旧值 0。如果两个核心都以为“自己手里的才是对的”数据就产生了分裂。硬件层有解决方案比如常见的缓存一致性协议MESI 这类让各核心之间通过消息同步缓存行状态。但这类协议只解决“一个变量到底哪个值是最新的”问题它管不了另一件事——指令重排序。编译器和 CPU 为了执行效率会在“不改变单线程语义”的前提下调整指令顺序。比如int a 0; int b 0; a 1; // 动作 A b 2; // 动作 B动作 A 和动作 B 彼此没有数据依赖编译器把 B 调到 A 前面执行单线程看不出任何区别。可一旦两个动作涉及不同线程共享的变量重排序的后果就严重了线程 1 先执行“设置 flag true”线程 2 看到 flag 后去读另一个普通变量可能读到还没来得及写回的值。硬件层的缓存一致性协议解决不了这种“顺序”问题它与语言无关只保证单个内存地址的传播。Java 程序跑在不同硬件上如果每次都要针对 CPU 型号调优那开发就没法做了。所以 Java 语言规范需要自己定一套抽象规则规定软件层面的可见性与有序性最低保证。这就是 Java 内存模型的由来。1.3 JMM 的边界它管什么不管什么JMMJava Memory Model是 JSR-133 规范Java 5 时代重新修订定义的一套抽象模型。它规定线程之间的共享变量什么时候、以什么顺序对其他线程可见什么情况下允许重排序什么情况下必须禁止哪些同步原语volatile、synchronized、final能建立怎样的可见性保证。可以这样理解 JMM 的定位它像一份“Java 多线程行为合同”。JVM 在某个具体 CPU 上运行必须想办法让实际表现满足这份合同但 JMM 本身不规定 JVM 该怎么调用 CPU 指令只规定“结果不能比这个更差”。所以 JMM 不管什么它不管某个变量的具体内存地址在哪、Cache 行怎么对齐、用没用写合并缓冲区。这些是 JVM 和硬件的事。它对程序员呈现的是抽象模型主内存 每个线程的工作内存。不深究物理细节反而让你可以用一套统一规则来分析所有平台上的并发问题。这个抽象正是 JMM 最大的价值——把 CPU 层面的千变万化收敛成一份可以推理的语言规范。2. 主内存与工作内存JMM 的抽象模型怎么读JMM 把内存划分为两类主内存所有线程共享的变量存放的地方可以理解为堆内存中的共享区域工作内存每个线程私有保存线程用到的变量副本。听上去像是“每个线程把变量复制了一份到自己私有空间”。这个说法对初学者很友好但也容易让人误解工作内存不是真的在 Java 堆中又复制了一块独立数据而是对寄存器、一级缓存、二级缓存的抽象统称。一个变量在工作内存里的“副本”可能是 L1 Cache 的一个缓存行也可能是寄存器里的一个值。为什么这样设计因为 CPU 操作寄存器比访问 L1 快一个数量级访问 L1 又比访问内存快很多。如果每个共享变量读写都直接穿透主内存性能会退化到无法接受。工作内存的存在本质上是在性能和语义之间找平衡。2.1 工作内存到底是什么一个文档协作的类比用一个团队协作场景类比你们小组共同维护一份策划文档文档存在公共网盘上这就是“主内存”。每个人在本地 Word 里打开一份副本编辑这就是“工作内存”。你改完本地副本后别人能看到吗看不到。你必须点“上传”把改动同步到网盘别人再点“下载”刷新本地副本才看到新内容。更麻烦的是两个同事同时改同一段文字都保存后保存的人覆盖了先保存的版本——这就是并发读写冲突。在 JMM 这套类比里公共网盘是主内存本地 Word 副本是工作内存上传对应“把工作内存写回主内存”下载对应“从主内存刷新到工作内存”两个同事同时编辑对应两个线程竞争共享变量没有同步机制时编辑顺序如何交错完全不可控。为什么强调“工作内存”而不直接说 CPU 缓存因为寄存器、缓存、写缓冲器这些硬件各厂商实现差异很大规范层面只用一个统一抽象JVM 可以根据目标平台自行映射。你在 Java 层写并发代码时只需要关心“共享变量在不在工作内存副本里、什么时候回写”而不必关心 L1 和 L2 的区别——这是抽象模型能带来的最大便利。2.2 三大特性原子性、可见性、有序性并发安全可以拆成三个独立的属性任何一个不满足程序就可能出错。原子性一个操作要么全部执行且中间不被任何因素打断要么完全不执行。count在字节码层面是“读取 - 计算 - 写回”三个步骤可以被其他线程插入所以它不是原子的。能提供原子性的工具包括 synchronized 临界区、Lock、以及各类 AtomicXX 类。可见性一个线程修改了共享变量另一个线程能不能立刻看到修改后的值。普通变量不保证可见性因为工作内存中的副本可能没及时回写主内存读线程可能一直用旧副本。volatile、synchronized、final特殊语义可以在各自的场景里保证可见性。有序性程序执行顺序与代码逻辑书写顺序一致。JMM 允许单线程内不发生语义变化的重排序但在跨线程场景中这种排序可能带来意外结果。volatile 和 synchronized 都能约束一部分重排序。三者关系用一个例子串起来两个线程同时执行count就算加 volatile 保证了可见性——读线程能看到写线程最新的值——依然会因为“读改写”三步不是原子的而丢更新。所以平时排查并发问题先问自己这个 bug 属于哪个维度再决定用哪个工具。属性解决什么问题不可用时典型表现常见解决工具原子性操作不可分割计数少、金额错乱synchronized、AtomicInteger可见性修改能被其他线程看到线程死循环读旧值volatile、synchronized有序性执行顺序符合预期拿到“半构造”对象volatile、synchronized、final2.3 变量读写的完整路径八种操作的抽象模型JMM 定义了 8 种操作来描述线程与主内存的交互read从主内存读取变量到工作内存load把 read 得到的数据放入工作内存副本use把工作内存副本传给执行引擎使用assign执行引擎把计算结果赋给工作内存副本store把工作内存副本传给主内存write把 store 传入的数据写入主内存变量lock把变量标记为线程独占unlock解除独占标记。先说清楚这 8 种操作是规范层面的抽象不是 JVM 实际发出的指令。它们定义的是“变量如何在不同内存区域之间流动”的逻辑流程。比如线程执行int y x;时会经历主内存read- 工作内存load- 执行引擎use。写回则反过来执行引擎assign-store- 主内存write。这套流程里最容易忽略的点read和load、store和write虽然成对出现但中间可能插入其他操作。比如线程对变量 x 连续执行多个assign后可能只触发一次store把最新值刷到主内存。规范允许这种合并优化但也意味着读线程在某段时间内看不到中间所有值只会在某个时刻看到“跳跃式”的最新值。这解释了为什么没有同步机制时另一个线程看到的值是“某一刻的快照”而不是按时间推进的完整序列。3. happens-before判断并发行为是否安全的最强工具主内存 工作内存的抽象模型告诉你“可能存在什么问题”但实际推出“这段代码是否安全”时逐条推断太慢。JMM 给了你一套更高效的分析工具happens-before先行发生规则。happens-before 定义了一个偏序关系如果动作 A happens-before 动作 B那么 A 的执行结果对 B 可见且 A 的执行顺序排在 B 之前。注意“可见”的含义不是说 A 在时间上一定先于 B 发生而是说 B 能观察到 A 产生的结果。JMM 并不要求所有操作都满足 happens-before也不要求没有 happens-before 关系的操作一定互相干扰——它只是不承诺任何保障。你只需记住一条铁律两个操作之间没有 happens-before 关系就不能对结果做任何假设。3.1 规则清单从程序顺序到传递性happens-before 的核心规则我在面试里常用一句话概括一个线程内跟代码走加锁解锁锁跟锁走volatile 写读跟变量走传递关系套着走。具体拆开是这些程序顺序规则同一线程内书写在前面的操作 happens-before 书写在后面的操作。这是重排序不能破坏单线程语义的规范表述。监视器锁规则对一个锁的解锁 happens-before 后续对这个锁的加锁。也就是说线程 A 释放 synchronized 块之后线程 B 抢到同一把锁进入必然能看到 A 在锁内做的所有改动。volatile 规则对一个 volatile 变量的写 happens-before 后续对它的读。这里的“后续”指时间顺序上的后续读不是任意读。线程启动规则Thread.start()happens-before 该线程的任何动作。线程终止规则线程内所有动作 happens-before 其他线程检测到该线程终止比如join()返回。中断规则调用thread.interrupt()happens-before 被中断线程检测到中断事件。终结器规则对象构造完成 happens-before 终结器的启动。传递性A happens-before BB happens-before C则 A happens-before C。这些规则不是枯燥的公理它们对应着你手写的各种同步原语。用锁就能得到锁规则用 volatile 就能得到 volatile 规则。判断代码安不安全就是沿着这些关系找出“某个操作是否能被另一个操作看到”。3.2 规则推导实例volatile 写读如何让“a 42”被看见直接看一个经典例子int a 0; volatile boolean flag false; // 线程 1 a 42; // 动作 A flag true; // 动作 B // 线程 2 if (flag) { // 动作 C System.out.println(a); // 动作 D }线程 2 看到flag true后它读到的a一定是 42 吗用 happens-before 推导程序顺序规则动作 A happens-before 动作 B都在线程 1 内且 A 写在 B 前面。volatile 规则动作 Bvolatile 写 happens-before 动作 Cvolatile 读因为 C 在时间上晚于 B。程序顺序规则动作 C happens-before 动作 D都在线程 2 内。传递性A hb BB hb CC hb D所以 A hb D。结论只要线程 2 进入了 if它读到的 a 一定是 42 这个新值。这比笼统说“volatile 保证可见性”要精确得多——它能推导出“哪个值对哪个操作可见”的边界。这也是我写并发代码时最常用的方法先用 happens-before 把链路画出来如果链路断了代码就可能出并发问题。3.3 没有 happens-before 会发生什么普通变量的不可预测性把上一节的 flag 改成普通变量非 volatile结果如何int a 0; boolean flag false; // 线程 1 a 42; // 动作 A flag true; // 动作 B // 线程 2 if (flag) { // 动作 C System.out.println(a); }线程 1 和 线程 2 之间没有任何同步关系。A 与 B 之间虽然有程序顺序规则但这条规则只在单个线程内成立跨线程的 C 与 B、C 与 A 都没有 happens-before 关系。也就是说线程 2 可能先读到flag true但此时 a 还没被写线程刷回主内存打印 0即使 flag 和 a 的写入都完成了重排序也可能让编译器和 CPU 先执行flag true再执行a 42线程 2 拿到最新 flag 时 a 还是旧值。不是“一定出错”而是“完全没有保证”。并发 Bug 的可怕之处就在于它像薛定谔的猫不打开盒子你永远不知道它现在是不是已经错了。我在生产环境见过的很多诡异问题都是概率性出现加个日志就复现不了因为这个日志操作本身改变了时间窗口。所以别试图用“跑一次没问题”来证明并发代码正确能证明正确的只有 happens-before 推导。4. 内存屏障JMM 规则是怎么落到 CPU 指令上的从概念上理解了 happens-before 之后很多朋友会问JVM 到底怎么保证这些规则落地难道它魔法般控制了 CPU答案是内存屏障Memory Barrier。这一节讲清楚屏障的类型和 volatile、synchronized 的落地方式面试中如果被问到“volatile 的底层原理”答到这一层就足够扎实了。4.1 四种屏障LoadLoad、StoreStore、LoadStore、StoreLoad内存屏障是一条 CPU 指令作用是阻止屏障两侧的指令重排序并保证屏障一侧的内存操作完成后另一侧能看到。按读写方向组合成四类LoadLoad 屏障屏障前的读操作先于屏障后的读操作完成。场景是防止“先读新变量再读旧变量”的乱序。StoreStore 屏障屏障前的写操作先于屏障后的写操作对其他处理器可见。场景是把前面的普通写在 volatile 写之前刷出去。LoadStore 屏障屏障前的读先于屏障后的写完成。场景是防止把后续写提前到前面的读之前。StoreLoad 屏障最重的一类屏障前的写操作全部刷新到主内存后屏障后的读操作才能开始。它同时具备其他三类屏障的效果但开销最高因为要在写完之后等待缓存行状态确认。为什么需要四种而不是一种因为不同处理器提供的屏障指令不同JMM 在设计时就定义了一套抽象屏障再让 JVM 根据目标平台映射到具体指令。比如 x86 平台的 StoreLoad 开销明显高于 ARM这就是为什么 JMM 不说“必须用某条指令”而是说“必须满足这种屏障语义”。4.2 volatile 与 synchronized 的屏障落地volatile 在字节码层面就是一个字段的 ACC_VOLATILE 标记真正起作用的是 JVM 在编译和生成机器码时插入的屏障。volatile 写写入前置入 StoreStore 屏障禁止前面的普通写与本次 volatile 写重排并确保前面普通写的值先落主内存写入后置入 StoreLoad 屏障禁止本写与后续的 volatile 读写重排同时让本写对后续读可见。volatile 读读取后置入 LoadLoad 屏障禁止后续普通读与本次 volatile 读重排随后置入 LoadStore 屏障禁止后续普通写与本次 volatile 读重排。这个细节解释了前面 happens-before 推导的实际含义volatile 写后面的“普通写不能被重排到写之前”volatile 读后面的“普通读不能被重排到读之前”——屏障正是这些规则在硬件层的具体承诺。synchronized 落地更复杂。加锁时除了互斥JVM 会确保进入临界区前线程会重新从主内存加载共享变量退出临界区时会把工作内存中的改动刷回主内存。尤其在偏向锁和轻量级锁优化之后锁并不总是一上来就是重量级监视器锁但“可见性”这一层语义始终必须满足。想搞清楚 synchronized 的完整底层实现可以看后续文章这里你只需要记住synchronized 同时提供原子性、可见性、有序性三重保证代价是锁竞争带来的阻塞和上下文切换开销。4.3 DCL 单例一个必须用 volatile 的经典案例每次讲 JMM双检锁Double-Checked LockingDCL都是必须提的案例因为它把“有序性”这个经常被忽略的特性暴露得非常直观。public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这段代码在 Java 5 之前是错的Java 5 重新修订 JSR-133 之后只有加上 volatile 才是安全的private static volatile Singleton instance;为什么new Singleton()在分解后包含三个动作分配内存空间初始化对象字段把引用赋值给 instance。问题在于步骤 2 和步骤 3 没有数据依赖赋值操作不依赖字段初始化编译器和 CPU 完全可能把顺序调换成“分配内存 - 赋值引用 - 初始化字段”。此时另一个线程走到instance null检查发现 instance 不为 null直接返回使用——但对象字段还没初始化完拿到的是一个“半构造”的脏对象。volatile 在这里做了什么volatile 写对 instance 的赋值前面的普通写对象字段初始化不能重排到赋值之后——这是 StoreStore 屏障的语义。所以只要 instance 的引用对其他线程可见说明对象初始化已经完成。一个懒加载单例的线程安全本质上不是锁的功劳而是 volatile 禁重排序的功劳。这个案例也说明了 JMM 的价值如果你只背“DCL 要加 volatile”永远不知道它是解决可见性还是解决有序性理解了屏障语义后再碰到类似“发布一个看似完整但内容未就绪的对象”的场景就能举一反三。5. 面试与实战JMM 的常见坑和答题要点最后一部分把 JMM 应用中最常踩的坑集中说一遍。这些内容既能在面试里直接输出也能在写代码时帮你避开生产事故。5.1 volatile 为什么不能保证 i 原子性前面提及的count案例这里展开完整分析。i在 JVM 中不是一条指令而是三条getfield读、iadd加、putfield写。假设两个线程同时读到 i 5各自加 1 得到 6先后写回最终 i 6而不是 7。这个丢失的增量与缓存无关与可见性无关——两个线程都基于同一个旧值计算属于典型的竞态条件。volatile 能让“写”的结果第一时间对其他线程可见但无法阻止两个线程读取同一个旧值。要解决它有三种方式使用 AtomicIntegerincrementAndGet()在底层通过 CAS比较并交换保证读改写流程的原子性使用 synchronized 包裹i用锁的互斥特性保证同一时刻只有一个线程执行用 LongAdder 这类高并发计数器适合高竞争场景。面试官问“volatile 能保证原子性吗”标准答案是“volatile 保证可见性与有序性不保证原子性”。如果你想显得更有深度就补充long和double的 64 位访问在普通变量下可能被拆成两次 32 位操作volatile 在这里反而能保证单次读写的原子性——这也是一种原子性保证但只在 64 位基本类型上成立和i这种复合操作完全无关。5.2 final 字段的 JMM 语义与安全发布JMM 对 final 字段有一条特殊的重排序限制构造函数内对 final 字段的写与构造函数外对该字段引用的发布不会被重排序。也就是说只要对象正确安全地发布不是让 this 逸出任何线程读到的 final 字段值一定是构造完成后的值。这个语义极大简化了不可变对象的使用public class Point { private final int x; private final int y; public Point(int x, int y) { this.x x; this.y y; } }只要 Point 对象被正确构造并发布其他线程读 x、y 时就不需要同步保证能看到构造器里写入的值不会看到默认的 0。但有两个容易忽略的前提final 只保证引用本身的安全发布不保证引用指向对象内部状态安全。如果 final 字段指向一个可变 ListList 内部的增删仍然需要同步不要在构造函数中把 this 引用传递给其他线程这叫构造期间逸出。逸出后其他线程可能看到 final 字段尚未赋值完成的对象JMM 无法在这种情况下做保证。我在实际项目中推荐一个组合拳共享的不可变对象用 final 字段 安全发布共享的可变对象优先用并发容器实在需要裸共享变量的才考虑 volatile 或 synchronized。这个优先级能让绝大多数并发设计更简单。5.3 面试答题策略从概念到案例面试答 JMM不同深度对应不同分数档。第一档背诵定义。“JMM 是 Java 定义的一套内存抽象模型规定主内存和工作内存的交互方式。”——能过基础题但很容易被追问卡住。第二档讲清三大特性 内存交互操作。能说出原子性、可见性、有序性的差异举出i和 volatile 的案例基本能过大多数 Java 岗位面试。第三档用 happens-before 推导 内存屏障解释底层。比如面试官问“volatile 如何实现可见性”你可以从工作内存刷新讲到 StoreStore / StoreLoad 屏障再引出 volatile 写读建立 happens-before 关系。这一层答出来面试官通常会认为你有深入阅读源码和规范的能力。第四档能结合实际问题判断“这段代码是否安全”。面试官给一个场景你能画出 happens-before 链路指出哪条链路断了、会导致什么后果。这是我在一线招聘时最看重的并发能力不是背结论而是具备一套通用的推导方法。5.4 实战习惯把 JMM 当作设计工具最后分享几条我从实际项目中总结出的经验都是踩过坑之后才真正明白的。第一共享可变变量越少越好。很多并发 Bug 源于“哪里都在改同一个 HashMap”。能用局部变量就不用共享变量能封装到线程内部就不暴露给其他线程。第二优先选择更高级的抽象。Executors、BlockingQueue、ThreadLocal、并发容器都是别人帮你实现了并发细节的现成品。自己拿 synchronized 和 volatile 去拼一个复杂结构出错概率远高于用成熟容器。第三加同步之前先问三个问题这个操作需要原子性吗需要可见性吗需要禁止哪些重排序三个答案决定了你该用 volatile 还是 synchronized 还是 AtomicInteger。我问过不少同事他们写锁的时候很少先想清楚这个问题这就是 Bug 的温床。第四不要把“压测通过”当作并发安全的证据。并发问题经常在特定调度窗口下才出现测试环境负载特征和生产完全不同。你唯一能做的是在设计阶段把 happens-before 链路画全让每个跨线程读取都有明确的安全边界。最后再分享一个记忆技巧分析并发代码时我会在草稿纸上把所有跨线程访问的变量画成节点然后把每一条同步关系synchronized 的锁、volatile 变量、Thread.join 等标成箭头。如果某个线程读变量时没有箭头指向它这里就是潜在 Bug。这套“画箭头”的笨办法帮我找出过不止一个线上隐患比瞪着屏幕空想要高效得多。