每个线程只管自己的变量,性能却不如单线程?问题出在缓存行 _

每个线程只管自己的变量,性能却不如单线程?问题出在缓存行 _

伪共享(False Sharing)是多线程编程中一个很容易被忽略,但在高并发场景下又可能非常致命的性能问题。

它最迷惑人的地方在于:从业务代码上看,多个线程并没有修改同一个变量,甚至每个线程都只操作属于自己的那份数据,理论上不应该发生竞争;但从 CPU 的视角看,这些变量可能刚好落在同一个缓存行里,于是一个线程修改自己的变量时,会导致其他 CPU 核心上的缓存行失效,最终引发大量无意义的缓存同步。

所以,伪共享不是“逻辑共享”导致的问题,而是“物理存储位置太近”导致的问题。

本文内容:

  1. 从现代处理器的缓存结构说起
  2. 缓存行为什么是 CPU 缓存的基本单位
  3. 什么是 CPU 缓存一致性
  4. 为什么缓存一致性会引出伪共享问题
  5. 用 Java 代码演示伪共享和缓存行填充
  6. 伪共享的常见解决方案
  7. 实际项目中该如何判断和取舍

CPU为什么需要缓存

在理解伪共享之前,我们要先理解一个基础问题:CPU 为什么需要缓存?

现代 CPU 的执行速度非常快,而内存相对 CPU 来说要慢很多。如果每一次读取变量、写入变量都直接访问主内存,那么 CPU 大部分时间都会浪费在等待内存数据返回上。为了缓解这个问题,CPU 和主内存之间会加入多级缓存,也就是我们常说的 L1、L2、L3 Cache。

一般来说,缓存层级可以简单理解为:

  1. L1 Cache:离 CPU 核心最近,速度最快,容量最小,通常每个核心独享
  2. L2 Cache:速度比 L1 慢一些,容量比 L1 大一些,很多处理器中也是每个核心独享
  3. L3 Cache:速度再慢一些,但容量更大,通常多个核心共享
  4. 主内存:容量最大,但访问延迟远高于 CPU Cache

也就是说,一个变量并不是每次都从内存中直接读取。CPU 会尽量把最近访问过的数据放到缓存里,下次再访问相同数据或相邻数据时,就可以直接从缓存中拿到,速度会快很多。

这背后依赖两个很重要的局部性原理:

  1. 时间局部性:一个数据刚被访问过,后续很可能还会再次被访问
  2. 空间局部性:一个数据被访问时,它附近的数据也很可能会被访问

比如我们遍历一个数组:

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时,实际上会把ah这一整段数据都加载到缓存里。

这样做大多数时候是有好处的。比如遍历数组时,CPU 预先加载相邻数据,可以显著提升访问效率。但凡事都有两面性:当多个线程在不同 CPU 核心上修改同一个缓存行里的不同变量时,问题就来了。

CPU缓存一致性是什么

现在考虑一个多核 CPU。每个核心都有自己的缓存,多个核心又共享同一块主内存。

如果只有读操作,一切都比较简单。多个核心都可以把同一份数据加载到各自的缓存里,大家读到的值一致即可。

但如果有写操作,问题就复杂了。

假设变量x的初始值为 1,线程 A 在 CPU Core 1 上运行,线程 B 在 CPU Core 2 上运行:

  1. Core 1 把x = 1加载到自己的缓存中
  2. Core 2 也把x = 1加载到自己的缓存中
  3. 线程 A 把x修改为 2
  4. 线程 B 如果继续从自己的缓存中读取x,是不是还会读到旧值 1?

为了避免不同核心看到的数据互相矛盾,CPU 需要一套机制来维护缓存之间的数据一致性,这就是 CPU 缓存一致性。

常见的一致性协议是 MESI,它把缓存行的状态大致分为下面几类:

状态含义
Modified当前缓存行被本核心修改过,数据和主内存不一致,其他核心没有有效副本
Exclusive当前缓存行只被本核心持有,数据和主内存一致
Shared当前缓存行可能被多个核心持有,数据和主内存一致
Invalid当前缓存行已经失效,不能继续使用

这里不需要把 MESI 的所有细节背下来,我们只要抓住一个关键点:CPU 维护一致性的单位不是某个 Java 字段,也不是某个 C 语言变量,而是缓存行。

也就是说,只要某个核心修改了一个缓存行中的任意一个字节,其他核心中同一个缓存行的副本就可能被标记为失效。

这句话就是理解伪共享的关键。

从缓存一致性到伪共享

现在我们构造一个场景。

有两个线程,分别运行在两个 CPU 核心上:

  1. 线程 A 只修改变量a
  2. 线程 B 只修改变量b
  3. 从业务逻辑上看,ab是两个完全不同的变量
  4. 但从内存布局上看,ab刚好落在同一个缓存行中

它可能长这样:

text

同一个缓存行(64字节) +---------------------------------------------------------------+ | a | b | 其他数据 | +---------------------------------------------------------------+ ^ ^ 线程A 线程B

此时会发生什么?

  1. 线程 A 修改a,Core 1 获得这个缓存行的写权限
  2. Core 2 上相同缓存行的副本被标记为 Invalid
  3. 线程 B 修改b,发现自己的缓存行失效,只能重新加载并获得写权限
  4. Core 1 上相同缓存行的副本又被标记为 Invalid
  5. 线程 A 下一次修改a,又要重新加载这个缓存行

两个线程明明没有修改同一个变量,却在缓存行层面互相“打扰”。这种因为不同变量共享同一个缓存行而导致的无意义缓存失效,就是伪共享。