1. Cache Line and Coherency
现代处理器会使用多级缓存来加速处理,当处理器访问一个对象时,会对其进行缓存,同时根据空间局部性原理,还会将其相邻的对象也一并放到缓存行(Cache Line),这种方式可以显著地提升应用的整体性能表现。
然而当多个处理器同时操作相邻内存的数据时,就会破坏这种优化,不同的处理器之间,需要有一个方式来维护彼此缓存行中相同对象的一致性(即缓存一致性)。
有许多协议能够维护不同 CPU 核心之间的缓存一致性,其中最常见的就是 MESI 协议。
1.1. MESI 协议
在 MESI 协议中,每个缓存行只会处于四种状态中:Modified, Exclusive, Shared 以及 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