共计 4519 个字符,预计需要花费 12 分钟才能阅读完成。
本文已收录到 GitHub · AndroidFamily,有 Android 进阶常识体系,欢送 Star。技术和职场问题,请关注公众号 [彭旭锐] 退出 Android 交换群。
前言
大家好,我是小彭。
在后面的文章里,咱们聊到了 CPU 的高速缓存机制。因为 CPU 和内存的速度差距太大,古代计算机会在两者之间插入一块高速缓存。
然而,CPU 缓存总能进步程序性能吗,有没有什么状况 CPU 缓存反而会成为程序的性能瓶颈?这就是咱们明天要探讨的伪共享(False Sharing)。
学习路线图:
1. 回顾 MESI 缓存一致性协定
因为 CPU 和内存的速度差距太大,为了拉平两者的速度差,古代计算机会在两者之间插入一块速度比内存更快的高速缓存,CPU 缓存是分级的,有 L1 / L2 / L3 三级缓存。
因为单核 CPU 的性能遇到瓶颈(主频与功耗的矛盾),芯片厂商开始在 CPU 芯片里集成多个 CPU 外围,每个外围有各自的 L1 / L2 缓存。其中 L1 / L2 缓存是外围独占的,而 L3 缓存是多外围共享的。为了保障同一份数据在内存和多个缓存正本中的一致性,古代 CPU 会应用 MESI 等缓存一致性协定保证系统的数据一致性。
缓存一致性问题
MESI 协定
当初,咱们的问题是:CPU 缓存总可能进步程序性能吗?
2. 什么是伪共享?
基于局部性原理的利用,CPU Cache 在读取内存数据时,每次不会只读一个字或一个字节,而是一块块地读取,每一小块数据也叫 CPU 缓存行(CPU Cache Line)。
在并行场景中,当多个处理器外围批改同一个缓存行变量时,有 2 种状况:
- 状况 1 – 批改同一个变量: 两个处理器并行批改同一个变量的状况,CPU 会通过 MESI 机制维持两个外围的缓存中的数据一致性(Conherence)。简略来说,一个外围在批改数据时,须要先向所有外围播送 RFO 申请,将其它外围的 Cache Line 置为“已生效”。其它外围在读取或写入“已生效”数据时,须要先将其它外围“已批改”的数据写回内存,再从内存读取;
事实上,多个外围批改同一个变量时,应用 MESI 机制保护数据一致性是必要且正当的。然而多个外围别离拜访不同变量时,MESI 机制却会呈现不合乎预期的性能问题。
- 状况 2 – 批改不同变量: 两个处理器并行批改不同变量的状况,从程序员的逻辑上看,两个外围没有数据依赖关系,因而每次写入操作并不需要把其余外围的 Cache Line 置为“已生效”。但从 CPU 的缓存一致性机制上看,因为 CPU 缓存的颗粒度是一个个缓存行,而不是其中的一个个变量。当批改其中的一个变量后,缓存管制机制也必须把其它外围的整个 Cache Line 置为“已生效”。
在高并发的场景下,外围的写入操作就会交替地把其它外围的 Cache Line 置为生效,强制对方刷新缓存数据,导致缓存行失去作用,甚至性能比串行计算还要低。
这个问题咱们就称为伪共享问题。
呈现伪共享问题时,有可能呈现程序并行执行的耗时比串行执行的耗时还要长。耗时排序: 并行执行有伪共享 > 串行执行 > 并行执行无伪共享。
伪共享性能测试
—— 数据援用自 Github · falseSharing —— MJjainam 著
3. 缓存行填充
那么,怎么解决伪共享问题呢?其实办法很简略 —— 缓存行填充:
- 1、分组: 首先须要思考哪些变量是独立变动的,哪些变量是协同变动的。协同变动的变量放在一组,而无关的变量分到不同组;
- 2、填充: 在变量前后填充额定的占位变量,防止变量和其余分组的被填充到同一个缓存行中,从而躲避伪共享问题。
上面,咱们以 Java 为例介绍如何做缓存行填充,在不同 Java 版本上填充的实现形式不同:
- Java 8 之前
通过填充 long 变量填充 Padding。 网上有的材料会将前置填充和后置填充放在同一个类中, 这是不对的。例如:
谬误示例
public class Data {
long a1,a2,a3,a4,a5,a6,a7; // 前置填充
volatile int value;
long b1,b2,b3,b4,b5,b6,b7; // 后置填充
}
在《对象的内存分为哪几个局部?》这篇文章中,咱们剖析 Java 对象的内存布局:其中咱们提到:“其中,父类申明的实例字段会放在子类实例字段之前,而字段间的并不是依照源码中的申明顺序排列的,而是雷同宽度的字段会调配在一起:援用类型 > long/double > int/float > short/char > byte/boolean。”
Java 对象内存布局
因而,下面的代码中,所有填充变量都变成前置填充了,并没有起到填充的成果:
试验验证
# 应用 JOL 工具输入对象内存布局:OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
# 填充有效
12 4 int Data.value 0
16 8 long Data.a1 0
24 8 long Data.a2 0
32 8 long Data.a3 0
40 8 long Data.a4 0
48 8 long Data.a5 0
56 8 long Data.a6 0
64 8 long Data.a7 0
72 8 long Data.b1 0
80 8 long Data.b2 0
88 8 long Data.b3 0
96 8 long Data.b4 0
104 8 long Data.b5 0
112 8 long Data.b6 0
120 8 long Data.b7 0
Instance size: 128 bytes
正确的做法是利用父子类继承来做缓存行填充:
正确示例
public abstract class SuperPadding {long a1,a2,a3,a4,a5,a6,a7; // 前置填充}
public abstract class DataField extends SuperPadding {volatile int value;}
public class Data extends DataField {long b1,b2,b3,b4,b5,b6,b7; // 后置填充}
试验验证
# 应用 JOL 工具输入对象内存布局:OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) bf c1 00 f8 (10111111 11000001 00000000 11111000) (-134168129)
12 4 (alignment/padding gap)
16 8 long SuperPadding.a1 0
24 8 long SuperPadding.a2 0
32 8 long SuperPadding.a3 0
40 8 long SuperPadding.a4 0
48 8 long SuperPadding.a5 0
56 8 long SuperPadding.a6 0
64 8 long SuperPadding.a7 0
72 4 int DataField.value 0
76 4 (alignment/padding gap)
80 8 long Data.b1 0
88 8 long Data.b2 0
96 8 long Data.b3 0
104 8 long Data.b4 0
112 8 long Data.b5 0
120 8 long Data.b6 0
128 8 long Data.b7 0
Instance size: 136 bytes
缓存行填充
例如,Java 并发框架 Disruptor 就是应用继承的形式实现:
Disruptor · RingBuffer.java
abstract class RingBufferPad {protected long p1, p2, p3, p4, p5, p6, p7;}
abstract class RingBufferFields<E> extends RingBufferPad {
// 前置填充:父类的 7 个 long 变量
...
private final long indexMask;
private final Object[] entries;
protected final int bufferSize;
protected final Sequencer sequencer;
...
// 后置填充:子类的 7 个 long 变量
}
public final class RingBuffer<E> extends RingBufferFields<E> implements Cursored, EventSequencer<E>, EventSink<E> {
protected long p1, p2, p3, p4, p5, p6, p7;
...
}
-
Java 8 开始
@sun.misc.Contended
注解是 JDK 1.8 新增的注解。如果 JVM 开启字节填充性能-XX:-RestrictContended
,在运行时就会在变量或类前后填充 Padding。Java 8 Thread.java
/** The current seed for a ThreadLocalRandom */
@sun.misc.Contended("tlr")
long threadLocalRandomSeed;
/** Probe hash value; nonzero if threadLocalRandomSeed initialized */
@sun.misc.Contended("tlr")
int threadLocalRandomProbe;
/** Secondary seed isolated from public ThreadLocalRandom sequence */
@sun.misc.Contended("tlr")
int threadLocalRandomSecondarySeed;
Java 8 ConcurrentHashMap.java
@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) {value = x;}
}
4. 总结
- 1、在并行场景中,当多个处理器外围批改同一个缓存行变量时,即便两个变量没有逻辑上的数据依赖性,CPU 缓存一致性机制也会使得两个外围中的缓存交替地生效,拉低程序的性能。这种景象叫伪共享问题;
- 2、解决伪共享问题的办法是缓冲行填充:在变量前后填充额定的占位变量,防止变量和其余分组的被填充到同一个缓存行中,从而躲避伪共享问题。
参考资料
- 深入浅出计算机组成原理(第 37 讲)—— 徐文浩 著,极客工夫 出品
- 字节面:什么是伪共享?—— 小林 Coding 著
- Be careful when trying to eliminate false sharing in Java —— nitsanw 著
- False Sharing && Java 7 —— Martin Thompson 著
- False sharing —— Wikepedia