重磅硬核|一文聊透对象在JVM中的内存布局(三)

r660926
发布于 2022-7-28 17:53
浏览
0收藏

4.2 False Sharing(伪共享)


我们先来看一个这样的例子,笔者定义了一个示例类FalseSharding,类中有两个long型的volatile字段a,b。

public class FalseSharding {

    volatile long a;

    volatile long b;

}

字段a,b之间逻辑上是独立的,它们之间一点关系也没有,分别用来存储不同的数据,数据之间也没有关联。

 

FalseSharding类中字段之间的内存布局如下:

重磅硬核|一文聊透对象在JVM中的内存布局(三)-鸿蒙开发者社区

 image.png


FalseSharding类中的字段a,b在内存中是相邻存储,分别占用8个字节。

 

如果恰好字段a,b被CPU读进了同一个缓存行,而此时有两个线程,线程a用来修改字段a,同时线程b用来读取字段b。

重磅硬核|一文聊透对象在JVM中的内存布局(三)-鸿蒙开发者社区

 falsesharding1.png


在这种场景下,会对线程b的读取操作造成什么影响呢?

 

我们知道声明了volatile关键字的变量可以在多线程处理环境下,确保内存的可见性。计算机硬件层会保证对被volatile关键字修饰的共享变量进行写操作后的内存可见性,而这种内存可见性是由Lock前缀指令以及缓存一致性协议(MESI控制协议)共同保证的。

 

 • Lock前缀指令可以使修改线程所在的处理器中的相应缓存行数据被修改后立马刷新回内存中,并同时锁定所有处理器核心中缓存了该修改变量的缓存行,防止多个处理器核心并发修改同一缓存行。


 • 缓存一致性协议主要是用来维护多个处理器核心之间的CPU缓存一致性以及与内存数据的一致性。每个处理器会在总线上嗅探其他处理器准备写入的内存地址,如果这个内存地址在自己的处理器中被缓存的话,就会将自己处理器中对应的缓存行置为无效,下次需要读取的该缓存行中的数据的时候,就需要访问内存获取。

 

基于以上volatile关键字原则,我们首先来看第一种影响:

重磅硬核|一文聊透对象在JVM中的内存布局(三)-鸿蒙开发者社区

 falsesharding2.png


当线程a在处理器core0中对字段a进行修改时,Lock前缀指令会将所有处理器中缓存了字段a的对应缓存行进行锁定这样就会导致线程b在处理器core1中无法读取和修改自己缓存行的字段b。


•处理器core0将修改后的字段a所在的缓存行刷新回内存中。


•从图中我们可以看到此时字段a的值在处理器core0的缓存行中以及在内存中已经发生变化了。但是处理器core1中字段a的值还没有变化,并且core1中字段a所在的缓存行处于锁定状态,无法读取也无法写入字段b。

 

从上述过程中我们可以看出即使字段a,b之间逻辑上是独立的,它们之间一点关系也没有,但是线程a对字段a的修改,导致了线程b无法读取字段b。

 

第二种影响:

重磅硬核|一文聊透对象在JVM中的内存布局(三)-鸿蒙开发者社区

 faslesharding3.png


当处理器core0将字段a所在的缓存行刷新回内存的时候,处理器core1会在总线上嗅探到字段a的内存地址正在被其他处理器修改,所以将自己的缓存行置为失效。当线程b在处理器core1中读取字段b的值时,发现缓存行已被置为失效,core1需要重新从内存中读取字段b的值即使字段b没有发生任何变化。

 

从以上两种影响我们看到字段a与字段b实际上并不存在共享,它们之间也没有相互关联关系,理论上线程a对字段a的任何操作,都不应该影响线程b对字段b的读取或者写入。

 

但事实上线程a对字段a的修改导致了字段b在core1中的缓存行被锁定(Lock前缀指令),进而使得线程b无法读取字段b。

 

线程a所在处理器core0将字段a所在缓存行同步刷新回内存后,导致字段b在core1中的缓存行被置为失效(缓存一致性协议),进而导致线程b需要重新回到内存读取字段b的值无法利用CPU缓存的优势。

 

由于字段a和字段b在同一个缓存行中,导致了字段a和字段b事实上的共享(原本是不应该被共享的)。这种现象就叫做False Sharing(伪共享)

 

在高并发的场景下,这种伪共享的问题,会对程序性能造成非常大的影响。

 

如果线程a对字段a进行修改,与此同时线程b对字段b也进行修改,这种情况对性能的影响更大,因为这会导致core0和core1中相应的缓存行相互失效。

 

