01 并发编程的硬件基础——CPU 缓存、MESI 与内存屏障

摘要:

很多 Java 工程师学了 synchronizedvolatilehappens-before,却始终觉得”知其然不知其所以然”——volatile 写为什么要插入 StoreLoad 屏障?synchronized 的解锁为什么能保证其他线程看到最新值?这些问题的答案不在 JVM 规范里,而在更底层的硬件架构里。本文从多核 CPU 的物理缓存层次出发,深入剖析缓存行(Cache Line)的结构、MESI 协议的四种状态转换、Store Buffer 与 Invalidate Queue 引入的”异步化”问题,以及内存屏障(Memory Barrier)如何在硬件层面重新建立秩序。理解这些硬件基础,是理解 JMM、volatilesynchronized 乃至整个 Java 并发体系的真正起点。


第 1 章 为什么硬件层面会有并发问题

1.1 从单核到多核:性能提升的代价

在单核处理器时代,并发编程几乎不存在”可见性”问题——所有线程共用同一个 CPU,共用同一套寄存器和缓存,写入的数据对所有后续读取立刻可见。那个时代的并发问题主要是”原子性”:操作系统可以在任意时刻打断线程切换到另一个,因此需要临界区保护。

多核处理器的普及彻底改变了这个图景。一台现代服务器通常有 2 个物理 CPU Socket,每个 Socket 有 16-64 个核心,每个核心有 2 个超线程。这些执行单元共享同一块内存(DRAM),但每个核心有自己私有的 L1/L2 缓存。

问题就在这里。内存的速度远远跟不上 CPU 的速度。一次 L1 缓存命中需要约 4 个 CPU 周期,L2 约 12 个,L3 约 40 个,而一次主内存访问需要约 200 个周期。如果 CPU 每次读写都直接操作内存,现代 CPU 将会有超过 95% 的时间在等待内存,性能优势荡然无存。

缓存(Cache)的引入是解决这个矛盾的必然选择——让 CPU 尽可能操作距离自己最近、速度最快的存储层次。但多个核心各自有私有缓存,就带来了一个新问题:同一块数据在多个核心的私有缓存中可能存在不一致的副本。这就是并发编程硬件层面的核心困境,也是一切上层并发机制的起点。

1.2 缓存层次结构

各层次的关键参数对比:

存储层次容量延迟私有/共享
L1 缓存32-64 KB4 周期每核私有
L2 缓存256 KB - 1 MB12 周期每核私有
L3 缓存8-64 MB40 周期所有核共享
主内存GB - TB~200 周期共享

缓存行(Cache Line) 是缓存管理的最小单位,通常为 64 字节。CPU 不以单个字节为粒度读写缓存,而是以缓存行为单位——读一个 int(4 字节),实际上会把这个 int 所在的整个 64 字节缓存行都加载进 L1。

这个设计基于空间局部性原理(Spatial Locality):程序访问了一个内存位置,它很可能很快也会访问附近的位置(如遍历数组)。以缓存行为粒度批量加载,可以减少后续访问的缓存未命中次数。


第 2 章 缓存一致性问题的根源

2.1 问题的具体形态

假设一个最简单的场景:

int x = 0;
// 线程 A(运行在 Core 0)
x = 1;
// 线程 B(运行在 Core 1)
int r = x;   // r 的值是 0 还是 1?

在物理层面:变量 x 初始值 0 被加载到 Core 0 和 Core 1 的 L1 缓存中;线程 A 在 Core 0 上执行 x = 1,Core 0 的 L1 缓存中 x 变为 1;线程 B 在 Core 1 上执行读取,Core 1 的 L1 缓存中仍然是旧值 0

如果没有任何协调机制,线程 B 读到的就是旧值 0——这就是可见性(Visibility) 问题。

2.2 为什么不直接写穿到内存

一个直觉性的解决方案是”写直达”(Write-Through):每次写缓存时同步写主内存。但这行不通:

  • 写直达会把 L1 缓存的速度优势完全抹杀。L1 缓存的优势是在 4 个周期内完成操作,如果每次写都要等 200 个周期写完主内存再继续,L1 缓存形同虚设。
  • 频繁写内存会造成内存总线拥塞。多核系统中所有核心共用一条内存总线,频繁写会使总线成为严重瓶颈。

