本文已收录到 AndroidFamily,技能和职场问题,请关注大众号 [彭旭锐] 发问。

前语

大家好,我是小彭。

在前面的文章里,咱们聊到了 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

什么是伪共享,如何避免?