Java 内存模型(JMM)

候选人小赵在面试拼多多 P7 时,面试官看了看简历上"擅长并发编程",问了一个看似简单的问题:

"你看下面这段代码,多线程环境下会有什么问题?"

public class VisibilityDemo {
    private static boolean flag = false;
    private static int data = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            data = 42;
            flag = true;
        });
        Thread t2 = new Thread(() -> {
            while (!flag) {
                // 忙等待
            }
            System.out.println(data);
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

小赵说:"没有同步,可能会有可见性问题。"面试官追问:"具体什么是可见性?为什么会有可见性问题?"

小赵说:"一个线程修改了变量,其他线程看不到..."面试官继续追问:"CPU 缓存和主内存之间是什么关系?JMM 规定了哪些 Happens-Before 规则?"

小赵彻底卡住了...

一、核心问题:什么是 Java 内存模型 🔴

1.1 问题拆解

JMM 是 Java 并发编程中最抽象、最难理解的概念之一,也是 P6/P7 面试的高频深水区。面试官的追问链:

第一层:概念理解(是什么?)
  "什么是 Java 内存模型?为什么需要 JMM?"
  考察点:是否能说清 JMM 解决的核心问题——可见性、原子性、有序性

第二层:硬件基础(为什么?)
  "CPU 缓存和主内存之间的一致性问题,JMM 是怎么解决的?"
  考察点:MESI 协议、缓存行、store buffer、invalidate queue

第三层:Happens-Before 规则(怎么做?)
  "JMM 规定了哪些 Happens-Before 规则?"
  考察点:八大规则的理解和实际应用

第四层:volatile 底层实现(源码细节)
  "volatile 是怎么保证可见性的?底层用了什么指令?"
  考察点:lock 前缀指令、MESI 协议的 flush 和 invalidate

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 定义了八大规则):

  1. 程序顺序规则:同一线程中,按代码顺序,前面的操作 Happens-Before 后面的操作
  2. 监视器锁规则:对一个锁的 unlock 操作 Happens-Before 对同一个锁的 lock 操作
  3. volatile 变量规则:对 volatile 变量的写操作 Happens-Before 对该变量的读操作
  4. 线程启动规则Thread.start() 调用 Happens-Before 被启动线程中的任何操作
  5. 线程终止规则:线程中的所有操作 Happens-Before 其他线程检测到该线程终止
  6. 中断规则interrupt() 调用 Happens-Before 被中断线程检测到中断
  7. 终结规则:对象的构造函数结束 Happens-Before 该对象的 finalize() 方法
  8. 传递性:如果 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 前缀的作用:

  1. 强制将修改写回主内存(而不是只写到 Store Buffer)
  2. 阻止后续的读写指令重排序到 lock 指令之前
  3. 使其他 CPU 的缓存行失效(invalidate)
// 伪代码解释 volatile 读写
volatile int x = 0;

// 写操作(伪汇编)
movl $1, %eax
lock; movl %eax, x   // lock 前缀保证写入主内存并失效其他 CPU 缓存

// 读操作(伪汇编)
movl x, %eax         // 强制从主内存读取(隐式内存屏障)

追问 2:synchronized 和 volatile 的区别是什么?

对比维度volatilesynchronized
原子性仅保证可见性,不保证原子性保证原子性
有序性禁止指令重排序(读写屏障)互斥,同一时间只有一个线程执行
可见性写后立即刷新读前立即失效lock/unlock 隐含刷新/失效语义
性能极低开销(仅内存屏障)较高开销(加锁/解锁、系统调用)
使用场景一个线程写、多个线程读的场景需要互斥的场景

追问 3:双重检查锁定(Double-Checked Locking)为什么要加 volatile?

class Singleton {
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {            // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) {    // 第二次检查
                    instance = new Singleton(); // 问题出在这里
                }
            }
}

问题instance = new Singleton() 不是原子操作。它分为三步:

  1. 分配内存
  2. 调用构造函数
  3. 将引用赋值给 instance

编译器和 CPU 可能重排序为 1 → 3 → 2,导致其他线程在步骤 2 完成前就看到了非 null 的 instance(但对象未初始化完成)。

volatile 的作用:通过写屏障,禁止步骤 3 重排序到步骤 2 之前。

二、缓存行与伪共享 🟡

2.1 什么是缓存行

CPU 缓存以缓存行(Cache Line)为单位工作,每个缓存行通常是 64 字节。当线程修改一个 volatile 变量时,整个缓存行都会被锁住(通过 MESI 协议),这意味着同一缓存行中的其他变量也会被间接影响。

伪共享(False Sharing)

public class FalseSharing {
    public static class Padding {
        public volatile long p1, p2, p3, p4, p5, p6, p7; // 填充
    }
    public static class Data extends Padding {
        public volatile long value; // 实际数据
    }
}

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,每秒数千万次更新),使用 AtomicLongincrementAndGet() 导致所有线程在同一个缓存行上竞争,性能退化严重。

根因:伪共享——所有线程修改同一个 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 字段的写入不会与构造函数外的操作重排序(通过内存屏障实现)。但前提是引用不要在构造函数中逸出,否则这个保证会失效。