4.3 False Sharing的解决方案


既然导致False Sharing出现的原因是字段a和字段b在同一个缓存行导致的,那么我们就要想办法让字段a和字段b不在一个缓存行中。

 

那么我们怎么做才能够使得字段a和字段b一定不会被分配到同一个缓存行中呢?

 

这时候,本小节的主题字节填充就派上用场了~~

 

在Java8之前我们通常会在字段a和字段b前后分别填充7个long型变量(缓存行大小64字节),目的是让字段a和字段b各自独占一个缓存行避免False Sharing。

 

比如我们将一开始的实例代码修改成这个这样子,就可以保证字段a和字段b各自独占一个缓存行了。

public class FalseSharding {

    long p1,p2,p3,p4,p5,p6,p7;
    volatile long a;
    long p8,p9,p10,p11,p12,p13,p14;
    volatile long b;
    long p15,p16,p17,p18,p19,p20,p21;

}

修改后的对象在内存中布局如下:

重磅硬核|一文聊透对象在JVM中的内存布局(三)-鸿蒙开发者社区

image.png


我们看到为了解决False Sharing问题,我们将原本占用32字节的FalseSharding示例对象硬生生的填充到了200字节。这对内存的消耗是非常可观的。通常为了极致的性能,我们会在一些高并发框架或者JDK的源码中看到False Sharing的解决场景。因为在高并发场景中,任何微小的性能损失比如False Sharing,都会被无限放大。

 

但解决False Sharing的同时又会带来巨大的内存消耗,所以即使在高并发框架比如disrupter或者JDK中也只是针对那些在多线程场景下被频繁写入的共享变量。

 

这里笔者想强调的是在我们日常工作中,我们不能因为自己手里拿着锤子,就满眼都是钉子,看到任何钉子都想上去锤两下。

 

我们要清晰的分辨出一个问题会带来哪些影响和损失,这些影响和损失在我们当前业务阶段是否可以接受?是否是瓶颈?同时我们也要清晰的了解要解决这些问题我们所要付出的代价。一定要综合评估,讲究一个投入产出比。某些问题虽然是问题,但是在某些阶段和场景下并不需要我们投入解决。而有些问题则对于我们当前业务发展阶段是瓶颈,我们不得不去解决。我们在架构设计或者程序设计中,方案一定要简单合适。并预估一些提前量留有一定的演化空间

 

4.3.1 @Contended注解


在Java8中引入了一个新注解@Contended,用于解决False Sharing的问题,同时这个注解也会影响到Java对象中的字段排列。

 

在上一小节的内容介绍中,我们通过手段填充字段的方式解决了False Sharing的问题,但是这里也有一个问题,因为我们在手动填充字段的时候还需要考虑CPU缓存行的大小,因为虽然现在所有主流的处理器缓存行大小均为64字节,但是也还是有处理器的缓存行大小为32字节,有的甚至是128字节。我们需要考虑很多硬件的限制因素。

 

Java8中通过引入@Contended注解帮我们解决了这个问题,我们不在需要去手动填充字段了。下面我们就来看下@Contended注解是如何帮助我们来解决这个问题的~~

 

上小节介绍的手动填充字节是在共享变量前后填充64字节大小的空间,这样只能确保程序在缓存行大小为32字节或者64字节的CPU下独占缓存行。但是如果CPU的缓存行大小为128字节,这样依然存在False Sharing的问题。

 

引入@Contended注解可以使我们忽略底层硬件设备的差异性,做到Java语言的初衷:平台无关性。

 

@Contended注解默认只是在JDK内部起作用,如果我们的程序代码中需要使用到@Contended注解,那么需要开启JVM参数-XX:-RestrictContended才会生效。

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
    //contention group tag
    String value() default "";
}

@Contended注解可以标注在类上也可以标注在类中的字段上,被@Contended标注的对象会独占缓存行,不会和任何变量或者对象共享缓存行。

 

 • @Contended标注在类上表示该类对象中的实例数据整体需要独占缓存行。不能与其他实例数据共享缓存行。

 

 • @Contended标注在类中的字段上表示该字段需要独占缓存行。

 

 • 除此之外@Contended还提供了分组的概念,注解中的value属性表示contention group 。属于统一分组下的变量,它们在内存中是连续存放的,可以允许共享缓存行。不同分组之间不允许共享缓存行。


下面我们来分别看下@Contended注解在这三种使用场景下是怎样影响字段之间的排列的。

