Java 内存模型(JMM)
候选人小赵在面试拼多多 P7 时,面试官看了看简历上"擅长并发编程",问了一个看似简单的问题:
"你看下面这段代码,多线程环境下会有什么问题?"
小赵说:"没有同步,可能会有可见性问题。"面试官追问:"具体什么是可见性?为什么会有可见性问题?"
小赵说:"一个线程修改了变量,其他线程看不到..."面试官继续追问:"CPU 缓存和主内存之间是什么关系?JMM 规定了哪些 Happens-Before 规则?"
小赵彻底卡住了...
一、核心问题:什么是 Java 内存模型 🔴
1.1 问题拆解
JMM 是 Java 并发编程中最抽象、最难理解的概念之一,也是 P6/P7 面试的高频深水区。面试官的追问链:
1.2 ❌ 错误示范
候选人原话 A:"JMM 就是 JVM 的内存结构,堆内存、栈内存..."
问题诊断:把 JMM 和 JVM 运行时数据区混为一谈。JMM 是抽象模型,解决的是并发问题;JVM 运行时数据区是物理划分,解决的是内存管理。这是两个完全不同层次的概念。
候选人原话 B:"volatile 就是保证变量每次都从主内存读取。"
问题诊断:这个理解过于简单。volatile 确实保证可见性,但其底层实现远比"每次都读主内存"复杂——涉及 store buffer、invalidate queue、内存屏障等多种机制。
候选人原话 C:"synchronized 可以保证原子性、可见性和有序性,所以用它就够了。"
问题诊断:synchronized 虽然能解决这三大问题,但代价是锁的开销。JMM 的设计恰恰是为了在更细粒度上(volatile、原子变量、无锁算法)解决并发问题,而不是所有场景都用 synchronized 大炮打蚊子。
1.3 标准回答
P5 级别:说清 JMM 解决的问题
Java 内存模型(Java Memory Model,JMM)是 Java 语言规范中定义的一套抽象模型,用于描述多线程环境下,线程之间如何通过内存交互。
JMM 解决的核心问题是:可见性(Visibility)、有序性(Ordering)和原子性(Atomicity)——也就是并发的三大问题。
为什么需要 JMM?
在早期,Java 程序员以为只要用
synchronized同步所有操作就能保证正确性。但随着 CPU 多核化、编译器优化、JIT 编译等技术发展,编译器和 CPU 可能会对指令进行重排序,同时每个 CPU 核心有自己的缓存(L1/L2/L3),导致一个线程对共享变量的修改,对另一个线程可能"不可见"。JMM 的抽象:
JMM 将内存划分为主内存(Main Memory)和工作内存(Working Memory):
- 主内存:所有线程共享,相当于物理内存
- 工作内存:每个线程独有,相当于 CPU 缓存和寄存器
线程对变量的所有操作(读取、赋值)都在工作内存中进行,然后同步到主内存。JMM 规定了这种交互的规则——哪些操作必须先执行(A Happens-Before B),哪些重排序是允许的。
这个回答展示了 JMM 的核心概念,已经达到 P5 要求。
P6 级别:深入硬件层与 Happens-Before
CPU 缓存一致性问题:
在多核 CPU 中,每个核心有自己的 L1/L2 缓存(L3 通常是共享的)。当线程修改一个变量时,修改首先发生在该核心的缓存中。如果这个修改没有及时刷新到主内存,其他核心的缓存中仍然是旧值。
这就导致了可见性问题:一个线程写入的数据,另一个线程可能永远读不到。
硬件层解决方案:CPU 使用 MESI 协议(Modified-Exclusive-Shared-Invalid)保证缓存一致性。当一个核心修改缓存行时,其他核心的对应缓存行会被标记为 Invalid,强制它们在下一次读取时从主内存重新获取。
但 MESI 协议有性能问题——总线嗅探(Bus Snooping)开销很大。所以现代 CPU 引入了 Store Buffer(写缓冲)和 Invalidate Queue(失效队列)来异步化写操作,这也带来了新的可见性问题:写入 Store Buffer 的数据可能还没有刷新到缓存,CPU 就执行了后续的读操作。
JMM 的 Happens-Before 规则(JSR-133 定义了八大规则):
- 程序顺序规则:同一线程中,按代码顺序,前面的操作 Happens-Before 后面的操作
- 监视器锁规则:对一个锁的 unlock 操作 Happens-Before 对同一个锁的 lock 操作
- volatile 变量规则:对 volatile 变量的写操作 Happens-Before 对该变量的读操作
- 线程启动规则:
Thread.start()调用 Happens-Before 被启动线程中的任何操作- 线程终止规则:线程中的所有操作 Happens-Before 其他线程检测到该线程终止
- 中断规则:
interrupt()调用 Happens-Before 被中断线程检测到中断- 终结规则:对象的构造函数结束 Happens-Before 该对象的
finalize()方法- 传递性:如果 A Happens-Before B,B Happens-Before C,则 A Happens-Before C
Happens-Before 的含义:不是指时间上的先后,而是保证可见性和有序性——如果 A Happens-Before B,那么 A 的结果对 B 一定是可见的,且 A 不会被重排序到 B 之后。
【面试官心理】 JMM 是我面试 P6/P7 候选人的必考题。90% 的候选人能说出"可见性"这个词,但只有 20% 能说清为什么需要 Happens-Before 规则,以及 volatile 和 synchronized 在有序性保证上的差异。能提到 MESI 协议、Store Buffer 的候选人,说明他对底层有真实的好奇心,不是只背八股的。
1.4 追问升级
追问 1:volatile 是怎么保证可见性的?底层用了什么指令?
volatile的底层实现依赖内存屏障(Memory Barrier):
- Store Barrier(写屏障):强制将 Store Buffer 中的数据刷新到 CPU 缓存(和主内存)
- Load Barrier(读屏障):强制 CPU 在读取数据前清空 Invalidate Queue,确保读到最新值
在 x86 架构下,
volatile变量的写会插入lock前缀指令(lock; movl %eax, (%ebx))。lock前缀的作用:
- 强制将修改写回主内存(而不是只写到 Store Buffer)
- 阻止后续的读写指令重排序到 lock 指令之前
- 使其他 CPU 的缓存行失效(invalidate)
追问 2:synchronized 和 volatile 的区别是什么?
追问 3:双重检查锁定(Double-Checked Locking)为什么要加 volatile?
问题:
instance = new Singleton()不是原子操作。它分为三步:
- 分配内存
- 调用构造函数
- 将引用赋值给 instance
编译器和 CPU 可能重排序为 1 → 3 → 2,导致其他线程在步骤 2 完成前就看到了非 null 的 instance(但对象未初始化完成)。
volatile 的作用:通过写屏障,禁止步骤 3 重排序到步骤 2 之前。
二、缓存行与伪共享 🟡
2.1 什么是缓存行
CPU 缓存以缓存行(Cache Line)为单位工作,每个缓存行通常是 64 字节。当线程修改一个 volatile 变量时,整个缓存行都会被锁住(通过 MESI 协议),这意味着同一缓存行中的其他变量也会被间接影响。
伪共享(False Sharing):
LongAdder的设计就利用了 padding 来避免伪共享——每个Cell对象占据自己的缓存行,避免多线程修改同一个缓存行导致的性能退化。
2.2 MESI 协议详解
MESI 是四种缓存行状态的缩写:
- M (Modified):已修改,缓存行数据被修改,与主内存不一致,且独占
- E (Exclusive):独占,缓存行数据与主内存一致,但只有当前核心持有
- S (Shared):共享,多个核心持有相同缓存行,数据与主内存一致
- I (Invalid):无效,缓存行数据已失效
当一个核心写入 Shared 状态的缓存行时,会发送 Invalidate 消息给其他持有该缓存行的核心,其他核心必须将缓存行置为 Invalid。这正是 volatile 写操作导致其他 CPU 缓存失效的硬件基础。
三、生产避坑
3.1 LongAdder vs AtomicLong 的性能坑
场景:在极高并发场景下(多核 CPU,每秒数千万次更新),使用 AtomicLong 的 incrementAndGet() 导致所有线程在同一个缓存行上竞争,性能退化严重。
根因:伪共享——所有线程修改同一个 AtomicLong 实例的 value 字段,导致同一缓存行被频繁 invalidate。
解决方案:LongAdder 的 Cell 数组设计(每个线程累加到不同的 Cell),通过 padding 和分段热点,将竞争分散到多个缓存行。
3.2 JMM 顺序一致性问题
场景:单例模式双重检查锁定未加 volatile,在某些 CPU 架构(如 ARM)下可能导致 instance 不为 null 但对象未初始化完成。
根因:ARM 的内存模型比 x86 弱(允许更多的重排序),JMM 规范只要求 Happens-Before,不强制顺序一致性。
面试加分点:能说出"JMM 的happens-before和as-if-serial的关系"——as-if-serial 保证单线程程序看不出重排序,happens-before 保证多线程程序的可见性和有序性。两者是不同维度的保证,不能混淆。
面试陷阱:被问到"final 字段在 JMM 中有什么特殊保证"时,很多人会说"没有"。错!JSR-133 对 final 有特殊规则——final 字段的写入不会与构造函数外的操作重排序(通过内存屏障实现)。但前提是引用不要在构造函数中逸出,否则这个保证会失效。