【JVM原理详解】41-JMM基础-主内存与工作内存
41-JMM基础-主内存与工作内存
引言
前几个模块我们一直在讲JVM的"内部世界"——类加载、运行时数据区、垃圾回收、JIT编译。但从本篇开始,视角要切换到另一个维度:多线程下内存如何表现。一个线程对变量的写入,另一个线程什么时候能看见?看似简单的可见性问题,底层牵涉到CPU缓存、指令重排序、内存屏障等一整套机制。这套机制的抽象规范,就是Java Memory Model(JMM)。
JMM是理解volatile、synchronized、happens-before的根基。不弄懂JMM,并发编程就只能停留在"背规则"层面,遇到诡异bug便无从下手。本篇从JMM的定义出发,讲清主内存与工作内存的划分、8种原子操作、内存屏障分类,以及JMM与CPU多级缓存的映射关系。
什么是Java内存模型
Java Memory Model(JMM)是Java语言规范(JLS §17.4)定义的一套抽象模型,用于描述多线程环境下变量(共享内存)的访问规则。它规定了线程之间如何通过内存进行交互——一个线程对共享变量的写入,何时、以何种顺序对其他线程可见。
需要强调几个关键点:
- JMM是规范,不是实现。它定义了"应该怎样",HotSpot等JVM负责落地实现。不同JVM只要遵守JMM规范,并发语义就一致。
- JMM针对的是共享变量,即存储在堆中的实例字段、静态字段,以及数组元素。局部变量和方法参数是线程私有的,不存在内存可见性问题,JMM不约束它们。
- JMM的核心目标是正确性,在正确性前提下尽量给编译器和CPU留出优化空间。这是一个"安全与性能"的平衡。
JMM在JDK 5(JSR-133)经历了一次重要重构,重新定义了volatile和final的语义,奠定了现代Java并发的基础。JDK 8/11/17的JMM核心语义与JSR-133保持一致,后续主要是增强工具(如JDK 9引入的VarHandle提供更细粒度的内存访问模式)。
主内存与工作内存
JMM规定了内存的两层划分:主内存(Main Memory)与工作内存(Working Memory,又称本地内存/Local Memory)。
线程A 线程B 线程C ┌──────┐ ┌──────┐ ┌──────┐ │ 工作内存 │ │ 工作内存 │ │ 工作内存 │ │ (本地副本)│ │ (本地副本)│ │ (本地副本)│ └───┬──┘ └───┬──┘ └───┬──┘ │ │ │ └──────────┬───────┴──────────┬───────┘ │ │ ▼ ▼ ┌─────────────────────────────┐ │ 主内存 (Main Memory) │ │ 共享变量: 实例字段 / 静态字段 / 数组元素 │ └─────────────────────────────┘主内存
主内存是所有共享变量的"权威存储"。所有共享变量都存在于主内存中,它是线程间通信的"中转站"。可以把主内存粗略对应到物理机的RAM(但JMM是抽象模型,不严格等于硬件RAM)。
工作内存
工作内存是每个线程私有的内存区域,保存了该线程读写的共享变量的副本。线程对变量的所有操作(读、写)都在工作内存中进行,不能直接读写主内存中的变量。不同线程之间无法访问对方的工作内存,线程间变量值的传递必须通过主内存完成。
工作内存对应到物理层面,包括:
- CPU的寄存器
- L1/L2/L3缓存
- 编译器优化产生的"内存位置"(有时变量压根不进内存,只在寄存器里流转)
注意:JMM的工作内存不等于JVM运行时数据区中的"虚拟机栈"。虚拟机栈存的是局部变量和操作数栈,而工作内存存的是共享变量的副本,二者是不同维度的概念。一个共享变量在某线程工作内存里有副本,同时该线程的虚拟机栈里可能还有它的临时拷贝(操作数栈上的值)。
8种原子操作
JMM定义了8种操作来描述线程与主内存之间的交互。每个操作都是原子的——要么完整执行,要么不执行,不会被其他线程打断。
| 操作 | 作用对象 | 含义 |
|---|---|---|
| lock(锁定) | 主内存变量 | 把一个变量标识为一条线程独占状态 |
| unlock(解锁) | 主内存变量 | 把一个处于锁定状态的变量释放出来,释放后其他线程可锁定 |
| read(读取) | 主内存变量 | 把一个变量的值从主内存传输到线程的工作内存中,供后续load使用 |
| load(载入) | 工作内存变量 | 把read操作从主内存得到的值放入工作内存的变量副本中 |
| use(使用) | 工作内存变量 | 把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到需要使用变量的字节码指令时执行 |
| assign(赋值) | 工作内存变量 | 把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到给变量赋值的字节码指令时执行 |
| store(存储) | 工作内存变量 | 把工作内存中一个变量的值传送到主内存中,供后续write使用 |
| write(写入) | 主内存变量 | 把store操作从工作内存得到的值放入主内存的变量中 |
这8个操作的协作关系如下图:
主内存 (Main Memory) ┌───────────────────────────┐ │ 变量 x = ... │ │ ▲ │ │ │ write │ │ │ ▲ │ │ │ │ store (工作→主) │ │ │ │ │ │ │ │ read (主→工作) │ │ │ ▼ │ │ │load │ │ ▼ │ └───┼───────────────────────┘ │ ▼ ┌───────────────────────────┐ │ 工作内存 (Working Memory) │ │ 变量副本 x = ... │ │ ▲ │ │ │ assign (引擎→副本) │ │ │ use (副本→引擎) │ │ ▼ │ │ 执行引擎 (Execution Engine) │ └───────────────────────────┘操作的配对规则
JMM规定这8个操作必须满足以下规则:
- read与load配对:不允许read了却不load,也不允许load了却不read。即主内存读出的值必须载入工作内存。
- store与write配对:不允许store了却不write,也不允许write了却不store。即工作内存存出的值必须写入主内存。
- assign与store可间隔:assign之后不一定要立刻store,可以攒在工作内存里。
- lock与unlock成对:一个变量同一时刻只能被一条线程lock,lock几次就要unlock几次。lock会清空工作内存中该变量的副本,unlock前必须把变量同步回主内存。
一个赋值操作的完整流程
以x = 1这条赋值语句为例,假设变量x是共享变量,线程执行时大致经历:
- read:从主内存读取
x的当前值 - load:载入到工作内存的变量副本
- use:把副本值传给执行引擎(如果需要旧值的话)
- assign:执行引擎把新值
1赋给工作内存的变量副本 - store:把新值从工作内存传送到主内存
- write:把新值写入主内存的
x
注意这只是逻辑描述,实际HotSpot并不会真的分6步执行——JIT和CPU会做大量优化(如合并、重排序)。JMM的8种操作是规范层面的抽象,不是实现的指令序列。
内存屏障
8种原子操作描述了"做什么",但**内存屏障(Memory Barrier,又称内存栅栏)**描述了"如何保证可见性与有序性"。内存屏障是CPU和编译器层面的机制,用于禁止特定类型的指令重排序,并强制刷新缓存。
JMM涉及4种内存屏障:
| 屏障类型 | 指令含义 | 作用 |
|---|---|---|
| LoadLoad | Load1; LoadLoad; Load2 | 保证Load1先于Load2完成,禁止Load2及之后的读重排到Load1之前 |
| StoreStore | Store1; StoreStore; Store2 | 保证Store1先于Store2完成(且对其他处理器可见),禁止Store2重排到Store1之前 |
| LoadStore | Load1; LoadStore; Store2 | 保证Load1先于Store2完成,禁止Store2重排到Load1之前 |
| StoreLoad | Store1; StoreLoad; Load2 | 保证Store1对其他处理器可见后,才执行Load2。开销最大,因为要等Store完全刷出 |
屏障与8种操作的对应
JMM的8种原子操作并不直接对应到硬件屏障,但可以粗略映射:
- volatile写前插入StoreStore,写后插入StoreLoad——保证写前的普通写不重排到volatile写之后,且volatile写对其他线程立即可见
- volatile读后插入LoadLoad和LoadStore——保证volatile读之后的普通读/写不重排到volatile读之前
- synchronized的unlock隐含StoreLoad语义——释放锁前的写必须对后续获得锁的线程可见
- final字段写在构造函数返回前插入StoreStore——保证构造完成时final字段已初始化并可见
StoreLoad是全能屏障,同时具备LoadLoad、LoadStore、StoreStore的效果,因为它要求Store完全生效后才能Load。在x86上StoreLoad对应mfence或lock前缀指令,开销较大(数十到上百周期),所以JVM会尽量避免不必要的StoreLoad。
与物理内存的关系
JMM是抽象模型,但它的落地离不开硬件。理解JMM与物理内存的映射,能帮助你看清volatile、synchronized的底层成本。
CPU的多级缓存
现代CPU都有多级缓存,以Intel架构为例:
CPU核心0 CPU核心1 ┌──────────┐ ┌──────────┐ │ 寄存器 │ │ 寄存器 │ ← 最快,纳秒级 ├──────────┤ ├──────────┤ │ L1 Cache │ │ L1 Cache │ ← 私有,~1ns,32KB │ (L1d/L1i)│ │ (L1d/L1i)│ ├──────────┤ ├──────────┤ │ L2 Cache │ │ L2 Cache │ ← 私有,~4ns,256KB~1MB ├──────────┴────┴──────────┤ │ L3 Cache │ ← 共享,~12ns,几MB~几十MB ├───────────────────────────┤ │ 主内存 (DRAM) │ ← ~100ns,GB级 └───────────────────────────┘- L1/L2是每个核心私有的,这就是"工作内存"的物理基础之一——一个核心修改了L1的值,另一个核心看不到
- L3多核共享,但仍有缓存一致性延迟
- 寄存器是编译器优化的"重灾区",变量可能长期只在寄存器里流转,根本不写回缓存
缓存一致性与MESI
多核CPU通过缓存一致性协议(Cache Coherence Protocol)保证各核心看到一致的内存视图。最主流的是MESI协议,它给每个缓存行定义4种状态:
- M(Modified):该缓存行被修改,与主内存不一致,本核心独占
- E(Exclusive):该缓存行与主内存一致,本核心独占
- S(Shared):该缓存行与主内存一致,多个核心共享
- I(Invalid):该缓存行无效
当一个核心修改了处于S状态的缓存行,MESI会通过总线消息把其他核心的副本置为I(失效),强制它们下次读时从主内存或修改方核心重新加载。这正是JMM"工作内存失效"的硬件落地。
JMM与硬件的映射
| JMM抽象 | 硬件对应 |
|---|---|
| 主内存 | DRAM(物理内存) |
| 工作内存 | CPU寄存器 + L1/L2缓存 + 编译器优化位置 |
| read/load | 从DRAM或L3加载到L1/寄存器 |
| store/write | 从寄存器/L1刷到L3或DRAM |
| lock | 总线锁或缓存锁(MESI的M状态) |
| 内存屏障 | mfence/lfence/sfence/lock前缀指令 |
需要强调:JMM的抽象是为了跨平台一致性。不同CPU架构的内存模型强弱不同——x86是强有序模型(TSO,Total Store Order),很多重排序天然不会发生;而ARM/POWER是弱内存模型,需要插入更多屏障。JVM的职责是在不同硬件上"补齐"屏障,让Java程序看到一致的JMM语义。这就是为什么同样一段无volatile的并发代码,在x86上可能"碰巧正确",换到ARM上就出错——JMM保证了你在加上正确的同步原语后,两个平台都正确。
代码示例:观察工作内存延迟
下面用一个示例直观感受工作内存与主内存的延迟同步问题。
// 适用 JDK 11/17publicclassVisibilityDemo{// 注意:没加 volatile,下面程序可能永远不会终止privatestaticbooleanrunning=true;publicstaticvoidmain(String[]args)throwsInterruptedException{Threadworker=newThread(()->{longcount=0;// worker线程读自己的工作内存副本,主线程的修改对它可能不可见while(running){count++;}System.out.println("Worker stopped, count="+count);});worker.start();Thread.sleep(1000);running=false;// 主线程修改,但未必同步到worker的工作内存worker.join(2000);System.out.println("Main exit, worker alive="+worker.isAlive());}}运行这段代码,很多时候程序会正常在1秒后停止;但在某些JVM/CPU组合下(尤其-server模式、JIT优化后),worker线程会永远循环下去——因为JIT把running优化到了寄存器,worker根本不去主内存刷新。
把running改为volatile后,强制每次读都从主内存加载(严格说是通过屏障保证可见性),程序必然正常停止。这就是JMM在工作内存层面的直接体现,下一篇我们会深入volatile的语义。
实践要点
JMM是抽象模型,不要硬套到实现。8种操作是规范描述,HotSpot实际不会按6步执行赋值。理解抽象是为了读懂规则,不是为了逐条对应字节码。
工作内存的"副本"不一定是独立的内存拷贝。它可能是寄存器里的一份值,也可能是缓存行里的一份,甚至JIT优化后压根没有"副本"这个实体,只是"对某线程暂时不可见的中间状态"。把工作内存理解为"线程可见性边界"更准确。
跨平台一致性是JMM的核心价值。x86的强内存模型掩盖了很多并发bug,但代码部署到ARM(如Apple Silicon、AWS Graviton)就会暴露。永远用JMM规则(而非"x86上跑对了")作为正确性判据。
StoreLoad开销最大。volatile写、解锁操作会触发StoreLoad,这是volatile比普通变量慢一个数量级的主因。高频写场景慎用volatile。
缓存行是缓存一致性的最小单位。即使只有一个boolean变量,MESI也是按缓存行(通常64字节)失效的。多个变量挤在同一缓存行会导致"伪共享"(False Sharing),下一篇之后讲并发底层时会展开。
JMM不约束局部变量。方法内的局部变量、参数都是线程私有的,不存在可见性问题,也不受JMM约束。不要把虚拟机栈里的局部变量表和"工作内存"混为一谈——后者专指共享变量的线程私有视图。
小结
- JMM是Java语言规范定义的抽象模型,规定多线程下共享变量的访问规则,核心是可见性与有序性
- JMM把内存划分为主内存(共享变量权威存储)与工作内存(线程私有副本),线程只能操作工作内存,通信必须经主内存
- 8种原子操作(lock/unlock/read/load/use/assign/store/write)描述了线程与内存的交互,必须满足配对与同步规则
- 内存屏障(LoadLoad/StoreStore/LoadStore/StoreLoad)是保证有序性、可见性的底层机制,StoreLoad开销最大
- JMM与硬件的映射:工作内存≈寄存器+L1/L2缓存+编译器优化,主内存≈DRAM,lock≈MESI缓存锁或总线锁
下一篇我们将基于这套抽象,深入原子性、可见性、有序性三大特性,并剖析指令重排序与经典的i++和双重检查锁定问题。