因此,实际采用的是”写回”(Write-Back)策略:先写缓存,等缓存行被驱逐(Evict)时再写内存。这带来了极高的缓存命中性能,但需要额外的缓存一致性协议来保证多核之间的数据一致。


第 3 章 MESI 协议——缓存一致性的解决方案

3.1 四种状态的精确语义

MESI 协议是当前最广泛使用的缓存一致性协议(Intel x86、ARM 都在此基础上实现)。它用四个状态来描述一个缓存行的当前状态:

状态全称含义其他核是否有副本
MModified已被修改,与内存不一致无(本核独占)
EExclusive与内存一致,只有本核有副本
SShared与内存一致,多核都有副本
IInvalid缓存行已失效,不可使用不确定

M 状态:只有持有 M 状态的核心有最新值,如果其他核心需要读,必须从这个核心获取。

E 状态:如果本核想写,可以直接从 E 升到 M,无需通知其他核心(因为没有其他副本需要失效)。

S 状态:多个核心都持有副本,任何一个核心要写时,必须先让其他所有核心的副本失效,然后才能写。

I 状态:如果要访问这行数据,必须从其他核心的缓存或主内存重新加载。

3.2 状态转换:一次完整的读-写-读场景

通过具体场景理解状态转换。假设 Core 0 和 Core 1 都要操作变量 x,初始两个核心缓存中都没有 x(I 状态):

步骤 1:Core 0 读取 x:Core 0 发出 BusRd 请求,主内存响应,将 x=0 传给 Core 0。Core 0 状态变为 E(只有我有)。

步骤 2:Core 1 读取 x:Core 1 发出 BusRd 请求,Core 0 通过总线嗅探(Bus Snooping)监听到,将自己的状态降为 S。Core 1 获取数据,状态也为 S

步骤 3:Core 0 写入 x = 1:Core 0 发出 BusRdX(读取以获得独占权)。Core 1 监听到,将副本置为 I(失效)。Core 0 的状态升为 M,写入 x=1

步骤 4:Core 1 再次读取 x:Core 1 发现缓存行是 I 状态,缓存未命中。Core 0 监听到请求,将修改后的数据写回内存(或直接传给 Core 1),自己降为 S。Core 1 获取到最新数据 x=1,状态也为 S

这就是 MESI 的本质:通过总线嗅探和状态机,让每个缓存行的修改都能被其他核心感知,从而保证一致性。

3.3 伪共享问题

MESI 以缓存行(64 字节)为粒度管理一致性,这带来了一个工程陷阱——伪共享(False Sharing)

class Counter {
    volatile long count0 = 0;  // 线程 A 使用
    volatile long count1 = 0;  // 线程 B 使用
    // 两个 long 各 8 字节,合计 16 字节,很可能在同一个 64 字节缓存行中
}

count0count1 逻辑上互不关联,但由于它们在同一缓存行中:Core 0 更新 count0 → 这个缓存行变为 M,Core 1 的副本被 Invalidate → Core 1 下次更新 count1 时缓存未命中,必须重新加载 → 又触发 Core 0 的 Invalidate…如此往复,两个核心因共处同一缓存行频繁”踢皮球”,性能急剧下降。

解决方案:缓存行填充(Cache Line Padding)

class PaddedCounter {
    volatile long count0 = 0;
    long p1, p2, p3, p4, p5, p6, p7;  // 填充 56 字节,保证 count0 独占一个缓存行
 
    volatile long count1 = 0;
    long q1, q2, q3, q4, q5, q6, q7;
}

JDK 8 引入的 @sun.misc.Contended 注解会自动在被注解字段两侧插入填充字节。LongAdderCell 数组正是使用了这个注解来防止伪共享——这也是它比 AtomicLong 在高并发场景下性能更好的原因之一。

核心概念

伪共享是一个”幽灵问题”——代码逻辑上完全正确,但性能比预期低一个数量级。在多线程高频写的场景中(如计数器、统计器),一定要检查关键字段是否存在伪共享风险。排查工具:Linux perf、Intel VTune 的 Cache Miss 指标。


第 4 章 Store Buffer 与 Invalidate Queue——性能优化引入的新问题

4.1 MESI 的性能瓶颈

