前言
现代 CPU 已经非常快了,但打开处理器结构图,却会发现一个很奇怪的现象:
寄存器
↓
L1 Cache
↓
L2 Cache
↓
L3 / LLC
↓
内存
↓
SSD / 硬盘
为什么要搞这么多层?
如果 CPU 需要读取一个 4 字节的 int,为什么硬件通常不是只搬这 4 个字节,而是把它所在的一整条 Cache Line 一起搬进来?
更奇怪的是,多线程程序里可能出现这种情况:
thread1 -> 修改 x
thread2 -> 修改 y
x 和 y 明明是两个完全不同的变量,没有共享同一个字段,为什么两个线程仍然可能互相拖慢?
继续往下还会遇到更多问题:
- L1、L2、L3 到底解决了什么?
- 程序局部性为什么能让 Cache 有效?
- Cache Line 为什么常见是 64 字节?
- 多个 CPU Core 各自缓存同一个内存地址后,谁说了算?
- MESI 到底保证了什么?它是不是已经解决了 Java 多线程可见性?
- “缓存锁”和“总线锁”到底是什么关系?
- False Sharing 为什么叫“伪共享”?
volatile、@Contended和 Disruptor 又是怎么和 Cache Line 联系起来的?
这篇文章就沿着一条链路把这些问题串起来:
CPU 比内存快
↓
必须引入 Cache
↓
Cache 按块读取
↓
出现 Cache Line
↓
多核各自持有副本
↓
需要 Cache Coherence
↓
MESI
↓
多个独立变量可能落在同一行
↓
False Sharing
↓
Padding / @Contended
一、为什么需要多级缓存?
先假设世界上没有 Cache:
CPU → 内存 → CPU → 内存 → CPU → 内存
CPU 每做一点计算都必须等内存返回数据。
问题在于,处理器执行能力增长以后,CPU 内部运算和外部 DRAM 访问之间出现了巨大的延迟差距。
如果处理器大部分时间都在:
等数据
等数据
等数据
再强的 ALU 也没有意义。
1. CPU 与内存的速度差
于是出现了缓存。
CPU
↓
很小、很快的存储
↓
再大一点、再慢一点
↓
更大、更慢
↓
主内存
如果把这个思想不断重复,就形成了存储器层次结构。
1.1 为什么不能把所有存储都做得和寄存器一样快?
如果只考虑速度,最理想的机器当然是:
所有数据
↓
全部放在 CPU 内部超高速存储
但现实还要考虑:
- 芯片面积;
- 功耗;
- 容量;
- 制造成本;
- 布线和访问复杂度。
越靠近 CPU 执行核心、越追求极低延迟的存储,单位容量通常越昂贵。
所以计算机最终形成的不是“全都最快”,而是一套工程折中:
少量超快存储 + 更多较快存储 + 大量较慢存储。
2. 存储器层次结构
一个适合建立直觉的模型是:
越往上,通常:
容量更小
成本更高
延迟更低
越往下则反过来。
2.1 延迟数字为什么不能死记?
很多资料为了建立直觉,会给出类似:
Register < L1 < L2 < L3 < DRAM
再给每一级配一个固定纳秒数。
顺序本身有意义,但具体数字不要死记。
访问延迟会受到很多因素影响:
- CPU 微架构;
- 时钟频率;
- Cache 容量和组织方式;
- NUMA 拓扑;
- 内存频率和时序;
- 是否命中;
- 是否发生 TLB Miss;
- 是否需要跨核心 / 跨 Socket 获取数据。
真正应该记住的是数量级和相对关系:
| 层级 | 典型位置 | 核心特点 |
|---|---|---|
| Register | CPU Core 内部 | 容量极小,直接服务指令执行 |
| L1 | Core 内部 | 最靠近执行单元的 Cache |
| L2 | Core 或 Core Cluster 附近 | 更大,通常比 L1 慢 |
| LLC / L3 | 多核共享或分片共享 | 容量更大,拓扑依 CPU 而异 |
| DRAM | CPU 外部内存系统 | 容量大,但延迟明显更高 |
| SSD / HDD | 持久化存储 | 容量更大,延迟又高几个数量级 |
不同 CPU 的实际 Cache 拓扑并不完全一样,因此“每个核一定独享 L1/L2、所有核一定共享一个完整 L3”也只能当常见模型,不能当所有架构的硬规则。
3. 局部性与 Cache Line
假设程序执行:
int x = array[100];
直觉上只需要 4 字节。
但 CPU Cache 通常不会围绕 Java 变量的大小来搬数据,而是以固定粒度进行数据填充和一致性管理。
这就是 Cache Line。
3.1 为什么按块读取更快?
关键是程序局部性。
局部性通常分成两个方向。
时间局部性
刚刚访问过的数据,很可能很快再次访问。
例如循环里的计数器:
for (int i = 0; i < 1_000_000; i++) {
sum += i;
}
sum、i 会被反复使用。
空间局部性
访问某个地址后,很可能继续访问附近地址。
例如:
for (int i = 0; i < array.length; i++) {
sum += array[i];
}
读取:
array[0]
之后,大概率就是:
array[1]
array[2]
array[3]
...
既然如此,与其每次只从更慢的一层拿几个字节,不如一次把附近数据一起搬进来。
3.2 Cache Line:缓存的基本管理粒度
在很多现代 x86 处理器中,Cache Line 常见大小是:
64 Bytes
假设有一个 64 字节对齐的区间:
0x1000 ~ 0x103F
程序只读:
0x1008
CPU 也可能把整个:
0x1000 ~ 0x103F
对应的 Cache Line 填充进 Cache。
于是下一次读取附近地址时,很可能直接命中,不必重新访问更慢的层级。
3.3 为什么常见是 64 字节?
不能把:
Cache Line 永远等于 64B
当成通用真理。
64B 在现代 x86 等很多平台上非常常见,但不同体系结构和具体处理器可以使用不同粒度。
写业务程序时,通常不应该依赖硬编码的 Cache Line 大小。
4. Cache Line、Page 与 Disk Block
它们有一个共同思想:
都在某个层面使用“块”而不是单个 bit / byte 作为管理或传输粒度。
但不能因此说:
Cache Line = Page = Disk Block
它们属于不同层级。
| 概念 | 管理层级 | 常见作用 |
|---|---|---|
| Cache Line | CPU Cache | Cache 填充、一致性等 |
| Memory Page | MMU / OS 虚拟内存 | 地址映射、权限、换页 |
| Filesystem / Disk Block | 文件系统 / 存储设备 | 持久化数据组织和 I/O |
例如:
Cache Line 常见几十字节级
Memory Page 常见 4KiB,也可能使用 Huge Page
文件系统块大小则取决于文件系统和配置
所以它们只是共享“分块管理”的思想,语义和大小完全不同。
5. DMA:让设备直接与内存传输
当网卡、SSD、GPU 等设备要和内存交换大量数据时,如果每个字节都必须:
设备
↓
CPU 寄存器
↓
内存
CPU 会变成昂贵的数据搬运工。
DMA,也就是 Direct Memory Access,要解决的就是这个问题。
一个简化流程可以表示为:
关键点不是“CPU 完全不知道这次传输”,而是:
CPU 负责配置和协调,但大批量数据不必一个字节一个字节经过 CPU 通用寄存器搬运。
现代系统中还可能涉及 IOMMU、设备队列、PCIe DMA 等更多细节,这里先不继续展开。
二、多核缓存一致性
1. 多核为什么会出现一致性问题?
假设内存里:
x = 0
Core 0 读取了 x:
Core 0 L1: x = 0
Core 1 也读取:
Core 1 L1: x = 0
现在同一个内存位置出现了多个缓存副本。
如果 Core 0 修改:
x = 1
Core 1 手里的:
x = 0
就不能继续被当成最新值。
于是多核 CPU 必须解决:
多个 Cache 中同一个内存位置的副本,怎样保持一致?
这就是 Cache Coherence 要处理的问题。
2. MESI 协议
MESI 是一种经典的 Cache Coherence 协议模型。
名字来自四个状态:
M = Modified
E = Exclusive
S = Shared
I = Invalid
可以先用非常直观的方式理解。
2.1 Exclusive
某个 Core 拿到一条 Cache Line,并且目前没有其他 Core 持有这条 Line 的有效副本。
它可以处于:
E
2.2 Shared
另一个 Core 也读取了同一条 Line。
于是两边都可以拥有只读有效副本:
Core 0: S
Core 1: S
2.3 Modified
如果 Core 0 想写这条 Line,它必须获得写权限 / 所有权。
其他 Core 对应副本会被失效。
最终可以形成类似:
Core 0: M
Core 1: I
2.4 Invalid
Core 1 的副本已经不能再使用:
I
如果 Core 1 下次还要读,就需要重新获得最新的 Cache Line。
一个简化过程:
2.5 Invalid 后的数据从哪里来?
不一定。
这也是很多入门图里容易简化过头的地方。
最新数据可能来自:
- 其他 Core 的 Cache;
- Last Level Cache;
- 一致性互连中的其他层级;
- 最终才可能访问 DRAM。
现代 CPU 的 Cache Coherence 是一个完整的硬件一致性系统,不是简单的:
失效
↓
永远去主内存重新读
3. MESI 与 Java 内存语义
这是理解并发时最关键的分界线之一。
MESI 解决的是:
硬件 Cache 副本的一致性。
Java Memory Model 解决的是:
Java 线程之间什么写入必须对什么读取可见,以及操作之间允许怎样重排序。
两者不是一个层级。
现代程序执行链里还存在:
Java 编译器
↓
JIT
↓
CPU 指令
↓
Store Buffer / Load Queue
↓
Cache Coherence
即使硬件能够保证某个内存位置最终具有一致值,也不代表:
线程 A 的任意写
一定立刻按照你写代码的顺序,被:
线程 B
观察到。
编译器和 CPU 都可能做合法的重排序。
因此 Java 还需要:
volatile;synchronized;- 锁;
- 原子类;
- happens-before 规则;
来建立语言层面的可见性和有序性语义。
可以把两者关系理解成:
所以:
有 MESI,不等于 Java 多线程自动线程安全。
这也是为什么 Cache Coherence 和 Java Memory Model 必须分开理解。
4. Cache Lock 与 Bus Lock
很多资料会给出这样一个模型:
能用 MESI → 缓存锁
不能用 MESI → 总线锁
它能让人知道“锁整个总线很贵”,但现代 x86 的实际机制不能简单概括成:
数据超过一个 Cache Line,就一定锁总线。
4.1 现代 x86 的 LOCK 如何工作?
从 P6 以后的 Intel 处理器开始,对于内部可缓存的内存区域,带 LOCK 的原子操作一般不会直接拉起传统意义上的 LOCK# 总线信号。
更常见的情况是:
锁定 / 独占相关 Cache Line
+
Cache Coherence 协议
↓
保证该原子操作
这比阻止所有处理器访问内存总线高效得多。
4.2 哪些场景仍可能出现 Bus Lock?
在某些特殊条件下,例如某些跨 Cache Line 的 Split Lock 或访问不可缓存内存区域,处理器仍可能需要更重的总线级机制。
所以应该建立的正确认知是:
现代处理器优先利用 Cache 与 Coherence 完成原子性;Bus Lock 是更特殊、更昂贵的路径,而不是所有“跨一行数据”的统一答案。
三、False Sharing 与数据布局
1. False Sharing 是怎么产生的?
现在来看这篇文章最有意思的部分。
假设同一条 64B Cache Line 里刚好有:
┌────────────────────────────────────────────────────┐
│ x │ y │ 其他数据 ... │
└────────────────────────────────────────────────────┘
两个线程分别运行在两个 Core:
Core 0 → 不停写 x
Core 1 → 不停写 y
从 Java 变量角度:
x != y
它们完全是两个独立字段。
但从 Cache Coherence 角度,同步粒度是:
Cache Line
而不是 Java 字段。
于是可能发生:
Core 0 获得这条 Line 的写权限
↓
Core 1 对应 Line 失效
↓
Core 1 又要写 y
↓
Core 1 获得这条 Line 的写权限
↓
Core 0 对应 Line 失效
↓
反复 Ping-Pong
这就是 False Sharing。
之所以叫“伪共享”,是因为:
逻辑上两个线程没有共享同一个变量,但物理上它们共享了同一条 Cache Line。
1.1 False Sharing 的触发条件
至少要出现类似情况:
- 两个不同 Core 频繁访问同一条 Cache Line;
- 至少一方频繁写入;
- 两个位置本来可以彼此独立,却因为 Cache Line 粒度产生额外一致性流量。
如果大家只是读:
Shared
通常不会出现这种反复争抢写权限的问题。
2. 实验:观察 @Contended 的影响
下面使用 OpenJDK 21 做一个简单实验。
这不是严谨的微基准,只是为了观察数据布局改变后可能出现的差异。
2.1 示例代码
import jdk.internal.vm.annotation.Contended;
public class FalseSharing {
private static final long ITERATIONS = 50_000_000L;
static class Pair {
volatile long x;
volatile long y;
}
static class ContendedPair {
@Contended("x")
volatile long x;
@Contended("y")
volatile long y;
}
private static long runPair() throws InterruptedException {
Pair pair = new Pair();
Thread t1 = new Thread(() -> {
for (long i = 0; i < ITERATIONS; i++) {
pair.x = i;
}
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < ITERATIONS; i++) {
pair.y = i;
}
});
long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
return System.nanoTime() - start;
}
private static long runContended() throws InterruptedException {
ContendedPair pair = new ContendedPair();
Thread t1 = new Thread(() -> {
for (long i = 0; i < ITERATIONS; i++) {
pair.x = i;
}
});
Thread t2 = new Thread(() -> {
for (long i = 0; i < ITERATIONS; i++) {
pair.y = i;
}
});
long start = System.nanoTime();
t1.start();
t2.start();
t1.join();
t2.join();
return System.nanoTime() - start;
}
public static void main(String[] args) throws Exception {
for (int i = 0; i < 3; i++) {
double normal = runPair() / 1_000_000.0;
double contended = runContended() / 1_000_000.0;
System.out.printf(
"pair=%.1f ms, contended=%.1f ms%n",
normal,
contended
);
}
}
}
2.2 编译参数
在现代 JDK 中:
jdk.internal.vm.annotation.Contended
属于 JDK 内部 API,不是标准 Java SE 公共 API。
因此编译时需要显式导出:
javac \
--add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED \
FalseSharing.java
这条命令验证的是:
允许当前未命名模块访问
java.base中的内部jdk.internal.vm.annotation包。
2.3 运行参数
执行:
java \
--add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED \
-XX:-RestrictContended \
FalseSharing
-XX:-RestrictContended 的作用,是允许 HotSpot 对普通用户类中的 @Contended 生效。
否则为了防止应用随意浪费大量内存,VM 默认会限制这个注解的使用范围。
2.4 实验结果与分析
在 OpenJDK 21.0.11 的一次测试环境中,得到过:
pair=484.9 ms, contended=101.0 ms
pair=67.9 ms, contended=13.1 ms
pair=73.2 ms, contended=12.2 ms
第一轮明显更慢,已经在提醒我们:
这不是可以拿来做性能结论的严谨 Benchmark。
JIT 预热、线程调度、CPU 频率、核心绑定、系统负载都会改变结果。
这个实验真正要观察的是:
普通相邻字段
↓
可能位于同一 Cache Line
↓
两个 Core 高频写
↓
Coherence Ping-Pong
vs
@Contended
↓
VM 插入隔离 / Padding
↓
降低独立热点字段共享同一 Cache Line 的概率
如果要做正式性能测试,应该使用 JMH,而不是直接拿一次 System.nanoTime() 的结果当结论。
3. volatile 与 False Sharing
不是。
这是一个非常容易被实验代码误导的地方。
False Sharing 的根本原因是:
多个热点内存位置落在同一 Cache Line,并被不同 Core 高频访问,其中存在写操作。
即使没有 volatile,硬件层面仍然存在 Cache Line 和 Coherence。
实验中使用 volatile,更多是为了让这些写操作具有明确的 Java 可见性语义,同时降低 JIT 把循环写入合并、消除等优化对演示造成的干扰。
因此:
volatile ≠ False Sharing
更不能理解成:
加了 volatile 才会触发 MESI。
Cache Coherence 是硬件机制,不是 Java 关键字打开的功能。
4. @Contended 的实现思路
JEP 142 在 JDK 8 引入了 @Contended 这套机制,目标就是:
让 VM 知道某些字段可能会在不同处理器核心上发生高竞争,从而安排对象布局,尽量避免这些字段和其他独立热点字段共享 Cache Line。
可以把普通布局想象成:
| header | x | y |
使用隔离后,逻辑上更接近:
| header | padding ... | x | padding ... | y | padding ... |
具体对象布局和 Padding 策略属于 VM 实现细节。
4.1 为什么手动 Padding 不够可靠?
以前常见手工 Padding:
long p1, p2, p3, p4, p5, p6, p7;
volatile long value;
long p9, p10, p11, p12, p13, p14, p15;
想法是:
7 × 8B = 56B
再加上目标 long 的 8B,凑到大约 64B。
这个思路能帮助理解,但它不是一个可靠的 Java 语言保证。
原因包括:
- 对象头会占空间;
- 字段布局由 JVM 决定;
- 对齐规则会变化;
- Cache Line 大小并非所有平台都相同;
- VM 可能重排字段布局。
所以手写 Padding 更像针对已知运行时和硬件做的底层优化,而不是普通业务代码应该依赖的通用技巧。
4.2 @Contended 的适用边界
它依然是 HotSpot / JDK 实现层面的能力。
因此更合理的态度是:
理解它解决什么问题,比在业务对象上到处加注解更重要。
它会用更多内存换取更低的 Cache Coherence 竞争,所以只适合真正的高竞争热点数据结构。
5. Disruptor 中的实际应用
LMAX Disruptor 是理解这个问题非常好的真实案例。
Disruptor 中的 Sequence 是一个会被高频读写的核心序列值。
它的实现专门考虑了 Padding,以降低这个热点值和其他独立数据发生 False Sharing 的概率。
为什么框架愿意这么做?
因为这类底层并发组件追求的是:
极高频率
极低延迟
极少竞争
多浪费几十或几百字节内存,可能换来更稳定的多核扩展能力。
但普通业务对象完全不是这个量级。
所以不要因为 Disruptor 做了 Padding,就把所有 Java Bean 都 Padding 一遍。
优化应该建立在真实 Profiling 和 Benchmark 上,而不是根据一个“底层技巧”提前猜测瓶颈。
四、常见误区
1. MESI 与一致性相关误区
误区 1:有 MESI,多线程就安全了
错误。
MESI 解决硬件 Cache Coherence;Java 线程安全还涉及 JMM、原子性、可见性、有序性以及同步语义。
误区 2:一个变量失效后一定回 DRAM 读取
错误。
最新 Cache Line 可能从其他 Core、共享 LLC 或其他一致性路径获得。
2. Cache Line 与数据布局相关误区
误区 3:Cache Line 永远是 64 字节
错误。
64B 很常见,尤其在现代 x86 平台,但不是跨所有 CPU 的规范常量。
误区 4:两个线程修改两个不同变量,就不存在竞争
错误。
只要两个变量落入同一个 Cache Line,并且不同 Core 高频写,就可能出现 False Sharing。
误区 5:volatile 会制造 False Sharing
错误。
volatile 是 Java 内存语义;False Sharing 的根因是数据布局和 Cache Coherence 粒度。
误区 6:7 个 long Padding 一定能让变量独占一行
错误。
对象头、字段布局、对齐和硬件 Cache Line 大小都可能让这个假设失效。
3. 原子操作与存储层级相关误区
误区 7:原子指令加 LOCK 就等于锁内存总线
不准确。
现代 x86 对可缓存内存通常利用 Cache Lock / Coherence 完成原子性,真正的 Bus Lock 属于更特殊而昂贵的场景。
误区 8:Cache Line、Memory Page、Disk Block 只是同一个东西换了名字
错误。
它们虽然都有分块思想,但属于 CPU、虚拟内存和存储系统三个不同层级。
五、把整条缓存链路串起来
1. 从上一篇的 CPU 执行继续往下走
上一篇《从硅到 CPU:Java 程序执行完整链路》解决的是一个更基础的问题:
计算机到底是怎么完成一次计算的?
它建立的主线是:
硅
↓
晶体管
↓
逻辑电路
↓
CPU
↓
机器指令
↓
JVM
↓
Java 程序执行
但当 CPU 真正开始执行程序后,会立刻遇到另一个现实问题:
CPU 很快,内存却跟不上。怎么办?
于是这一篇继续从 CPU 往存储体系深入:
CPU 执行程序
↓
CPU 与内存存在速度差
↓
引入多级 Cache
↓
Cache 按 Cache Line 管理数据
↓
多核产生多个缓存副本
↓
需要 Cache Coherence
↓
MESI 等一致性协议
↓
以 Cache Line 为粒度又可能产生 False Sharing
所以这两篇文章不是两个孤立主题,而是一条连续的硬件执行链:
第一篇回答“CPU 为什么能够计算”,这一篇继续回答“CPU 怎样尽量减少等待内存,以及多核缓存因此产生了什么新问题”。
到这里,整个认知链已经从最底层的晶体管,一直走到了 Java 程序在多核处理器上的缓存行为。
2. 一次 Cache 访问的完整流程
假设 Core 0 第一次读取变量 x。
把这张图理解了,Cache Line、MESI 和 False Sharing 就不再是三个孤立的面试名词。
它们其实是同一个因果链上的三个阶段:
为了快 → 引入 Cache
为了提高命中 → 按 Line 搬
为了多核正确 → 做 Coherence
因为 Coherence 以 Line 为粒度 → 产生 False Sharing
为了减少 False Sharing → 改变数据布局
六、总结
CPU Cache 的本质,是用空间和复杂度换时间。
多级 Cache 让 CPU 不必每次都等待 DRAM;程序局部性让“多搬一点附近数据”通常是值得的;Cache Line 又成为 Cache 填充和一致性管理的重要粒度。
但多核之后,同一个内存位置可以同时存在多个缓存副本,于是硬件必须引入 Cache Coherence。
MESI 正是在这一层工作。
而一旦 Coherence 的粒度是整条 Cache Line,就会出现一个有趣的副作用:
两个完全不同的变量
↓
恰好处于同一 Cache Line
↓
不同 Core 高频写
↓
整条 Line 反复转移
↓
False Sharing
这也是 Padding、@Contended 和 Disruptor 数据布局优化背后的真正原因。
Cache 解决的是 CPU 与内存的速度差,MESI 解决的是多核 Cache 副本的一致性,而 False Sharing 则是“以 Cache Line 为一致性粒度”带来的性能副作用;理解这三层关系之后,Java 中很多看似神秘的底层并发优化就有了清晰的硬件起点。
评论区