面试官:什么是伪共享,如何避免?

面试官,什么,共享,如何,避免 · 浏览次数 : 424

小编点评

## Java 对象内存布局分析 **父类声明的实例字段会在子类实例字段之前声明,而字段间的并不是按照源码中的声明顺序排列的,而是相同宽度的字段会分配在一起:** * 父类声明的 7 个 long 变量:`a1, a2, a3, a4, a5, a6, a7` * 子类继承的 7 个 long 变量:`b1, b2, b3, b4, b5, b6, b7` **由于 Java 对象内存布局的限制,所有填充变量都变成前置填充了,并没有起到填充的效果。** **实验验证:** ```java // 使用 JOL 工具输出对象内存布局 long offset = 0; long size = 4; Type type = Type.LONG; String description = "Value"; System.out.printf("%12s %4d %s %s\n", offset, size, type, description); // 其他填充格式的测试 offset = 12; size = 8; type = Type.INT; System.out.printf("%12s %4d %s %s\n", offset, size, type, description); ``` **输出结果:** ``` 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) ``` **可以看到,`a1` 和 `b1` 的值被分配到同一个缓存行中,因为它们是相同宽度的。** **总结:** 在 Java 中,由于对象内存布局的限制,所有填充变量都变成前置填充了,并没有起到填充的效果。为了解决伪共享问题,可以采用缓冲行填充等方法来避免变量和其他分组被填充到同一个缓存行中。

正文

theme: jzman

本文已收录到  GitHub · AndroidFamily,有 Android 进阶知识体系,欢迎 Star。技术和职场问题,请关注公众号 [彭旭锐] 私信我提问。

前言

大家好,我是小彭。

在前面的文章里,我们聊到了 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、解决伪共享问题的方法是缓冲行填充:在变量前后填充额外的占位变量,避免变量和其他分组的被填充到同一个缓存行中,从而规避伪共享问题。


本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

参考资料

与面试官:什么是伪共享,如何避免?相似的内容:

面试官:什么是伪共享,如何避免?

theme: jzman 本文已收录到 GitHub · AndroidFamily,有 Android 进阶知识体系,欢迎 Star。技术和职场问题,请关注公众号 [彭旭锐] 私信我提问。 前言 大家好,我是小彭。 在前面的文章里,我们聊到了 CPU 的高速缓存机制。由于 CPU 和内存的速度差距

面试官:什么是双亲委派模型?

本文已经收录进 JavaGuide(「Java学习+面试指南」一份涵盖大部分 Java 程序员所需要掌握的核心知识。) 参加过校招面试的同学,应该对这个问题不陌生。一般提问 JVM 知识点的时候,就会顺带问你双亲委派模型(别扭的翻译。。。)。 就算是不准备面试,学习双亲委派模型对于我们也非常有帮助。

美团一面问我i++跟++i的区别是什么

美团一面问我i++跟++i的区别是什么 面试官:“i++跟++i的区别是什么?” 我:“i++是先使用然后再执行+1的操作,++i是先执行+1的操作然后再去使用i” 面试官:“那你看看下面这段代码,运行结果是什么?” public static void main(String[] args) {

[转帖]图解:什么是红黑树?

https://zhuanlan.zhihu.com/p/273829162 注:本文比较硬核但是很值得大家花心思看完,看完你一定会有所收获的 红黑树是面试中一个很经典也很有难度的知识点,网传字节跳动面试官最喜欢问这个问题。很多人会觉得这个知识点太难,不想花太多功夫去了解,也有人会认为这个数据结构在

【转帖】一道面试题:JVM老年代空间担保机制

面试问题 昨天面试的时候,面试官问的问题: 什么是老年代空间担保机制?担保的过程是什么?老年代空间担保机制是谁给谁担保?为什么要有老年代空间担保机制?或者说空间担保机制的目的是什么?如果没有老年代空间担保机制会有什么不好? 下面我们就带着这些问题去了解一下JVM老年代空间担保机制吧。 老年代空间担保

面试官:说说Netty对象池的实现原理?

Netty 作为一个高性能的网络通讯框架,它内置了很多恰夺天工的设计,目的都是为了将网络通讯的性能做到极致,其中「对象池技术」也是实现这一目标的重要技术。 1.什么是对象池技术? 对象池技术是一种重用对象以减少对象创建和销毁带来的开销的方法。在对象池中,只有第一次访问时会创建对象,并将其维护在内存中

面试官:谈谈对SpringAI的理解?

Spring AI 已经发布了好长时间了,目前已经更新到 1.0 版本了,所以身为 Java 程序员的你,如果还对 Spring AI 一点都不了解的话,那就有点太落伍了。 言归正传,那什么是 Spring AI?如何快速进行 Spring AI 开发呢? 1.什么是Spring AI? Sprin

面试官:字节流可以处理一切文件为什么还需要字符流呢?

一、写在开头 在计算机领域中百分之九十以上的程序拥有着和外部设备交互的功能,这就是我们常说的IO(Input/Output:输入/输出),所谓输入就是外部数据导入计算机内存中的过程,输出则是将内存或者说程序中的数据导入到外部存储中,如数据库、文件以及其他本地磁盘等。 二、什么是IO流 这种输入输出往

面试官:如何打破双亲委派机制?

面试连环call:1. 双亲委派机制是什么?如何打破双亲委派机制?2. JVM都有哪些类加载器?3. 如何构造一个自定义类加载器?

面试官:在原生input上面使用v-model和组件上面使用有什么区别?

前言 还是上一篇面试官:来说说vue3是怎么处理内置的v-for、v-model等指令? 文章的那个粉丝,面试官接着问了他另外一个v-model的问题。 面试官:vue3的v-model都用过吧,来讲讲。 粉丝:v-model其实就是一个语法糖,在编译时v-model会被编译成:modelValue