讲个真实的面试场景。前阵子面一个写了五六年 Java 的候选人让他说说 JMM张口就是“Java 内存模型分堆和栈”。我先愣了一下随即意识到这是最典型的概念混淆把JVM 运行时内存区堆、栈、方法区、程序计数器和JMMJava 内存模型完全搞混了。如果只是记错了名次倒还好说但顺着往下聊可见性、happens-before、volatile 关键字发现他是真的没有把 JMM 的核心逻辑串起来。最后那句“我平时写代码好像也没遇到问题”更是暴露了问题不是没遇到而是根本没意识到某些 bug 的根源就在这。这篇不是教科书复读是我把 JMM 从面试、在线问题排查、代码设计三个维度揉碎了之后整理出的完整逻辑。无论你是在准备面试还是写多线程代码时觉得“好像懂了但说不透”看完应该能把 JMM 的骨架搭起来并且知道它到底在解决什么问题。1. 面试问到 JMM到底在问什么1.1 分清 JMM 和 JVM 运行时内存区很多人一听到“内存模型”四个字第一反应是“堆啊栈啊”。这是理解 JMM 的第一个坎必须先掰开。JVM 运行时内存区指的是Java 虚拟机运行时会把内存划分成哪几个区域比如堆、虚拟机栈、本地方法栈、方法区元空间、程序计数器。它是内存的“物理布局”回答的是“对象放在哪里、方法调用帧存在哪里”的问题。而 JMMJava Memory Model是另一层概念它描述的是Java 程序在多线程环境下的行为规范规定线程之间如何通过内存交互、什么时候一个线程对某个变量的修改能被另一个线程看到、重排序在什么范围内被允许、什么情况下必须禁止重排序。它解决的核心问题是“多线程读写共享数据时最终看到的值是否一致”。打个比方运行时内存区相当于一家公司的办公室布局你在哪个工位、文档放在哪个格子JMM 则是公司的协作规范规定信息通报流程、审批权限、哪些信息必须同步到公共系统。布局回答“东西在哪”规范回答“信息怎么流通、何时一致”。面试官问 JMM就是在问后者的逻辑尤其是主内存和工作内存之间的交互规则。1.2 为什么会有主内存和工作内存JMM 的核心模型并不复杂所有变量都存放在主内存每条线程还有自己的工作内存。线程对变量的所有操作读、赋值都发生在工作内存中不能直接读写主内存的变量。不同线程之间也没有直接访问对方工作内存的通道只能通过主内存“中转”。这个设计和实际的 CPU 硬件有对应关系现代 CPU 都有多级缓存L1/L2/L3寄存器又是更快的存储不同核心之间的缓存要通过缓存一致性协议同步。如果每次读写都直接访问主内存性能会低到无法接受所以硬件层面必须引入缓存。JMM 把这套硬件模型抽象成主内存和工作内存本质上是屏蔽了不同 CPU 架构的差异给上层 Java 开发者定义一套统一的可见性规则。对于写业务代码的人来说不需要感知 CPU 缓存怎么同步但得知道一个事实线程在工作内存里改了值可能不会立刻刷回主内存另一个线程读的时候也可能直接从自己的工作内存里读旧值。这就是共享数据在多线程环境下乱象的根源。而 JMM 要做的就是给出规则让“一个线程对一个共享变量的写入在满足特定条件时对另一个线程可见”。这套规则的基础就是 volatile、synchronized、final、锁等关键字对应的内存语义。2. 三条核心规则可见性、原子性、有序性2.1 可见性线程之间的“信息差”如何产生网上讨论多线程并发时有三性原子性、可见性、有序性。但很多人的理解停留在“记住这三个名词”的阶段问深一点就露馅。可见性说的是当一个线程修改了共享变量的值其他线程是否能立刻感知到这个修改。如果没有特殊机制答案是不能保证。原因就是我前面说的线程的工作内存可能没同步回主内存或读方没有重新从主内存加载。一个经典的可见性问题长这样public class VisibilityDemo { private static boolean flag false; public static void main(String[] args) throws InterruptedException { Thread reader new Thread(() - { while (!flag) { // 空转等待 } System.out.println(reader sees flag change); }); reader.start(); Thread.sleep(1000); flag true; System.out.println(main set flag); } }表面上看1 秒后主线程把 flag 改成 truereader 线程就会退出循环并输出。但如果没有额外同步机制reader 线程很可能一直空转下去因为它在自己的“工作副本”里读到的 flag 一直是 false。我见过很多人跑这段代码“没出现问题”那是因为循环体太短、JIT 还没来得及优化或者恰好赶上工作内存同步的时机。一旦用-server模式跑久了或者循环体里加一些复杂操作问题立刻暴露。想让这段代码稳定地按预期跑通最简单的做法就是把 flag 声明为 volatile。volatile 保证了对它的写操作能立刻对其他线程可见相当于每次读写都强制走主内存这条共享通道。2.2 原子性JMM 本身不提供万能药原子性指一个操作或多个操作要么全部执行且不被中断要么全部不执行。经典例子是i它由“读取 i 的值、计算 i1、写回 i”三步组成在多线程环境里这三个步骤可能交错执行导致最后的结果比期望值小。需要特别注意一点JMM 对基本类型的简单读取和赋值做了原子性保证比如对int、boolean这类 32 位以内的变量的读写是原子的对long、double这种 64 位变量的简单读写在未加 volatile 的情况下理论上不保证原子性但实际常见的 64 位 JVM 通常会保证规范层面却不是强制要求。但 JMM 不保证i这种“读-改-写”复合操作的原子性也不保证先判断再操作这类复合流程的原子性。想保证原子性要靠 synchronized、Lock 或者 AtomicInteger 这类并发工具。这里很多人有个误区以为 volatile 能保证原子性。volatile 只能解决可见性和一定程度的有序性它不解决复合操作的原子性问题volatile int i的i照样会丢更新。面试时如果能主动把“volatile 不保证原子性”讲清楚会加分不少。2.3 有序性指令重排的收益和代价有序性也很容易被低估。为了提高执行效率编译器和 CPU 都可能对指令进行重排序。在不改变单线程语义的前提下重排几乎是无害的甚至会显著提升性能。但多线程环境下一个线程的指令顺序变了另一个线程的观测结果就不可预测。典型的案例就是双重检查锁单例DCL。如果不加 volatile某个线程可能在对象引用还没完全构建完成时就把“半成品对象”发布出去了。核心在于instance new Singleton()这一步在字节码层面包含分配内存、初始化对象、把引用指向内存等多个操作。CPU 和编译器可能把“引用赋值”重排到“对象初始化”之前于是另一线程检查instance ! null成立直接使用了尚未完成构造的对象。要让这道题无懈可击标准写法是私有静态变量用 volatile 修饰同时双重检查加锁。volatile 在这里不仅保证了可见性还通过禁止指令重排堵死了“半成品对象发布”这条路。反过来也说明一个道理只要被 volatile 修饰的变量JMM 就会在读写两侧插入内存屏障限制其前后的重排序。这部分是理解有序性问题的实操关键。3. happens-before 才是面试真正的分水岭3.1 八条规则怎么记聊 JMM 如果每次都从“主内存、工作内存”讲起还只是及格水平。真正拉开差距的是你能不能把happens-before 原则讲得头头是道。它是 JMM 定义的一套偏序关系用来规定哪些操作在时间逻辑上必须先于另一些操作发生从而保证后一个操作能看到前一个操作的结果。八条核心规则我按记忆优先级排个序程序次序规则在一个线程内按代码顺序前面的操作 happens-before 后面的操作。monitor 锁规则解锁操作 happens-before 后续对同一把锁的加锁操作。volatile 变量规则对一个 volatile 变量的写操作 happens-before 后面对该变量的读操作。线程启动规则线程对象的 start() 方法 happens-before 该线程中的任意动作。线程终止规则线程中的所有动作 happens-before 其他线程对该线程的 join() 方法返回。线程中断规则对线程 interrupt() 方法的调用 happens-before 被中断线程检测到中断事件。对象终结规则对象的构造函数执行完 happens-before finalize() 方法的开始。传递性如果 A happens-before BB happens-before C那么 A happens-before C。面试的时候如果能随口把 1、2、3、8 这几条和实际案例结合起来讲比如“volatile 写读天然构成 happens-before所以写入的值一定能被后续读看见”面试官基本上就认可你是真懂了。3.2 规则推导实战volatile 写读怎么联动给大家演示一下怎么用 happens-before 推导问题。假设线程 A 修改了一个普通变量 x然后写一个 volatile 变量 v线程 B 读同一个 volatile 变量 v然后读变量 x。按照 volatile 规则A 中对 v 的写 happens-before B 中对 v 的读。按照程序次序规则A 中 x 的写 happens-before A 中 v 的写B 中 v 的读 happens-before B 中 x 的读。再结合传递性最终 A 写 x 的结果对 B 读 x 是可见的。这就是一个很重要的实践结论volatile 变量除了自己保证可见性还能“捎带”它的前序写入一起对其他线程可见。利用这个特性可以减少很多不必要的锁。反过来说如果你没利用好这条传递性只想靠一个普通变量去实现线程间通知那结果就是我在 2.1 小节里展示的“死循环等不到 flag 变化”。很多文章讲 happens-before 就列一堆规则但实际临场用得好的方式是先列出“我现在有哪些同步手段”然后顺着传递性推导两个线程的读写是否安全。这类推导我建议在面试前亲手练几遍比背十遍定义都有用。4. volatile、synchronized、final 各自解决什么问题4.1 volatile 的完整内存语义和内存屏障volatile 在 JMM 里的定位是“最轻量的同步机制”它的完整语义有两条写 volatile 变量时JMM 会立即把该变量在工作内存中的副本刷新回主内存。读 volatile 变量时JMM 会先把主内存中的值同步到工作内存再让代码使用这个新值。这保证了 volatile 变量的读写都直接面向主内存不仅是当前线程感知变化也迫使其他线程在读取时能拿到最新值。为了防止指令重排JMM 还在 volatile 读写前后插入了内存屏障Memory Barrier。简单说编译器在生成指令序列时会在 volatile 写操作之前插入 StoreStore 屏障之后插入 StoreLoad 屏障在 volatile 读操作之后插入 LoadLoad 屏障和 LoadStore 屏障。屏障的意义是告诉 CPU 和编译器屏障两侧的指令不许乱序穿过。不过 volatile 不提供原子性这一点我再强调一次因为实际开发中不少人把 volatile 当成万能锁用在计数器场景里结果线上数据错乱。计数类场景老老实实用 AtomicInteger 或 LongAdder或者加锁别再让 volatile 背锅。4.2 synchronized 的 monitor 规则怎么支撑一切synchronized 的底层实现涉及 Monitor监视器锁核心原理是每个对象都关联一个 monitor线程进入 synchronized 代码块前需要获取该 monitor执行完或异常后释放。同一个 monitor 同一时刻只能被一个线程持有所以它天然解决了原子性问题。从 JMM 视角看synchronized 的加锁和解锁对应的 happens-before 规则是“解锁 happens-before 后面对同一把锁的加锁”。也就是说线程 A 在锁内修改的所有共享变量在释放锁之后线程 B 获取同一把锁时都能看到这些修改。这就是 synchronized 同时保证原子性、可见性、有序性的根本原因。不过要注意synchronized 带来的有序性保证和 volatile 不一样。它靠“临界区互斥”避免了并发访问而不是直接禁止内部指令重排。在锁内部编译器依然可能对无关指令进行重排序。因为同一时刻只有一个线程在临界区内重排不会影响外部观察到的程序语义。但如果你在锁内把对象引用发布到锁外仍然可能出现问题这点需要结合 DCL 去理解。所以面试时可以这样总结synchronized 是“重武器”同时也保证互斥和内存可见性volatile 是“轻骑兵”只解决可见性和部分有序性解决不了原子性。4.3 final 的构造安全性和逸出问题final 在 JMM 里是一个经常被忽视的点。它的核心语义是在构造函数内对 final 字段的写入与随后把构造好的对象引用赋值给某个引用变量之间不能重排序。换句话说一旦一个对象的 final 字段被正确构造完成其他线程看到该对象引用时看到的 final 字段一定是最初写入的值不会出现“半初始化状态”。但这里有个前提构造函数中不能让this引用逸出。比如构造函数里把 this 注册给了某个全局监听器或者把这个对象传给另一个线程那 final 的保证就会被破坏。这种逸出问题很隐蔽代码评审时我经常提醒团队构造函数里做额外发布动作哪怕用 final 字段也未必安全。从常见实践角度说不可变对象是并发编程中最大的“免费午餐”能定义为 final 的字段尽量定义为 final能设计成不可变对象的类尽量设计成不可变。这比用一堆锁和 volatile 来得可靠得多。5. 实战排查一个可见性问题的完整复盘5.1 一段看似正常的代码为了把上面的原理落到实际我复盘一个此前帮同事排查的例子。他的业务场景是一个后台任务线程持续更新一个状态值MapString, Long另一个线程每 5 秒读取这个 Map 并上报统计。两个线程之间没有任何同步Map 也不是线程安全的但同事坚持说运行时“看起来没毛病”。我把他的代码简化成下面这样public class StatsReporter { private MapString, Long stats new HashMap(); public void update(String key, Long value) { stats.put(key, value); } public MapString, Long snapshot() { return new HashMap(stats); } }update 在后台线程跑snapshot 在读取线程跑。隐患有四层第一HashMap 本身多线程读写就可能出环严重的会让读取线程 CPU 飙升第二stats 引用没有可见性保证读取线程可能一直看到旧的 Map 对象第三即使看到了新 Map 对象内部的 put 操作和引用替换之间也可能存在重排第四snapshot 的new HashMap(stats)复制过程也不是原子性的复制时其他线程可能正在改 map。这类问题在本地小流量低并发下出现概率极低。但一旦到生产环境核心数多、JIT 优化激进、GC 压力和线程竞争交织在一起问题就冒出来了。5.2 加上了语义视角看问题如果你现在具备 JMM 的完整视角应该能很自然地列出修复方案而不是乱试。方案一把 stats 声明为 volatile。这就保证了引用替换对其他线程可见同时 volatile 写读会建立 happens-before。update 里的 put 发生在 volatile 写之前读线程在 volatile 读之后执行new HashMap(stats)时理论上可以看到 put 的效果。但注意这只解决了可见性和部分有序性HashMap 本身并发读写导致的内部结构错乱依然无解。方案二用 ConcurrentHashMap并用 volatile 持有引用。ConcurrentHashMap 内部用分段锁和 CAS 提供了并发安全性而 volatile 保证了 map 引用替换的可见性。大多数统计上报类场景这种方式就够了。方案三如果数据和业务强相关不容半点差错直接用 synchronized 保护 update 和 snapshot双保险既不担心可见性也不担心原子性。排查时我建议按“先保证并发安全容器 - 再加可见性 - 最后看原子性要求”的顺序去验证。不要一上来就上锁锁太多会影响吞吐。上面那种统计上报业务对精确性不是 100% 要求所以我最终给同事的方案是 ConcurrentHashMap volatile 引用实测上线后指标上报稳定性能没有明显劣化。顺带说一个排查技巧如果你怀疑是可见性问题可以先用-XX:PrintAssembly看热点代码的汇编观察有没有锁前缀指令或内存屏障但这对大多数人门槛太高。更快的办法是临时把共享变量都加上 volatile如果问题立刻消失基本可以锁定是可见性/有序性问题。再做根因分析时再回到代码结构上去排查竞争条件。6. 面试回答 JMM 的框架和避坑经验6.1 怎么组织回答让面试官觉得你真懂很多人在面试时回答 JMM 都是“散装”的想到什么说什么。我建议你按下面这个顺序组织逻辑最顺。第一层先说清楚 JMM 的定义它是一组规范不是实际的内存布局解决的是多线程共享变量的可见性、有序性、原子性问题。第二层说模型主内存和工作内存线程对共享变量的操作都发生在线程自己的工作内存线程间只能通过主内存交互。第三层说三性可见性强调工作内存和主内存之间的同步原子性强调复合操作的不可分割有序性强调重排对多线程的影响。第四层说 happens-before这是 JMM 提供的最强保证有了它才能推导某个同步方案是否可靠然后挑 volatie 和 synchronized 的规则展开。第五层结合实际给一个 DCL 或者计数器的例子说明为什么需要 volatile、什么时候必须 synchronized。按照这个框架下来大概三到五分钟内容量充足而且有推导有案例明显比“背定义”高一截。6.2 哪些回答一听就知道没理解我面试时最怕听到三类回答大家准备面试时可以自查一下。第一类“JMM 就是堆和栈”。这个错前面说过了根源在于没有区分运行时内存区和内存模型这两个概念。纠正方法很简单把 JVM 内存结构、Java 内存模型分开记各用一张图理一遍。第二类“volatile 可以保证原子性”。这说明对复合操作没有概念也不清楚 volatile 的内存语义只覆盖单个变量的读写。建议拿i自己写个多线程例子跑十遍数数丢了多少次更新印象会很深。第三类“主内存即物理内存工作内存即 CPU 缓存”。这个理解过于机械。JMM 的主内存/工作内存是一个抽象模型物理上可能对应主存、缓存、寄存器也可能对应编译器的优化结果。用静态的物理对应关系去解释动态语义容易在后续问题上翻车。有个常见追问是“普通变量读写为什么不是可见的”如果候选人只回答“因为没加 volatile”反而中了圈套。要知道普通变量的读写本身也有机会被 JIT 优化成寄存器操作而 volatile 通过内存屏障强制走主内存这才是“可见”的真正原因。能把这个细节讲出来说明是真研究过。还有一个小坑是关于 long/double 的非原子性。有些面试官会问“long 和 double 的写入在 JMM 中是否原子”。你回答规范层面不保证即可千万别加一句“所以实际 JVM 都不保证”那就错了64 位 JVM 上大多数实现其实会保证。严谨的说法是规范未强制要求但主流虚拟机实现中通常具备原子性。别把规范和实现搅在一起。我在面候选人的时候还经常加一问“如果两个线程同时 write 同一个 volatile 变量会怎样”答案是不会因为 volatile 就自动互斥它们仍然可以交错。volatile 不是锁。能秒答这个问题的人通常是真的写过并发代码的。最后再分享一个我自己的习惯。每次写完一段多线程代码我都会在脑子里过一遍 JMM 的检查清单共享变量有哪些、谁写谁读、写的值和读的值之间有没有建立起 happens-before、复合操作有没有加锁、对象发布有没有逃逸。这个习惯救过我好几次也推荐你试试。别把 JMM 当成面试八股它是在多线程代码里预防线上事故最重要的那层心智模型。
阅读完成 · 觉得有帮助?