如果你阅读过 Java 的 Striped64
源码(没看过的可以看看博主的
这篇文章),可能遇到过 @Contended
注解。如果你经常看 C 语言的代码,也可能遇到过在结构体加 padding 的情形。它们都是为了提高缓存的性能,解决伪共享(False Sharing)的问题。
缓存行(cache line)
首先需要知道一个概念:cache line(缓存行)。缓存从内存加载数据时,并不是只加载我们请求的那部分,而是会多加载一些,例如我们想访问一个 int,只有 4 字节,但缓存会一次性加载 64 字段(不同机器不同)。缓存每次处理的这一“块”数据,就叫 cache line。
为什么缓存要多加载数据呢?为了利用空间局部性(space locality)来提高性能 [1]。相当于是缓存做了猜想,后续地址的数据,通常就是接下来马上要访问的数据,提前加载能提高性能。
缓存一致性与 MESI 协议
现代 CPU 体系中,一般每个核都单独配备了自己的(L1)缓存,为了保证多个 CPU 在读写缓存时保证整体数据的一致性,通常需要使用缓存一致性协议,MESI 就是其中一种(可以参考博主的这篇文章)。
MESI 协议可以简单理解为“踢人协议”,如果一个 CPU 写数据到缓存里,则需要“踢”掉其它缓存里的副本。
上图中,第 ⑦ 步就是“踢人”的操作。同时要注意如果 CPU 对缓存只做“读”操作,缓存也是需要同步的,如上图的第 ⑤ 步,只是它的开销更小。
伪共享(False Sharing)
缓存一致性说的是一个 cache line 在不同缓存间的同步操作。那如果一个 cache line 上存了两个变量,并且两个变量分别被不同的线程写入呢?
可以看到,虽然 CPU A 和 CPU B 各自在写自己关心的变量 x
和 y
,但由于它们存在于同一个 cache line,每次写入都会造成另一个 CPU 的缓存失效。造成严重的性能问题。
常见场景与解法
我们看到伪共享的发生有两个条件:
- 两个变量在同一个缓存行里。通常是一个类/结构体的两个 field,或是同一个数组的相邻元素
- 不同线程同时对两个地址读写[2]。因此通常发生在高并发的场景下。
由于 #2 条件多线程处理一般是业务要求,解法通常是打破 #1 条件:加 padding,让一个cache line 里只保留一个变量[3]。例如 int
只占
4
字节,可以在后面加 15个没用的 int 变量,撑满 64
字节[4]。而
Java 专门提供了@Contended
[5] 来简化这种情形。
False Sharing 的影响有多大?
JMH 有一个测 False Sharing 的 Benchmark ,在我的机器上(20c、Java 11)运行结果[6]如下:
Benchmark Mode Cnt Score Error Units |
baseline、contended 及 padded 吞吐上的差别大概 10%
(网上一些文章差异在 2 倍、
3 倍,和我的结果出入这么大的原因还没找到)。我们再用 perf 对比 cache misses:
---------------------------- Baseline ------------------------------------ |
对比其中的 L1-dcache-load-misses
,可以看出,加了 @Contended
的 cache miss
只有 baseline 的 3%
。
小结
缓存的加载写入以 cache line 为单位,典型的大小为 64B。为了保证多 CPU 下缓存数据的一致性,需要使用一些缓存一致性协议,MESI 是其中的一个经典协议,写入缓存行时会“踢掉”其它 CPU 上的缓存。如果两个变量在同一个 cache line 中,且多线程频繁读写这两个变量,会导致多 CPU “互踢”对方的 cache line,导致性能下降。在博主的机器上 False Sharing 实测大概慢 10%,而 cache miss 大概是正常的 33 倍。
参考
- A Guide to False Sharing and @Contended 里面基本把 False Sharing 涉及的知识都说清楚了,推荐阅读
- @Contended (a.k.a. JEP 142) PPT 介绍
@Contended
功能及实现 - JVM series: Contend annotation and false-sharing 其中的实验显示 padding 版本的吞吐大概是没有 padding 版本的 2 倍
注意一般 C 语言里 padding 还有另一个作用,将数据按“字”来对齐地址,这也有助于提高性能,但缓存的视角主要还是在局部性上 ↩
两个线程同时写入的情况比较明显;一写一读也有问题;两个线程都是读则没有问题 ↩
采用 padding 的方式其实不一定靠谱,因为编译器优化有可能会把没用的字段去掉 ↩
64
这个数字并不是固定的,有些机器会设置为128
字节, Linux 下可以执行getconf LEVEL1_DCACHE_LINESIZE
来查看 cache line 大小, MacOS 下执行sysctl hw.cachelinesize
↩Java 8 中通过
@sun.misc.Contended
引用, Java 9 及之后,通过@jdk.internal.vm.annotation.Contended
引用,但需要额外 export 一些包 ↩需要在启动参数上加上
-XX:-RestrictContended
,用户代码里加的@Contended
才能生效。 ↩