MESI 保证了缓存一致性,但其同步阻塞特性带来性能问题。以”Core 0 写入 x”为例:

  1. Core 0 发出 BusRdX 请求,要求所有其他核心将 x 对应缓存行置为 Invalid
  2. Core 0 必须等待所有其他核心回复”已 Invalidate”确认
  3. 等确认收齐后,Core 0 才能真正写入数据

在一个 64 核服务器上,步骤 2 可能需要等待 63 个核心的确认。为解决这个问题,硬件引入了两个缓冲区来”异步化”原本同步的操作:Store BufferInvalidate Queue

4.2 Store Buffer——写操作的异步化

Store Buffer 是每个核心私有的小缓冲区,位于 CPU 核心与 L1 缓存之间。

引入后,写操作变为:Core 0 要写 x=1,先把 (address_of_x, value=1) 写入 Store Buffer,然后立即继续执行后续指令;Store Buffer 在后台异步处理 Invalidate 确认;确认收到后,才将写操作提交到 L1 缓存。

对 Core 0 来说,写延迟从”等所有核心 Invalidate”变成了”写入 Store Buffer”(几个周期),性能大幅提升。

但 Store Buffer 引入了写-读可见性延迟。Core 0 写入 Store Buffer 中的值,对 Core 1 来说是不可见的——Core 1 只能从缓存读,看不到 Core 0 的 Store Buffer 内容。

说明

对本核自身,硬件通过 Store Forwarding(写前递转)解决:CPU 在读取时先检查 Store Buffer,如果有对相同地址的未提交写入,直接从 Store Buffer 取值。这保证了单线程的程序顺序语义。但 Store Forwarding 只对本核有效。

4.3 Invalidate Queue——失效操作的异步化

Invalidate Queue 在”失效”端做异步化。Core 1 收到 Invalidate 请求时,将请求放入 Invalidate Queue,然后立即回复确认,而不是真正去失效缓存行。之后在某个合适时机,才真正处理 Invalidate Queue 中的请求。

这让 Core 0 能快速收到确认,提升写操作吞吐量。但代价是:Core 1 的缓存中可能存在一个虽然”应该无效”但尚未被处理的缓存行

4.4 两个异步机制导致的可见性问题

经典场景(flag-data 模式):

初始状态:flag = 0, data = 0,两个变量都在 Core 0 和 Core 1 的 S 状态缓存中

Core 0:               Core 1:
data = 42;             while(flag == 0) {}  // 等待 flag
flag = 1;              print(data);          // 期望打印 42

实际可能发生的序列

  1. Core 0 执行 data = 42:写入 Store Buffer,发出 BusRdX(data),不等待。
  2. Core 0 执行 flag = 1:如果 flag 的 Invalidate 确认比 data 更快完成,flag=1 先提交到缓存,data=42 仍在 Store Buffer 中。
  3. Core 1 看到 flag = 1,退出循环。
  4. Core 1 读取 datadata 的 Invalidate 请求可能还在 Core 1 的 Invalidate Queue 中未处理,Core 1 缓存中 data 仍是旧值 0
  5. Core 1 打印出 0,而不是预期的 42

生产避坑

这个场景正是 flag 变量在 Java 中必须声明为 volatile 的根本原因。如果没有 volatile,JVM 不会插入必要的内存屏障,上述乱序会在生产环境中静默发生——表现为偶发的读到旧值,极难复现和排查。


第 5 章 内存屏障——重新建立秩序

5.1 内存屏障的本质

内存屏障(Memory Barrier) 是 CPU 提供的、用来”排空”异步缓冲区的指令,从而在特定点重新建立确定的操作顺序。内存屏障不是”锁”,它只是保证屏障前后的操作不会被重排序跨越屏障,以及强制刷新 Store Buffer 或处理 Invalidate Queue。

5.2 四种内存屏障的语义

屏障类型防止的重排序主要作用性能代价
LoadLoadLoad-Load处理 Invalidate Queue
StoreStoreStore-Store刷新 Store Buffer
LoadStoreLoad-Store两者都做
StoreLoadStore-Load刷新 Store Buffer + 处理 Invalidate Queue最高

StoreLoad 屏障是代价最高的屏障——它既要排空 Store Buffer(让写对所有核心可见),又要确保后续 Load 不读到缓存中的旧值(处理 Invalidate Queue)。在 x86 上通过 MFENCELOCK XCHG 实现。

