每个线程只管自己的变量,性能却不如单线程?问题出在缓存行 _
伪共享(False Sharing)是多线程编程中一个很容易被忽略,但在高并发场景下又可能非常致命的性能问题。
它最迷惑人的地方在于:从业务代码上看,多个线程并没有修改同一个变量,甚至每个线程都只操作属于自己的那份数据,理论上不应该发生竞争;但从 CPU 的视角看,这些变量可能刚好落在同一个缓存行里,于是一个线程修改自己的变量时,会导致其他 CPU 核心上的缓存行失效,最终引发大量无意义的缓存同步。
所以,伪共享不是“逻辑共享”导致的问题,而是“物理存储位置太近”导致的问题。
本文内容:
- 从现代处理器的缓存结构说起
- 缓存行为什么是 CPU 缓存的基本单位
- 什么是 CPU 缓存一致性
- 为什么缓存一致性会引出伪共享问题
- 用 Java 代码演示伪共享和缓存行填充
- 伪共享的常见解决方案
- 实际项目中该如何判断和取舍
CPU为什么需要缓存
在理解伪共享之前,我们要先理解一个基础问题:CPU 为什么需要缓存?
现代 CPU 的执行速度非常快,而内存相对 CPU 来说要慢很多。如果每一次读取变量、写入变量都直接访问主内存,那么 CPU 大部分时间都会浪费在等待内存数据返回上。为了缓解这个问题,CPU 和主内存之间会加入多级缓存,也就是我们常说的 L1、L2、L3 Cache。
一般来说,缓存层级可以简单理解为:
- L1 Cache:离 CPU 核心最近,速度最快,容量最小,通常每个核心独享
- L2 Cache:速度比 L1 慢一些,容量比 L1 大一些,很多处理器中也是每个核心独享
- L3 Cache:速度再慢一些,但容量更大,通常多个核心共享
- 主内存:容量最大,但访问延迟远高于 CPU Cache
也就是说,一个变量并不是每次都从内存中直接读取。CPU 会尽量把最近访问过的数据放到缓存里,下次再访问相同数据或相邻数据时,就可以直接从缓存中拿到,速度会快很多。
这背后依赖两个很重要的局部性原理:
- 时间局部性:一个数据刚被访问过,后续很可能还会再次被访问
- 空间局部性:一个数据被访问时,它附近的数据也很可能会被访问
比如我们遍历一个数组:
java
for (int i = 0; i < arr.length; i++) { sum += arr[i]; }CPU 读取arr[0]时,并不会只把arr[0]这几个字节加载到缓存里,而是会把它附近的一整块连续内存都加载进来。这样后续访问arr[1]、arr[2]时,大概率已经命中缓存,不需要再去主内存读取。
这个“一整块连续内存”,就是接下来要讲的缓存行。
缓存行
在现代处理器中,缓存行(Cache Line)是 CPU Cache 和主内存之间进行数据交换的最小单位。主流 CPU 的缓存行大小通常是 64 字节。
注意这里的重点是“最小单位”。
假设有一个long类型变量,占 8 字节。当 CPU 需要读取这个long变量时,并不是只从主内存加载 8 字节,而是会把包含这个变量的一整个缓存行加载到 CPU Cache 中。如果缓存行大小是 64 字节,那么一次就会加载 64 字节。
比如内存中有一段连续的数据:
text
| long a | long b | long c | long d | long e | long f | long g | long h |
一个long占 8 字节,8 个long正好占 64 字节。假设它们刚好处在同一个缓存行里,那么 CPU 访问a时,实际上会把a到h这一整段数据都加载到缓存里。
这样做大多数时候是有好处的。比如遍历数组时,CPU 预先加载相邻数据,可以显著提升访问效率。但凡事都有两面性:当多个线程在不同 CPU 核心上修改同一个缓存行里的不同变量时,问题就来了。
CPU缓存一致性是什么
现在考虑一个多核 CPU。每个核心都有自己的缓存,多个核心又共享同一块主内存。
如果只有读操作,一切都比较简单。多个核心都可以把同一份数据加载到各自的缓存里,大家读到的值一致即可。
但如果有写操作,问题就复杂了。
假设变量x的初始值为 1,线程 A 在 CPU Core 1 上运行,线程 B 在 CPU Core 2 上运行:
- Core 1 把
x = 1加载到自己的缓存中 - Core 2 也把
x = 1加载到自己的缓存中 - 线程 A 把
x修改为 2 - 线程 B 如果继续从自己的缓存中读取
x,是不是还会读到旧值 1?
为了避免不同核心看到的数据互相矛盾,CPU 需要一套机制来维护缓存之间的数据一致性,这就是 CPU 缓存一致性。
常见的一致性协议是 MESI,它把缓存行的状态大致分为下面几类:
| 状态 | 含义 |
|---|---|
| Modified | 当前缓存行被本核心修改过,数据和主内存不一致,其他核心没有有效副本 |
| Exclusive | 当前缓存行只被本核心持有,数据和主内存一致 |
| Shared | 当前缓存行可能被多个核心持有,数据和主内存一致 |
| Invalid | 当前缓存行已经失效,不能继续使用 |
这里不需要把 MESI 的所有细节背下来,我们只要抓住一个关键点:CPU 维护一致性的单位不是某个 Java 字段,也不是某个 C 语言变量,而是缓存行。
也就是说,只要某个核心修改了一个缓存行中的任意一个字节,其他核心中同一个缓存行的副本就可能被标记为失效。
这句话就是理解伪共享的关键。
从缓存一致性到伪共享
现在我们构造一个场景。
有两个线程,分别运行在两个 CPU 核心上:
- 线程 A 只修改变量
a - 线程 B 只修改变量
b - 从业务逻辑上看,
a和b是两个完全不同的变量 - 但从内存布局上看,
a和b刚好落在同一个缓存行中
它可能长这样:
text
同一个缓存行(64字节) +---------------------------------------------------------------+ | a | b | 其他数据 | +---------------------------------------------------------------+ ^ ^ 线程A 线程B
此时会发生什么?
- 线程 A 修改
a,Core 1 获得这个缓存行的写权限 - Core 2 上相同缓存行的副本被标记为 Invalid
- 线程 B 修改
b,发现自己的缓存行失效,只能重新加载并获得写权限 - Core 1 上相同缓存行的副本又被标记为 Invalid
- 线程 A 下一次修改
a,又要重新加载这个缓存行
两个线程明明没有修改同一个变量,却在缓存行层面互相“打扰”。这种因为不同变量共享同一个缓存行而导致的无意义缓存失效,就是伪共享。
