1. 为什么每本Java书都躲不开JMM做Java开发多年一开始我对并发编程的认知基本停留在用synchronized加锁、用volatile修饰共享变量这种操作层面。真正被逼着去啃JMMJava Memory ModelJava内存模型是在一次线上偶发数据错乱的排查现场。两个线程同时操作同一个共享配置对象代码里该加的锁都加了但就是间歇性出现一个线程读到旧值的情况。那次之后我才意识到光是知道加锁远远不够你必须理解锁在内存语义层面到底保证了什么JMM规定的happens-before关系又是如何兜住可见性和有序性的。这篇内容我想把JMM这套东西掰开揉碎讲清楚从底层硬件的行为讲到抽象模型再到日常开发怎么用、排查问题怎么想一条线走下来希望能帮你把脑子里那些零散的知识串成一张网。先说一个很多人都会误解的点JMM跟JVM的内存区域划分堆、栈、方法区不是一回事甚至跟物理内存也没有直接对应关系。JMM定义的是一套跨线程数据可见性的抽象规则它回答的核心问题是当线程A修改了一个值线程B什么时候能看到这个修改为什么有时候看不到在编译器、CPU都可能重排序的现实下哪种顺序是程序必须保持的要理解JMM得先看看底层硬件到底有多不守规矩。现在的多核CPU每个核都有自己的高速缓存L1/L2甚至L3是共享的还有写缓冲区、指令乱序执行等技术。单线程看来完全正常的顺序在多核视角下数据的写入可能先落在核的高速缓存里很久之后才刷到内存中。Java语言要跨平台就不能依赖某一类CPU的具体行为必须在语言层面统一出一套对程序员可见的、稳定的内存语义这就是JMM存在的意义。所以从设计目标看JMM要做两件事第一规定线程之间如何通过共享内存进行通信什么情况下一个线程的写入对另一个线程可见第二规定哪些重排序是不允许的给程序员的直觉一个明确边界。这两件事分别对应了可见性和有序性的问题再加上原子性就是并发三大特性的全部家底。如果你只记一句话那应该是JMM是Java并发正确性的基石synchronized、volatile、final、锁工具这些机制的背后最终都是靠JMM的规则在支撑。2. 主内存和工作内存并不是真有两块内存JMM里最容易被误解的就是主内存和工作内存这两个词。很可能你在网上看到的图是一个CPU连着主内存每个线程还有自己的工作内存于是你脑子里浮现出内存条和CPU寄存器两处物理区域。其实这是一个抽象模型不是物理结构。主内存可以粗略理解为所有线程共享的那份数据区在JVM里大致可以对应堆中共享变量的存储位置。工作内存则是对CPU高速缓存、写缓冲、寄存器、编译器优化后的暂存位置等一整套底层机制的统称每个线程私有一份。线程操作一个共享变量不是直接到主内存里改而是先把主内存的值拷贝到自己的工作内存在自己的地盘上处理处理完再按某种时机刷回主内存。这个过程我用一个生活化的类比解释过很多次把主内存想象成办公室公共区域的公告板每个线程的工作内存就是各人手边的草稿纸。张三改了一个数字如果只是写在自己的草稿纸上没有正式誊到公告板上李四就算跑去看公告板也只能看到旧值就算张三誊到公告板了李四手边的草稿纸上也未必已经更新。线程之间互相不知道对方草稿纸上是新是旧各种诡异的问题就来了。JMM对主内存和工作内存之间的交互定义了8种原子操作。这8个名字听起来吓人其实分成三组就好记了工作内存内部操作use把工作内存的变量值交给执行引擎、assign把执行引擎的值赋给工作内存的变量副本在主内存和工作内存之间搬运read从主内存读值到工作内存、load把read读到的值装载到工作内存的变量副本、store把工作内存的变量值传送到主内存、write把store传来的值写入主内存的变量加锁相关lock把主内存变量标记为线程独占、unlock释放主内存变量的独占标记这些操作之间有严格的约束。例如read和load必须成对出现store和write必须成对出现但不要求连续执行中间可以插入其他操作不允许线程丢弃最近一次的assign操作也就是说工作内存中修改过的变量没有store到主内存之前不能因为后续又赋了新值就直接抛弃旧值的同步义务。这些规则本质上是在回答数据什么时机上行、什么时机下行、能不能省略某一次同步。说实话日常写CRUD代码几乎用不到这8个操作的名字但它是理解volatile和synchronized底层语义的钥匙。volatile修饰的变量JMM要求每次use之前必须重新load每次assign之后必须立刻store相当于绕过了攒一批再刷的懒同步机制强制走实时同步通道。synchronized则是在进入临界区时执行刷新关卡退出时执行让出关卡把跨线程的可见性边界划得明明白白。3. happens-beforeJMM给程序员的安全承诺书如果你觉得8种操作太底层、太抽象没关系JMM还给程序员准备了一套更友好、更常用、分析并发问题时真正的主力工具——happens-before规则。happens-before的表述很简洁如果操作A happens-before操作B那么A的执行结果对B是可见的并且A的执行顺序在B之前在允许重排的语义下指的是A产生结果的操作在B需要这个结果的操作之前完成并且这个结果必须能传递到B。这里要注意happens-before是一种语义上的先后不是时间线上的先后它是JMM对编译器、CPU下达的约束指令违反了这个约束的重排序是不允许的。happens-before规则一共有8条但平常分析问题真正高频使用的是下面几条程序次序规则。在同一个线程内按照代码顺序前面的操作happens-before后面的操作。注意这不是说编译器不能重排无关语句而是说重排不能破坏这个逻辑先序的可见性语义。单线程内你几乎感觉不到差异因为它只保证结果一致性。锁规则。对一个锁的解锁操作happens-before于对同一个锁的加锁操作。这条是synchronized一切保证的核心线程A退出同步块之前做的所有共享变量修改在线程B进入同一个同步块之后一定全部可见。这里强调的是同一把锁不同锁对象之间没有这个保证。volatile变量规则。对一个volatile变量的写操作happens-before于对该变量的读操作。所以线程A对volatile变量flag的写入线程B只要读到flag的最新值就能看到A在写flag之前做的所有操作的效果。这就是volatile能提供轻量级可见性的底层来源——它只保证单次读写的可见性不保证复合操作的原子性。传递性。如果A happens-before BB happens-before C那么A happens-before C。这条规则的价值在于串联你代码里可能并没有直接让A和C操作同一个锁、同一个volatile变量但通过传递性它们之间也能建立起可预测的同步关系排查问题时特别常用。还有几条不常被提起但同样重要的线程启动规则对线程的start()调用happens-before于该线程内的任何动作、线程终止规则线程内所有操作happens-before于其他线程检测到该线程终止、中断规则对线程的interrupt()调用happens-before于被中断线程检测到中断事件、finalizer规则对象的构造函数完成happens-before于finalizer开始执行、传递性前面说了。这些规则合起来构建了一张哪些操作之间有可见性保证的全景图。工程上分析并发问题我基本只做两件事一是找代码里是否存在锁规则、volatile规则、线程启动/终止规则这些显式同步点二是通过传递性推断两个线程操作之间的可见性链条。如果链条断了那这个并发缺陷不是偶然而是必然。拿最常见的一个场景举例线程A设置config newConfig; flag true;线程B循环检查if(flag) use(config);。如果flag不声明为volatile也没加锁那依照JMM线程B完全可能看到flag true但config还是旧值因为A对config的写入happens-before对flag的写入但这条先后关系只在A线程内部成立跨线程没有任何规则能把config的修改传递到B。一旦把flag声明为volatileA写flag就happens-before B读flag再通过传递性A写config的结果也对B可见了。这就是很多所谓优雅停机的标准写法背后真正的原理。4. volatile与synchronizedJMM在代码里的两张脸很多初学并发的人会把volatile和synchronized搞混要么觉得volatile比synchronized快所以能替代它要么觉得有了synchronized就不需要volatile。这两种想法都不对。搞清楚它们在JMM语义下的真实分工选择起来就很自然。4.1 volatile到底保证什么、不保证什么volatile保证的是可见性和有序性。可见性前面说了对一个volatile变量的写后续对该变量的读一定能看到这个写并且能看到写之前一系列操作的结果。有序性体现在JMM对volatile的特殊约束上写volatile变量时JMM会插入内存屏障禁止前面的普通写操作重排到写volatile之后读volatile变量时禁止后面的普通读操作重排到读volatile之前。简单说volatile划出了操作之间的隔离墙保证它两侧的普通操作不会混到对方阵营去。volatile不保证的是原子性。经典反例volatile int count;多线程执行count结果仍然是丢失更新。因为count在字节码层是读、加一、写回三步三步之间随时可能插入其他线程的操作。volatile只保证每次读都是最新值、每次写都会立刻刷新但读旧值→计算→写回这个整体过程并不是不可分割的。count要线程安全必须用synchronized、AtomicInteger或带锁的同步机制。那什么时候该用volatile我的经验是三个条件同时满足时对变量的写操作不依赖于当前值比如写一个布尔标志、一个固定数值该变量不需要与其他状态变量共同参与不变式约束多线程只有读取这个变量的需求不需要加锁保护复合操作。典型的场景就是状态标志、开关标记、发布不可变对象引用比如把volatile引用指向一个新建的不可变配置对象。4.2 synchronized为什么既是锁又是内存栅栏synchronized在JMM语义下保证互斥和可见性两条。互斥好理解同一时刻只有拿到锁的线程能进入临界区。可见性则来自锁规则的强制要求线程进入synchronized块时会从主内存重新加载相关变量到工作内存线程退出synchronized块时会把这期间修改过的共享变量刷新回主内存。这相当于锁的acquire和release天然携带了内存屏障。于是我们有了一个重要的推理工具如果在临界区内写入共享变量A另一个线程在同一个锁的临界区内读取A那么写入一定对读取可见不需要A是volatile。反过来也成立线程退出临界区后它写的所有共享变量都会对下一个进入同一把锁的线程可见甚至不需要那些变量本身带任何修饰。这解释了为什么并发教科书总说同步块是可见性的边界。很多团队的规范里要求所有共享变量的修改必须发生在锁保护范围内本质就是在利用锁的JMM语义锁定可见性和原子性两条线。4.3 两者的组合与取舍实际代码里volatile和synchronized经常组合使用。一种典型组合是状态变量用volatile复合操作加锁。比如一个缓存加载器用一个volatile的loaded标志表示缓存是否就绪加载过程本身用synchronized保证不重复加载。volatile负责跨线程发布状态锁负责保护临界区内的复杂操作各管一摊配合得很干净。另一种误导很深的观点是读多写少就该用volatile替代锁。读写锁、CopyOnWriteArrayList、ConcurrentHashMap这些工具在JMM层面做的事远不是volatile单变量能替代的。理解JMM之后你再去看java.util.concurrent包的实现会发现大量volatile字段的存在——它们靠volatile保证字段可见性靠Unsafe/CAS或synchronized保证原子性两者互相配合缺一不可。这也是为什么我常说JUC源码是检验JMM理解程度的最佳试卷。5. 用JMM视角拆解三个经典并发陷阱理论讲多了容易飘落到具体代码里才有感觉。我挑了三个工作中反复出现、网上提问率极高的场景用JMM的规则一条条推给他们看。5.1 双重检查锁与单例的volatile之谜双重检查锁Double-Checked LockingDCL单例是JMM最经典的考卷。代码长这样public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { // 第一次检查无锁 synchronized (Singleton.class) { if (instance null) { // 第二次检查加锁 instance new Singleton(); } } } return instance; } }问题集中在instance new Singleton()这一步。它在字节码层面大致分三个阶段分配对象内存、调用构造函数初始化对象、把引用赋给instance。如果编译器把初始化和赋值引用两步重排了这在无volatile时是JMM允许的那线程B可能在instance已经非空但对象还没完成初始化的时候直接返回这个引用然后一用就爆炸。volatile在这里的作用就是禁止这两步重排确保一个线程看到instance ! null时它背后的对象一定已经完整初始化。这个例子每次讲完都有同行恍然大悟——原来volatile不只是可见性它还在管有序性。5.2 简单的boolean标志加不加volatile的天壤之别public class TaskRunner { private boolean stopped false; public void stop() { stopped true; } public void run() { while (!stopped) { // do something } } }主线程调用stop()工作线程在while循环里检查stopped。不加volatile时工作线程可能永远看不到stopped的最新值因为它的工作内存里缓存了旧值主线程的写入没有happens-before关系传递过来。这在循环体很空、循环很快的场景下尤其容易复现表现为程序停不下来。把stopped声明为volatile主线程写它就是对工作线程读它的happens-before前提可见性建立循环正常退出。这也是我建议所有跨线程的状态标志默认都加volatile的原因——成本极低但能消除一整类偶发停不下来的玄学问题。5.3 不同锁对象看起来都加了锁实际等于没加有次帮人排查线上问题代码大概长这样一个服务类里有两个方法方法A加了synchronized(this)方法B加了synchronized(lockObj)两个方法都在修改同一个共享Map的key。问为什么高并发下数据还是会错乱。我用happens-before一推就明白了两个方法用的不是同一把锁锁规则完全不生效线程A和线程B之间没有任何可见性关系就算锁都生效两个锁也各自管各自的临界区互相之间根本不存在互斥。这种问题用肉眼看代码很难发现因为你满眼都是synchronized但从JMM的角度看锁的身份不明一切都是白搭。6. 工程实践把JMM变成并发代码的检查清单理论的价值在于指导实践。我这里整理了一套自己一直在用的并发代码review清单每一条都能对应到JMM的具体规则推荐你也试试。第一列出所有被多线程访问的共享变量包括被锁保护的、volatile的、final的逐一标注可见性靠什么保证。如果某个共享变量既不是volatile又不在任何锁的保护范围内也没有final修饰那它跨线程的可见性就是裸奔状态出bug只是迟早问题。第二检查每个加锁点是否用的是同一把锁。锁规则只对同一把锁生效。不同锁、不同对象的monitor在JMM语义下就是两条平行线永远不会有交集。第三检查volatile变量的使用是否满足不依赖当前值的条件。如果你写volatile int i; i;这在多线程下依然是错的因为i不是原子操作。第四检查复合操作的边界。如果一段逻辑要先读一个共享状态再做计算再写回共享状态那么即使每个共享变量都标了volatile整个读-算-写过程仍然不是原子的需要锁或原子类来兜底。第五检查锁的粒度跟业务需求是否匹配。JMM不负责帮你判断粒度的合理性但如果你在用锁保护一个根本不需要跨线程可见性的局部临时变量那就是白白损失吞吐量。粒度选择主要靠对业务不变式的理解JMM解决的是用了锁是否安全的问题。这套清单在code review时特别管用。很多新人写并发代码眼睛只盯着有没有加锁却从不想锁对象是谁、可见性靠什么建立、原子性边界在哪。把JMM当检查工具很多问题在第一遍review时就能被消灭而不是等到线上出事故再定位。再分享一个压测阶段的验证技巧在测试环境可以试着关闭JIT的部分激进优化比如-XX:-EliminateLocks这类参数人为弱化底层的优化行为模拟更保守的执行语义。如果代码在这种环境下暴露出可见性或重排序相关的问题那大概率说明某些共享变量的同步保障不完善。这种方法不适合生产环境长期开启但作为验证手段比靠肉眼在代码里找bug高效得多我实测下来很稳。我个人在实际排查中的体会是遇到偶发、复现不了、加锁无效的并发问题先别急着怀疑硬件和框架回到JMM的规则表上用最朴素的这条读写路径上存不存在happens-before关系来提问。答案往往就藏在你没注意的那个共享变量上——它可能没加volatile可能加错了锁对象可能依赖了非线程安全的工具类。JMM说白了就是一张底层地图有了它并发问题不再是玄学而是有据可依的逻辑推理题。最后留一个小建议不要创造性地发明并发同步方案先学会把JMM规定的几种标准模型锁内可见、volatile发布、不可变对象、线程间通信用熟。绝大多数场景标准模型都够用了。等你哪天真需要unlocked高性能路径时再回过头从JMM的8种操作和内存屏障去设计自己的同步策略也不迟。