5.3 x86 的内存模型:TSO

x86/x64 使用 TSO(Total Store Order) 模型——这是一种相对严格的内存模型,除了 Store-Load 重排序外,其他三种重排序在 x86 上都不会发生。

这意味着在 x86 上,很多在 ARM 上需要显式插入的内存屏障实际上是”空操作”。但 JVM 不能依赖特定平台,必须按照 JMM 的语义生成代码——在 ARM 等弱序架构上,所有必要的屏障都要实际插入。

设计哲学

这就是为什么 Java 在 x86 上运行时,volatile 的性能代价相对较小(只有 StoreLoad 屏障有实际成本),而在 ARM 上代价更大。这是 JVM 跨平台抽象的必要代价——写一份代码,在所有平台上都正确。

5.4 内存屏障在 Java 中的落地

Java 工程师不直接调用 CPU 内存屏障指令,而是通过 JMM 定义的语义间接使用。JVM 负责在不同 CPU 架构上插入正确的屏障指令:

  • volatile:写操作前插入 StoreStore,写操作后插入 StoreLoad
  • volatile:读操作后插入 LoadLoadLoadStore
  • synchronized 进入:相当于 LoadLoad + LoadStore
  • synchronized 退出:相当于 StoreStore + StoreLoad

这些对应关系将在 03 volatile 的实现原理——内存屏障与禁止重排序04 synchronized 的锁升级——偏向锁、轻量级锁与重量级锁 中详细展开。


第 6 章 总结

本文建立了一条从硬件物理现实到 Java 并发抽象的完整推导链:

层次问题解决方案
物理层多核私有缓存 → 数据不一致MESI 协议(总线嗅探 + 状态机)
性能优化层MESI 同步阻塞 → 性能差Store Buffer + Invalidate Queue(异步化)
可见性层异步化 → 写-读顺序被打破内存屏障(排空缓冲区)
Java 抽象层屏障指令依赖 CPU 架构JMM:volatilesynchronizedhappens-before

理解了这条链路,02 Java 内存模型(JMM)——happens-before 与可见性保证 就不再是一堆抽象规则的死记硬背——JMM 的每一条 happens-before 规则,背后都对应着具体的内存屏障插入策略,而每一种屏障的存在,都是为了解决 Store Buffer 和 Invalidate Queue 引入的特定可见性问题。


参考文献

  1. Patterson & Hennessy, “Computer Organization and Design: The Hardware/Software Interface”, 5th Edition
  2. Intel, “Intel 64 and IA-32 Architectures Software Developer’s Manual”, Volume 3A
  3. Herlihy & Shavit, “The Art of Multiprocessor Programming”
  4. Preshing, Paul, “Memory Barriers Are Like Source Control Operations”, preshing.com, 2012
  5. JSR-133 Java Memory Model and Thread Specification, 2004
  6. Doug Lea, “The JSR-133 Cookbook for Compiler Writers”, gee.cs.oswego.edu

思考题

  1. MESI 协议保证了多核 CPU 缓存的一致性,但它本身并不能保证程序的可见性——因为 Store Buffer 和 Invalidate Queue 的存在延迟了缓存行状态的传播。在 x86 架构上,Store Buffer 导致的唯一可观察的重排序是’Store-Load 重排序’。这意味着在 x86 上,除了 Store-Load 之外的其他内存序是天然保证的——那为什么 Java 仍然需要 volatile 关键字?
  2. CPU 的 false sharing(伪共享)发生在两个变量恰好落在同一个缓存行(通常 64 字节)中——一个核写变量 A 会导致另一个核缓存中变量 B 的缓存行失效。Java 8 引入了 @Contended 注解(如 LongAdder 中使用)来避免伪共享。@Contended 的实现原理是什么?在什么场景下伪共享会导致明显的性能下降?
  3. 内存屏障(Memory Barrier)分为 LoadLoad、StoreStore、LoadStore 和 StoreLoad 四种。StoreLoad 屏障的开销最大(通常需要刷新 Store Buffer)。在 Java 中,volatile 写操作后会插入 StoreLoad 屏障。如果一个热点循环中有 volatile 写操作,StoreLoad 屏障的开销是否会成为性能瓶颈?你如何量化这个开销?