CPU 中的伪共享

Posted on Sat, Sep 26, 2020 Java

1. Cache Line and Coherency

现代处理器会使用多级缓存来加速处理,当处理器访问一个对象时,会对其进行缓存,同时根据空间局部性原理,还会将其相邻的对象也一并放到缓存行(Cache Line),这种方式可以显著地提升应用的整体性能表现。

然而当多个处理器同时操作相邻内存的数据时,就会破坏这种优化,不同的处理器之间,需要有一个方式来维护彼此缓存行中相同对象的一致性(即缓存一致性)。

有许多协议能够维护不同 CPU 核心之间的缓存一致性,其中最常见的就是 MESI 协议

1.1. MESI 协议

在 MESI 协议中,每个缓存行只会处于四种状态中:Modified, Exclusive, Shared 以及 Invalid

为了更好地理解,可以通过以下例子来说明:

Core A 从主存中读取对象 a ,这时候会连通相邻对象 b 也一并缓存到缓存行中,这时只有 Core A 在操作这个缓存行,因此这个缓存行会被标记为 Exclusive

过了一会,Core B 决定从主存中读取 b 对象,a 与 b 又被同时缓存到同一个缓存行,这时候 Core A 跟 Core B 都会将对应的缓存行标记为 Shared。:

接下来 Core A 决定修改 a 的值, Core A 会将缓存行标记为 Modified,同时也会向 Core B 传递信息,Core B 则会将自己对应的缓存行标记为 Invalid

由于 Core B 的缓存行被标记为 Invalid,这时候如果要读取 b,只能重新从主存中获取,同时为了缓存 a 最新的值,还会触发 Core A 将 a 提前 flush 到主存中

在 Core B 重新缓存之后,两个 CPU 核心又重新处于 Shared 状态。

如果频繁发生这种现象,CPU 缓存的作用就失去了意义,一旦相邻的缓存值(即使是不相关的值)发生变化,就可能导致 CPU 需要重新从主存中取值,这种称之为伪共享

2. 消除伪共享

消除伪共享的方式,主要就是通过字节填充方式,使得一个对象能够刚好占用一个缓存行,在 Java 7 之前,都是通过添加一些无实际意义的字段来进行填充,但从 Java 7 开始,一些无意义的字段会被自动优化掉,这时候有两个选择:将无意义的填充字段放到父类中,或者使用 Java 8 的 @Contended 注解。

@Contended 注解会自动为对象添加填充内容,达到字节填充的效果,默认只对 JDK 内部实现的类生效,如果想要我们自定义的类生效,则需要为 JVM 添加 -XX:-RestrictContended` 参数。

默认情况下,@Contended 会添加 128 bytes 的字节填充,这主要是因为许多现代处理器的缓存行大小都是64/128 bytes,该参数值可以通过 -XX:ContendedPaddingWidth 指定,取值范围为 0 ~ 8192。

如果要关闭 @Contented ,则可以添加 -XX:-EnableContended ,在内存资源紧张的情况下可以考虑,但会损失一点(也可能是很多)性能。

3. 伪共享对性能的影响

网上对伪共享的测试例子有很多,但目前见过的最令人信服的测试可以看这篇文章,除了通过 Benchmark 进行压测外,还使用了 Perf 获取了 CPU 的一些底层信息,就结果而言,消除了伪共享的测试用例里 L1-dcache-load-misses 低了两个量级。

业界还有一些一些优秀的实现案例: