
我做技术面试这几年一个特别直观的感受是但凡问到 JMMJava Memory ModelJava 内存模型十个人里有六七个会把话题引向堆内存、栈内存、方法区剩下两三个能提到 volatile 和 synchronized但再往深追问一句为什么 volatile 能保证可见性基本就卡壳了。这个现象跟工作年限没什么关系我见过写了七八年 Java、每天在并发代码里打转的开发者在这个问题上照样说不到点子上。这篇文章不打算带你背八股而是把 JMM 的来龙去脉、核心规则、背后的硬件原因以及面试里最常被追问的几个题目完整拆一遍。无论你是要准备面试还是想把并发基础真正补扎实都值得认真看完。1. 面试里最常见的翻车现场把 JMM 当成 JVM 内存结构1.1 堆、栈、方法区那是运行时数据区不是内存模型如果你背过 JVM 相关的面试题大概率对堆、栈、方法区、程序计数器、本地方法栈这一套很熟。但请注意这套东西的正式名称是 JVM 运行时数据区Runtime Data Area它描述的是 JVM 在执行 Java 字节码时把内存划分成了哪些区域、每个区域放什么数据。它回答的问题是对象存在哪、方法调用栈长什么样是一张静态的地图。而 JMM 回答的是另一个完全不同的问题多个线程同时读写共享变量时什么行为是合法的、什么保证是语言层面一定会兑现的。它不关心给你的对象分配在哪块内存只关心线程之间如何通过内存通信以及这种通信在什么条件下是可靠、可预测的。把这两个概念混在一起等于把地图和导航规则混为一谈地图告诉你路是怎么分布的导航规则告诉你什么时候可以走、谁有优先权。面试官问 JMM想听的是后者。1.2 JMM 的正确定义一套线程通信规则而不是一块内存按照 Java 语言规范第 17 章的表述Java Memory Model 规定了一个多线程程序中哪些行为是被允许的以及 volatile、synchronized、final 这些同步机制分别提供什么样的保证。它的核心抽象是主内存 线程工作内存每个线程都有自己的工作内存对应寄存器和 CPU 缓存的抽象线程对共享变量的所有操作都必须在工作内存中进行不能直接读写主内存而且不同线程之间无法直接访问对方的工作内存。所有跨线程的变量传递必须经过主内存。这套抽象思路和很多 C/C 程序员的直觉不一样。写 C 的时候多线程共享变量的语义很大程度取决于编译器和目标平台换一个平台行为就可能完全不同。Java 则用 JMM 把最小保证固化在语言规范里只要你写的代码满足 JMM 的规则你就能在任何一个 JVM 实现、任何一类 CPU 上获得同样的语义保证。这是 JMM 最重要的存在意义。JMM 里还有一个经常被忽略的成员final 字段。JSR-133 给 final 设计了专门语义——只要对象发布的过程是安全的没有把半成品对象泄漏出去其他线程就能直接看到 final 字段的正确值不需要 volatile 也不需要加锁。这是很多人不知道的隐藏保证面试时作为补充点抛出来效果很好。1.3 为什么 Java 需要自己的内存模型而不是直接信任硬件这里有必要展开一下硬件背景。现代 CPU 都有多级缓存每个核心有自己的 L1、L2 缓存多个核心共享 L3 缓存。为了保持缓存和主存的一致性硬件设计了一套缓存一致性协议比如常听到的 MESI。按理说有了这套协议一个核心改了变量其他核心应该能看到。问题在于现代 CPU 为了性能还引入了写缓冲区等机制加上允许指令乱序执行硬件的一致性并没有强到让我们随心所欲。不同的 CPU 厂商对内存序的约定也不一样x86 系列的模型接近 TSO总存序约束相对较强ARM、RISC-V 这类弱内存模型的约束弱得多重排更自由。如果 Java 直接依赖硬件同样一段代码在不同架构上可能表现出完全不同的结果。JMM 的价值就是把这个差异彻底屏蔽掉它定义了一套统一的、与硬件无关的规则JVM 实现方负责把这些规则映射到具体 CPU 的屏障指令上而普通开发者只需要面对这套统一规则。所以 JMM 本质上是写代码的人、JVM 实现者、硬件三者之间的契约。2. JMM 要解决的三座大山可见性、有序性、原子性所有 JMM 的规则最终都是为了对付三个问题可见性、有序性、原子性。很多人背了这三个词但不知道它们是从哪里冒出来的。这一节从硬件到语言一层层把根源讲清楚。2.1 可见性CPU 缓存让共享变量不再真正共享先看一段看起来很无辜的代码public class VisibilityDemo { private static boolean flag false; public static void main(String[] args) throws InterruptedException { Thread writer new Thread(() - { try { Thread.sleep(100); } catch (InterruptedException ignored) {} flag true; }); Thread reader new Thread(() - { while (!flag) { // 空转 } System.out.println(flag 变成 true 了); }); reader.start(); writer.start(); reader.join(); } }直觉上writer 线程把 flag 改成 truereader 线程的 while 循环应该很快结束。但这段程序在真实环境里很可能永远不退出或者要过很久才退出。原因很简单writer 线程修改 flag 时这个改动可能只发生在自己的工作内存里还没刷回主内存而 reader 线程一直在自己的工作内存里读那个旧值。两个线程明明共享同一个变量各自看到的却是不同版本的值这就是可见性问题。JMM 对可见性的结构化描述是每个线程的工作内存是私有的普通变量的读写在线程间没有同步机制时JVM 不做任何保证。你可能早就写完这个 flag 了但对另一个线程来说它可能永远看不见。要强制让改动对其他线程可见就必须借助 volatile 或 synchronized 一类的机制。2.2 有序性编译器和 CPU 都不会乖乖按代码执行第二个问题是重排序。为了优化执行效率编译器、JIT、CPU 都可能调整指令的执行顺序。比如int a 1; // 指令1 int b 2; // 指令2在单线程下JVM 和 CPU 只要保证最终结果和按代码顺序执行一致即满足 as-if-serial 语义就可以自由地把指令2放到指令1前面。这个自由度在单线程里完全没问题但在多线程里就会坑人。一个典型的例子就是单例模式的双重检查锁定后面会专门讲。现在先记住结论多线程环境下重排序会让你没写错代码但程序就是不对。JMM 对有序性的处理方式不是禁止重排序而是规定什么情况下重排序的结果不能被你感知。对于有 happens-before 关系的两个操作JVM 必须保证前一个操作的结果对后一个操作可见并且执行顺序按 happens-before 来确定。没有 happens-before 关系的操作随便怎么重排JMM 都不管。2.3 原子性一条 Java 语句背后往往是多条底层指令原子性说的是一个操作要么全部执行要么全部不执行中途不能被其他线程插入。很多人觉得给变量赋个值肯定是原子的但实际情况要分情况讨论。最简单的例子是 ipublic static int counter 0; counter;counter 在字节码层面大体上是三条指令读取 counter、把值加 1、写回 counter。两个线程同时执行时完全可能出现这种交错两个线程都读到 0各自加 1再各自写回最后 counter 只变成了 1。即便把 counter 声明成 volatile也只能保证读写之间可见依然无法阻止读-改-写这个复合操作被交错执行。另外还有一个历史遗留的细节早年间某些 JVM 上对一个非 volatile 的 long 或 double 进行读写因为 64 位值可能被拆成两次 32 位操作从而出现读到一半的脏值。JMM 规定 volatile 的 long/double 读写必须原子普通 long/double 的原子性则不作硬性要求好在主流 JVM 在常见平台上已经按原子方式实现了。面试时能主动提到这一点是明显的加分项。2.4 JMM 的定位给开发者一份低于硬件细节的契约把三个问题放在一起看JMM 做的事情可以浓缩成一句话它定义了一套统一的抽象规则规定在什么条件下一个线程的写操作保证能被另一个线程看到可见性操作之间的顺序保证是什么有序性哪些操作保证不可分割原子性。至于 JVM 内部是用内存屏障、锁前缀指令还是别的手段去实现这些保证那是实现层面的事你不需要关心也不应该关心。早期 JMM 里还有一套 read/load/use/assign/store/write/lock/unlock 八种操作的描述用来规定工作内存和主内存之间的交互规则。现在看 Java 语言规范更偏向用 happens-before 和因果关系来描述。面试时如果被问到底层交互可以提一句这套八操作的框架但一定要把重心放在 happens-before 上那才是现代 JMM 的主心骨。3. Happens-Before 规则判断并发代码是否安全的标尺JMM 最核心的实用工具是 happens-before先行发生关系。它是一组定义在 Java 语言规范第 17 章里的规则。只要两个操作之间存在 happens-before 关系那么前一个操作的结果对后一个操作是可见的并且前一个操作的执行顺序不能以被后一个操作观察到的方式被重排。背规则不难关键是会用。3.1 八大规则逐一拆解通常我们把它整理成八条面试时最好能一口气说出来并且每一条都能跟一个使用场景对应规则内容典型场景程序次序规则同一线程内按代码顺序书写在前的操作happens-before 于后面的操作同一线程内两个普通赋值语句管程锁定规则对一个锁的解锁 happens-before 于后续对这个锁的加锁synchronized 块退出后下一个进入同一锁的线程能看到之前的修改volatile 变量规则对一个 volatile 变量的写 happens-before 于后续对该变量的读volatile 标志位、状态开关线程启动规则对 Thread.start() 的调用 happens-before 于被启动线程中的任何动作启动线程前准备好的数据线程启动后一定能看到线程终止规则线程中的所有操作 happens-before 于其他线程从该线程的 join() 成功返回用 join 等待线程执行完成后汇总它的计算结果线程中断规则对线程 interrupt() 的调用 happens-before 于被中断线程检测到中断事件中断标志位的传递对象终结规则一个对象构造完成 happens-before 它的 finalize() 开始执行终结器实际项目中已很少依赖传递性如果 A happens-before B且 B happens-before C则 A happens-before C多个规则串联推导注意管程锁定和 volatile 变量这两条的共同表述解锁或写 volatile发生在后续的加锁或读 volatile 之前。这里后续不是指时间上的先后而是指在同步关系上建立了连接通常表现为同一个锁的再次获取、同一个 volatile 变量的再次读取。这两条是实际并发编程里最常用的。3.2 程序顺序规则同一个线程这个前提很关键很多初学者对程序次序规则理解有误以为有了这条规则JVM 就会严格按照代码顺序执行所有指令。不是这样的。回到 as-if-serial 语义单线程下只要最终结果一致指令怎么重排都行。程序次序规则说的是从该线程自身的视角看前一个操作的结果对后一个操作是可见的不能被重排到影响自身结果的程度但其他线程看到的又是另一回事。举个例子线程 T 里先写 a 1再写 b 2。对 T 自己来说a 一定先写、b 一定后写中间不能插入会破坏结果的重排。但对另一个没有同步机制的线程 S 来说它完全可能先看到 b 2之后才看到 a 1甚至永远看不到 a 1。这就是为什么程序顺序规则兜不住跨线程的可见性——它只对同一个线程内部生效。3.3 靠传递性串联起来的安全链Happens-before 真正的威力在可传递。很多时候两个操作之间没有直接规则但可以通过一条链条串起来。最经典的串法就是volatile synchronized组合拳线程 A 在某个 synchronized 块里修改数据然后写一个 volatile 标志线程 B 循环读这个 volatile 标志发现变化后进入同一个 synchronized 块读数据。此时A 的写操作 happens-before A 对 volatile 标志的写程序次序A 对 volatile 的写 happens-before B 对 volatile 的读volatile 规则B 对 volatile 的读 happens-before B 进入同步块后的读程序次序。传递性一拼接A 修改的数据对 B 就是完全可见的。很多框架里的状态同步逻辑本质上就在利用这套传递链。面试时如果能主动讲一个多规则串联推导的例子会显得你真的理解了而不是背了八条口诀。3.4 一个例子用 happens-before 分析代码是否线程安全我拿一个很常见的场景来演示分析方法class SafePublish { private final MapString, String config new HashMap(); private volatile boolean ready false; public void loadConfig() { config.put(key, value); // 动作1 ready true; // 动作2volatile 写 } public String readConfig() { if (ready) { // 动作3volatile 读 return config.get(key); // 动作4 } return null; } }假设 config 只被写线程修改、读线程只做读取。线程 A 执行 loadConfig线程 B 执行 readConfig。动作1 happens-before 动作2程序次序动作2 happens-before 动作3volatile 规则前提是 B 真的读到了 true动作3 happens-before 动作4程序次序。传递一下只要 B 看到 ready 为 true动作1 的结果对动作4 必然可见。这就是用 happens-before 做安全判断的标准姿势找到两条线程之间的连接点volatile 读写、加锁解锁、线程启动或汇合然后把链条串起来。反过来如果 ready 不是 volatile动作2 和动作3 之间没有任何同步关系那么 B 即使看到了 true也不能保证动作4 能读到动作1 写入的值。很多并发 bug 就是这么来的。4. Volatile 深度拆解内存屏障、可见性与它的边界volatile 是 JMM 里出场率最高的关键字也是面试官最爱追问细节的地方。很多人只知道volatile 保证可见性但讲不清它到底是怎么做到的更说不清它的边界。这一节把机制拆到内存屏障的粒度。4.1 volatile 保证什么、不保证什么先澄清一个最重要的结论。volatile 提供两项保证可见性对一个 volatile 变量的写会立刻对其他线程的后续读可见。并且根据 happens-before 的传递性写线程在写入之前完成的普通写操作也会一并被读到。有序性volatile 变量的读写操作不会被 JVM 和 CPU 重排序到越过边界。具体来说volatile 读之后的普通读写不能重排到它之前volatile 写之前的普通读写不能重排到它之后。volatile 不提供原子性。加 1、判断后再写这类读-改-写复合操作它管不了。对 volatile 变量做 counter两个线程照样会丢更新。这是面试里最容易踩的坑很多人以为 volatile 是轻量级锁其实它不是。锁能互斥volatile 不能。4.2 四种内存屏障LoadLoad、StoreStore、LoadStore、StoreLoadJMM 把禁止重排序的底层实现手段抽象成了四道屏障。所谓内存屏障本质是一条特殊指令它告诉 CPU 和编译器屏障两侧的读写指令不能随便跨越这道门。屏障作用通俗理解LoadLoad确保屏障前的读先于屏障后的读完成两个读不能被重排序LoadStore确保屏障前的读先于屏障后的写完成读不能被后面的写越过去StoreStore确保屏障前的写先于屏障后的写完成两个写按顺序落盘StoreLoad确保屏障前的写先于屏障后的读完成在写和读之间建立完整边界开销最大对应到 volatile 的语义上常见的实现方案是volatile 读之后插入 LoadLoad 和 LoadStore 屏障防止后面的普通读写被挪到 volatile 读之前volatile 写之前插入 StoreStore 屏障防止前面的普通写被挪到 volatile 写之后volatile 写之后插入 StoreLoad 屏障防止后面的普通读被挪到 volatile 写之前。StoreLoad 几乎是全屏障也是四道里面最贵的。这里有一个很实用的架构知识点不同 CPU 上这些屏障的实现成本完全不同。x86 的 TSO 内存模型本身就不允许读读、写写、读写之间的乱序所以 LoadLoad、LoadStore、StoreStore 在 x86 上基本是空操作真正需要的是 StoreLoad而在 ARM、RISC-V 这类弱内存模型架构上四道屏障都可能触发真实指令。所以你在 x86 上怎么测都测不出来的并发问题换到 ARM 上可能立刻现形。这也是为什么生产环境跨架构部署时一定要严格遵守 JMM 规则不要靠我本地跑没问题来证明线程安全。4.3 双重检查锁定volatile 的经典应用场景双重检查锁定DCL是每个 Java 面试者必背的单例写法同时也是考察 volatile 理解深度的最佳题目。class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance null) { // 第一次检查不加锁 synchronized (Singleton.class) { if (instance null) { // 第二次检查加锁 instance new Singleton(); } } } return instance; } }为什么要加 volatile核心原因是第一次检查发生在锁外面。instance new Singleton() 在字节码层面不是一步分配内存、调用构造器初始化字段、把引用赋值给 instance。如果 JVM 把把引用赋值给 instance重排到构造器初始化字段之前那么另一个线程在第一次检查时就会看到非 null 的 instance但对象里的字段还是默认值等于拿到一个半成品。volatile 在这里的两种屏障恰好解决了问题写这边StoreStore 屏障保证构造器里的写完引用赋值这个 volatile 写才发生读这边volatile 读自身的屏障又保证后续使用不会被提前重排。于是所有看到非 null instance 的线程都能保证看到一个完整构造好的对象。顺带提一个历史冷知识DCL 在 Java 5 之前即使写了 volatile 也不靠谱因为旧的内存模型对 volatile 的语义定义不完整。JSR-133 在 Java 5 里重写了内存模型之后DCL 加 volatile 才成为标准写法。所以面试时说一句DCL 依赖 Java 5 以后的内存模型修复绝对是大加分项。4.4 什么场景该用 volatile什么场景别想偷懒根据我自己的经验volatile 适合这么几类场景布尔状态开关、进度百分比这类一个线程写、其他线程只读的独立变量。单例模式的双重检查锁定。发布不可变对象的引用引用本身是 volatile对象内容创建之后不再变化。配合锁或其他同步机制作为信号量传递 happens-before 关系。不适合的场景同样明显计数器、累加器、依赖当前值的状态更新。这些要么用 Atomic 系列类要么老实加锁。另外也别指望 volatile 能替代锁来保护一组相关变量的复合更新——它只能管单个变量的边界管不了多个变量需要作为一个整体提交这种事。5. Synchronized 与 JMM锁为什么是重型但全面的方案如果说 volatile 是精确制导的小手术那 synchronized 就是覆盖面最广的包治百病。理解 synchronized 和 JMM 的关系核心就一句话锁的释放和获取天然形成了跨线程的可见性边界。5.1 锁的释放与获取可见性的天然边界管程锁定规则说的是对一个监视器锁的解锁 happens-before 于后续对该锁的加锁。顺着这条规则再往实现层面看主流 JVM 实现会在同步块退出时做写冲刷把线程工作内存中修改过的变量刷回主内存在进入同步块时做读加载把需要用到的变量从主内存读到工作内存。这样一来锁的临界区就形成了一堵天然的保护墙——临界区之前的所有写操作对后面拿到同一个锁的线程都是可见的。这也是为什么 synchronized 可以同时保证三个性质原子性来自互斥同一时刻只有一个线程能进临界区可见性和有序性来自锁边界上的冲刷与加载。一个 synchronized 块基本等于跟其他线程说我进去之前写的东西你们都能看到我出来之后写的东西你们也都能看到。5.2 synchronized 如何同时满足三性用一个简单场景串一遍。线程 A 在 synchronized 块里修改了 data1、data2、data3 三个普通字段线程 B 之后通过 synchronized 进入同一把锁再读这三个字段。因为 A 的解锁 happens-before B 的加锁所以 A 对三个字段的所有写对 B 都可见因为 A 在锁内对三个字段的修改是互斥完成的B 不可能读到中间状态的组合。原子性、可见性、有序性一次全齐。反过来也要注意 synchronized 的局限如果线程 B 读取数据时没有加锁哪怕 A 加锁写了B 依然可能读到旧值。锁的可见性保证建立在同一把锁之上。A 用锁B 不用两边就搭不上 happens-before 的桥。很多线上问题排查到最后发现就是写的人加了锁读的人图省事没加锁。这个细节面试官也爱考。5.3 选型对比volatile、synchronized、原子类怎么选把三者放一张表里选型思路就清楚了维度volatilesynchronized原子类Atomic*可见性单变量保证临界区整体保证单变量保证有序性有屏障约束有锁边界通过 CAS volatile 实现原子性不保证保证保证单变量复合操作性能开销低x86 上接近普通访问较高但有偏向锁和轻量锁优化低无锁适用场景状态标志、安全发布、DCL多变量复合操作、代码块互斥计数器、累加器、CAS 循环选择的时候先问自己我的操作是单变量可见性问题还是多变量复合更新问题前者优先考虑 volatile 或原子类后者老老实实用 synchronized 或 Lock。不要为了追求性能把该加锁的代码改成 volatile最后收获一个诡异的线上 bug得不偿失。6. 面试实战高频 JMM 问题的拆解与答题框架最后把面试里真正会被追问的高频问题过一遍。这些问题我面试时基本都会问答得好不好很能反映一个人是背了题还是真懂。6.1 i 为什么不是线程安全的这个问题的标准拆解是i 不是一条指令而是读-改-写三步。用 javap 看字节码对应 getstatic、iadd、putstatic 三条指令。两个线程交错执行时可能都读到旧值、都加一、都写回导致少加了一次。即使把 i 声明成 volatile也只能保证每次读到的都是最新值但读-改-写这三步之间依然可能被另一个线程插入。所以要么用 AtomicInteger 的 getAndIncrementCAS 循环无锁要么加锁。答到这个层次面试官基本满意。如果还想再拔高一点可以补充long/double的 64 位读写也是一个相关话题volatile 能保证它们的原子性普通非 volatile 的 64 位读写理论上可能被拆成两次 32 位操作。虽然主流平台上实际已经原子化但这个细节体现了你对 JMM 的掌握颗粒度。6.2 volatile 能保证原子性吗答案必须是不能。要分两层说清楚对单次读写操作volatile 能保证原子包括 volatile long/double 的 64 位读写但对复合操作比如 counter、check-then-actvolatile 没有能力保证原子性。正确说法是volatile 提供可见性和有序性保证原子性由锁、原子类或 JDK 提供的其他并发原语来兜底。如果候选人斩钉截铁地说volatile 是原子的那基本可以判断他对 JMM 的理解停留在背概念层面。6.3 内存屏障和 happens-before 是什么关系这是一个很好的分层问题。happens-before 是 JMM 规范层面的抽象规则它定义了两个操作之间在逻辑语义上的顺序和可见性保证。内存屏障是 JVM 在实现这些抽象规则时向硬件下达的实际指令集合LoadLoad、StoreStore、LoadStore、StoreLoad 等。简单说happens-before 是法律条文内存屏障是执法手段。同一个 happens-before 关系在不同架构上映射出的屏障指令可能不同但开发者只面对 happens-before不需要关心底层实现。能把这个抽象层次讲清楚通常说明候选人是真的形成了体系化理解。6.4 我验证并发结论的土办法跑并发压测学 JMM 最大的问题就是光看理论无法确认。写并发测试永远有运气成分单跑一次过了不代表正确。我的建议是用社区里那些专门做并发正确性压测的开源框架来验证结论。这类工具的使用方式是写一个极小的竞态用例框架自动用大量线程反复交错执行把违反内存模型预期的结果统计出来。我在研究 volatile 重排序、DCL 这些问题时都是靠这种工具跑上十几分钟看结果比自己脑补可靠得多。拿它跑一个没有 volatile 的 DCL你就能非常直观地看到我以为没问题的代码在大量线程交错下是怎么翻车的。结合这些年的面试经验我最想强调的一件事是JMM 不只是一个面试考点它是理解 Java 并发所有工具的地基。volatile 为什么这么设计、synchronized 为什么能自动实现可见性、Lock 和 Condition 又基于什么保证最后都能溯回到 happens-before 和内存屏障这两块基石上。把地基打牢了后面学并发编程才会事半功倍。