@Contended标注在类上
@Contended
public class FalseSharding {
    volatile long a;
    volatile long b;

    volatile int c;
    volatile int d;
}

当@Contended标注在FalseSharding示例类上时,表示FalseSharding示例对象中的整个实例数据区需要独占缓存行,不能与其他对象或者变量共享缓存行。

 

这种情况下的内存布局:

重磅硬核|一文聊透对象在JVM中的内存布局(三)-鸿蒙开发者社区

 image.png


如图中所示,FalseSharding示例类被标注了@Contended之后,JVM会在FalseSharding示例对象的实例数据区前后填充128个字节,保证实例数据区内的字段之间内存是连续的,并且保证整个实例数据区独占缓存行,不会与实例数据区之外的数据共享缓存行。

 

细心的朋友可能已经发现了问题,我们之前不是提到缓存行的大小为64字节吗?为什么这里会填充128字节呢?

 

而且之前介绍的手动填充也是填充的64字节,为什么@Contended注解会采用两倍的缓存行大小来填充呢?

 

其实这里的原因有两个:

 

1.首先第一个原因,我们之前也已经提到过了,目前大部分主流的CPU缓存行是64字节,但是也有部分CPU缓存行是32字节或者128字节,如果只填充64字节的话,在缓存行大小为32字节和64字节的CPU中是可以做到独占缓存行从而避免FalseSharding的,但在缓存行大小为128字节的CPU中还是会出现FalseSharding问题,这里Java采用了悲观的一种做法,默认都是填充128字节,虽然对于大部分情况下比较浪费,但是屏蔽了底层硬件的差异。

 

不过@Contended注解填充字节的大小我们可以通过JVM参数-XX:ContendedPaddingWidth指定,有效值范围0 - 8192,默认为128

 

 • 第二个原因其实是最为核心的一个原因,主要是为了防止CPU Adjacent Sector Prefetch(CPU相邻扇区预取)特性所带来的FalseSharding问题。

 

CPU Adjacent Sector Prefetch:https://www.techarp.com/bios-guide/cpu-adjacent-sector-prefetch/

 

CPU Adjacent Sector Prefetch是Intel处理器特有的BIOS功能特性,默认是enabled。主要作用就是利用程序局部性原理,当CPU从内存中请求数据,并读取当前请求数据所在缓存行时,会进一步预取与当前缓存行相邻的下一个缓存行,这样当我们的程序在顺序处理数据时,会提高CPU处理效率。这一点也体现了程序局部性原理中的空间局部性特征。

 

当CPU Adjacent Sector Prefetch特性被disabled禁用时,CPU就只会获取当前请求数据所在的缓存行,不会预取下一个缓存行。

 

所以在当CPU Adjacent Sector Prefetch启用(enabled)的时候,CPU其实同时处理的是两个缓存行,在这种情况下,就需要填充两倍缓存行大小(128字节)来避免CPU Adjacent Sector Prefetch所带来的的FalseSharding问题。

 

@Contended标注在字段上

public class FalseSharding {

    @Contended
    volatile long a;
    @Contended
    volatile long b;

    volatile int c;
    volatile long d;
}

重磅硬核|一文聊透对象在JVM中的内存布局(三)-鸿蒙开发者社区

 image.png


这次我们将 @Contended注解标注在了FalseSharding示例类中的字段a和字段b上,这样带来的效果是字段a和字段b各自独占缓存行。从内存布局上看,字段a和字段b前后分别被填充了128个字节,来确保字段a和字段b不与任何数据共享缓存行。

 

而没有被@Contended注解标注字段c和字段d则在内存中连续存储,可以共享缓存行。

 

@Contended分组

public class FalseSharding {

    @Contended("group1")
    volatile int a;
    @Contended("group1")
    volatile long b;

    @Contended("group2")
    volatile long  c;
    @Contended("group2")
    volatile long d;
}

重磅硬核|一文聊透对象在JVM中的内存布局(三)-鸿蒙开发者社区

 image.png


这次我们将字段a与字段b放在同一content group下,字段c与字段d放在另一个content group下。

 

这样处在同一分组group1下的字段a与字段b在内存中是连续存储的,可以共享缓存行。

 

同理处在同一分组group2下的字段c与字段d在内存中也是连续存储的,也允许共享缓存行。

 

但是分组之间是不能共享缓存行的,所以在字段分组的前后各填充128字节,来保证分组之间的变量不能共享缓存行。

标签
已于2022-7-28 17:53:37修改
收藏
回复
举报
回复
    相关推荐