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
低了两个量级。
业界还有一些一些优秀的实现案例:
- Thread#threadLocalRandomSeed
- RingBuffer
- The Striped64 class to implement counters and accumulators with high throughput
- The Thread class to facilitate the implementation of efficient random number generators
- The ForkJoinPool work-stealing queue
- The ConcurrentHashMap#CountCell implementation
- The dual data structure used in the Exchanger class