缓存写入策略与 MESI 一致性协议
写缓存,比读缓存更复杂
在《CPU cache 原理》里讲的是"读缓存":CPU 如何靠局部性原理、地址拆分、组相联映射来加速内存读取。这一篇讲"写缓存"和它带来的连锁问题——多核一致性。
读缓存是单向的:缓存 miss 就去内存搬数据,不会破坏别处的数据。写缓存则不同,一旦写入,缓存和内存就出现了两份可能不一致的数据;到了多核,各核心之间还会互相不一致。所以写缓存需要两套策略分别回答两个问题:
- 缓存和内存怎么保持一致——什么时候把写回内存(写直达 vs 写回)。
- 核心和核心怎么保持一致——多个核心缓存了同一块内存怎么办(MESI 一致性协议)。
下面先讲第一套策略,再讲第二套协议。
缓存写入策略
写数据时,缓存和内存两份数据可能不一致,需要策略约定。核心问题有两个:什么时候写内存、写未命中时要不要先加载。
写直达 vs 写回
- 写直达(Write-Through):每次写缓存的同时,同步写入内存。实现简单、一致性好,但每次写都要等内存,性能差。
- 写回(Write-Back):只写缓存,把这一行标记为 dirty(脏),等到这行被替换时再写回内存。性能好,但实现复杂,还要维护 dirty 位。
现代 CPU 普遍用写回,因为内存写太慢,频繁写直达会拖垮性能。而写回带来的"脏数据"正是后面多核一致性要处理的核心对象。
写分配 vs 写不分配
针对"写未命中"(要写的数据不在缓存里):
- 写分配(Write-Allocate):先把内存块加载进缓存,再写。利用空间局部性,后续读写更快。
- 写不分配(No-Write-Allocate):直接写内存,不加载进缓存。
常见组合是 写回 + 写分配:写 miss 时加载进缓存再写,标记 dirty,替换时写回。这是大多数 CPU 的默认行为。
多核时代,缓存为什么需要"一致"
写入策略解决了"缓存和内存"之间的一致性。但现代 CPU 都是多核的,每个核心有自己独立的 L1/L2 缓存,同一块内存可能同时被多个核心缓存。这时候"缓存和内存一致"还不够,还得保证"各核心之间的缓存也一致"。
问题场景很直接:核心 A 和核心 B 都缓存了变量 x = 0。A 把 x 改成 1(写回策略下,A 的 L1 里是 1,内存里可能还是 0)。B 再去读 x,如果读自己的缓存会读到 0,就拿到了一份过期数据。
要解决这个问题,光靠"写回内存"不够,因为 B 的缓存里还躺着旧值,它根本不会去内存读。必须在多个核心的缓存之间建立一套协议,让它们知道"某个缓存行已经失效了"。
MESI 协议
MESI 是经典的缓存一致性协议(Cache Coherence Protocol),用四个状态标记每个缓存行,四个字母就是状态的缩写:
| 状态 | 全称 | 含义 |
|---|---|---|
| M | Modified(已修改) | 本缓存行的数据被改过,与内存不一致,且只有本核心有最新副本 |
| E | Exclusive(独占) | 数据与内存一致,且只有本核心持有这份副本 |
| S | Shared(共享) | 数据与内存一致,多个核心都持有这份副本 |
| I | Invalid(无效) | 该缓存行失效,不能使用 |
读、写、被读时的状态变化
围绕这四个状态,MESI 规定了三种核心场景下的行为:
- 读:本核心缓存行是 I(无效)时,需要从内存或其他核心加载。如果其他核心有 M 状态的最新副本,需要先拿到那份数据。
- 写:本核心要写一个 S(共享)状态的缓存行时,必须先让其他核心的副本失效(发 Invalidate 消息,把它们置为 I),再升级为自己的 M 状态。
- M 状态被读:其他核心要读 M 状态的数据时,持有 M 的核心要把数据写回内存(或直接转发给请求方),然后两者都变为 S。
这套协议保证了一个核心不变量:任意时刻,最多只有一个核心持有某个缓存行的 M 状态。这是多核程序正确性的基石。
用变量 x(初始内存值为 0)串一遍状态流转,建立直觉:
- A 读
x:缓存行加载进 A,此时只有 A 有副本,A 持有 E。 - B 读
x:B 也加载,A、B 都变成 S。 - A 写
x = 1:A 先发 Invalidate 让 B 的副本失效(B 变 I),A 自己变 M。 - B 再读
x:B 是 I,需要拿最新值;A 把数据写回内存(或直接转发给 B),A、B 都变 S,B 读到 1。
整个过程保证了 B 在任何时候都不会读到过期数据。
伪共享:缓存行粒度的副作用
MESI 的一致性粒度是缓存行(64 字节),不是单个变量。这带来一个隐蔽的性能问题:伪共享(False Sharing)。
看一个多线程计数器的例子:
// Java 示例
class Counter {
volatile long a = 0; // 线程 1 反复 a++
volatile long b = 0; // 线程 2 反复 b++
}
a 和 b 各占 8 字节,但它们在内存里紧挨着,落在同一个 64 字节缓存行里。线程 1 写 a 时,会把整行置为 M 并让线程 2 的缓存行失效;线程 2 写 b 时同样让线程 1 失效。两个线程逻辑上互不相关,却因为共享同一缓存行而反复互相失效,导致性能严重下降——这就是"伪"共享:没有真正的数据共享,却有共享的代价。
排查思路:多线程程序里,两个"看起来应该并行无冲突"的变量,如果频繁被不同线程写且性能异常,先怀疑是否落在同一缓存行。
解决办法是缓存行填充(Cache Line Padding):在变量之间填充无用字节,让它们各自独占一个缓存行:
class Counter {
volatile long a = 0;
long p1, p2, p3, p4, p5, p6, p7; // 填充 56 字节,把 b 推到下一个缓存行
volatile long b = 0;
}
Java 8 还提供了 @Contended 注解(需加 JVM 参数 -XX:-RestrictContended),由 JVM 自动完成填充;C/C++ 里用 alignas(64) 或手写填充结构体。
缓存一致性与 Java 内存模型
缓存一致性是硬件层的机制,Java 的 volatile 等语言层保证,底层就是靠它落地。
volatile 保证两点:可见性(一个线程的写对另一个线程可见)和禁止重排。写 volatile 变量后,会插入内存屏障,把对应的缓存行刷到内存并失效其他核心的副本;读 volatile 变量时,保证读到最新值。这正是 MESI 协议在语言层的体现。
二者是"语言层保证"和"硬件层机制"的配合关系:JMM(Java 内存模型)定义了 volatile 的语义,硬件靠 MESI 把这份语义变成真实的缓存行为。理解这层关系,能解释很多并发编程里"为什么加了这个关键字就对了"的现象。
面试高频追问
Q1:为什么写回策略比写直达更常用? 内存写延迟高(200+ 周期),写直达每次写都要等内存,性能差。写回把写操作"延迟"到替换时批量处理,配合 dirty 位,是性能优先的选择。代价是实现复杂,且断电可能丢数据,所以需要缓存的刷新机制。
Q2:MESI 里 E(独占)和 S(共享)状态有什么区别? 两者数据都与内存一致,区别在于副本数量:E 表示只有本核心持有这份副本,S 表示多个核心都持有。E 状态的好处是,本核心从 E 写数据时,因为只有自己有副本,无需通知其他核心失效,可以直接升级为 M,省一次 Invalidate。
Q3:为什么有了 MESI,Java 还需要 volatile / 锁?
MESI 解决的是"缓存数据一致",但不能解决"代码执行顺序"和"复合操作的原子性"。比如 i++ 是读-改-写三步,MESI 保证每个核心看到的数据一致,但保证不了这三步不被其他核心交叉。所以语言层还需要 volatile(禁止重排)和锁(保证原子性)来补上更高的抽象。
Q4:伪共享为什么这么隐蔽,怎么定位?
隐蔽在它"看起来正确"——逻辑上没有共享任何数据,程序结果也正确,只是慢。定位靠 perf 等工具观察 cache miss 和缓存行争抢,或直接审视代码里"不同线程高频写相邻变量"。解决靠缓存行填充或 @Contended。
总结
- 写缓存比读缓存复杂,需要两套策略分别解决缓存与内存、核心与核心的一致性问题。
- 写入策略:写直达 vs 写回、写分配 vs 写不分配,现代 CPU 默认写回 + 写分配,写回产生的脏数据是多核一致性的核心对象。
- MESI 用 M/E/S/I 四个状态标记缓存行,核心不变量是"任意时刻最多一个核心持有 M 状态"。
- 伪共享是缓存行粒度(64B)带来的副作用:不同变量落在同一行会互相失效,拖慢性能。
volatile的可见性底层靠 MESI 落地,但 MESI 不解决原子性和指令重排,所以还需要锁和内存屏障。- 解决伪共享的核心手段是缓存行填充(手写填充、
@Contended、alignas)。