首页>>后端>>java->Java 伪共享的原理深度解析以及避免方法

Java 伪共享的原理深度解析以及避免方法

时间:2023-12-01 本站 点击:0

本文介绍了伪共享的产生以及Java中如何避免伪共享,并配以案例演示!

缓存系统中的缓存是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,因为都会导致同一个缓存行失效而会无意中影响彼此的性能,这就是伪共享(false sharing)。由于从代码中很难看出是否会出现伪共享,有人将其描述成无声的性能杀手。

1 CPU Cache

众所周知,CPU的运算速度远远快于主内存、硬盘的运算速度。为了解决计算机系统中主内存与CPU 之间运行速度差问题,避免直接访问内存或者磁盘,降低因为速度差对CPU整体吞吐量的影响,一般会在CPU与主内存之间添加一级或者多级高速缓冲结构( Cache )。这个Cache 一般是被集成到CPU 内部的,所以也叫CPU Cache。

按照数据读取顺序和与 CPU 结合的紧密程度,CPU 缓存可以分为一级缓存L1,二级缓存L2,部分高端 CPU 还具有三级缓存L3。每一级缓存中所储存的全部数据都是下一级缓存的一部分,越靠近 CPU 的缓存越快也越小。L1和L2只能被一个单独的 CPU 核使用,L3被CPU 核共享,主存由所有 CPU 核共享。

当 CPU 执行运算的时候,它先去 L1 查找所需的数据,再去 L2,然后是 L3,最后如果这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的事,你要确保数据在 L1 缓存中。

引用《深入理解计算机系统》书中关于CPU缓存和内存、硬盘存储器的层次结构:

现代多核架构,一个物理 CPU 可以存在多个物理 Core,而每个 Core 又可以存在多个硬件线程:

2 Cache Line

计算机缓存系统中是以缓存行(cache line)为单位存储的,Cache Line 是 CPU 和主存之间数据传输的最小单位,缓存行通常是 64 字节。

当一行 Cache Line 被从内存拷贝到 Cache 里,Cache 里会为这个 Cache Line 创建一个条目。这个 Cache 条目里既包含了拷贝的内存数据,即 Cache Line,又包含了这行数据在内存里的位置等元数据信息。

当CPU 访问某个变量时,首先会去看CPU Cache 内是否有该变量,如果有则直接从中获取,否则就去主内存里面获取该变量,然后把该变量所在内存区域的一个Cache 行大小的内存复制到Cache 中。由于存放到Cache 行的是内存块而不是单个变量,所以可能会把多个连续的变量存放到一个Cache 行中。

由此,所以通常情况下访问连续存储的数据会比随机访问要快,访问数组结构通常比链结构快,因为通常数组在内存中是连续分配的。注意:一些JVM实现中大数组不是分配在连续空间的。

3 伪共享

如上图,变量x 和y 同时被放到了CPU 的L1、L1、L3,一个core一次只能运行一条线程。

当线程l使用core1对变量x 进行更新时,首先会修改core1的一级缓存变量x 所在的缓存行,这时候在缓存一致性协议(MESI)下,core2中变量x对应的缓存行失效。那么线程2 在写入变量y 时就只能去二级缓存里查找,这就破坏了一级缓存。而一级缓存比二级缓存更快,这也说明了多个线程不可能同时去修改自己所使用的CPU 中相同缓存行里面的变量。更坏的情况是,如果CPU 只有一级缓存,则会导致频繁地访问主内存。

因为缓存与内存交换数据的单位就是缓存行,这就造成了造成多个变量被放入了一个缓存行中,为了保证缓存数据一致性,根据MESI协议,会使其他core相同缓存行数据过期,如果多个线程同时去写入缓存行中不同的变量,虽然明面上不同线程读写不同的数据,但是由于数据在同一缓存行上,造成缓存频繁失效,就会无意中影响彼此的性能,这就是伪共享。由于从代码中很难看出是否会出现伪共享,有人将其描述成无声的性能杀手。

4 避免伪共享

为了避免由于 false sharing 导致 Cache Line 从 L1,L2,L3 到主存之间重复载入,我们可以使用数据填充追加字节的方式来避免,即单个数据填充满一个CacheLine。

该方法本质上是一种空间换时间的做法。

4.1 传统方法

在JDK 8 之前一般都是通过代码手动字节填充的方式来避免该问题,也就是创建一个变量时使用填充字段填充该变量所在的缓存行,这样就避免了将多个变量存放在同一个缓存行中。Doug Lea的jsr166中早期的LinkedTransferQueue。就是使用了追加字节填充来解决伪共享,另外在早起ConcurrentHashMap、以及无锁并发框架Disruptor中均使用这种技术。

早期的LinkedTransferQueue中的部分源码如下:

staticfinalclassPaddedAtomicReference<T>extendsAtomicReference<T>{//追加15个对象引用,一个对象引用占据4个字节Objectp0,p1,p2,p3,p4,p5,p6,p7,p8,p9,pa,pb,pc,pd,pe;PaddedAtomicReference(Tr){super(r);}}//父类publicclassAtomicReference<V>implementsjava.io.Serializable{privatevolatileVvalue;publicAtomicReference(VinitialValue){value=initialValue;}}

这个内部类PaddedAtomicReference相对于父类AtomicReference仅仅做了一件事情,就将共享变量追加到64字节,其中一个对象的引用占4个字节。它追加了15个变量共占60个字节,再加上父类的Value变量,一共64个字节(这其中涉及的对象内存布局:Java中的对象内存布局、压缩指针、对象大小计算以及对象访问定位的详解)。

Doug lea使用追加到64字节的方式来填满快速缓冲区的缓存行。避免头结点和尾节点载入到同一个缓存行,使得头尾节点在改动时不会互相锁定。

4.2 注解

JDK 8开始,提供了一个sun.misc.Contended 注解,用来解决伪共享问题,加上这个注解的类会自动补齐缓存行。jdk8中已经使用sun.misc.Contended的地方:

/**ThecurrentseedforaThreadLocalRandom*/@sun.misc.Contended("tlr")longthreadLocalRandomSeed;/**Probehashvalue;nonzeroifthreadLocalRandomSeedinitialized*/@sun.misc.Contended("tlr")intthreadLocalRandomProbe;/**SecondaryseedisolatedfrompublicThreadLocalRandomsequence*/@sun.misc.Contended("tlr")intthreadLocalRandomSecondarySeed;

Thread 类里面这三个变量默认被初始化为0 ,这三个变量会在ThreadLocalRandom 类中使用。在默认情况下,@Contended 注解只用于Java 核心类, 比如此包下的类。如果用户类路径下的类需要使用这个注解, 则需要添加JVM参数:-XX:-RestrictContended。填充的宽度默认为前后128字节,要自定义宽度则可以设置-XX: ContendPaddingWidth参数。

5 案例

运行下面的案例,可以看见VolatileLong2、VolatileLong3比VolatileLong更块,实际上VolatileLong2的填充变量p0、p1……,多或者少一两个这样的变量对程序影响都很小,我们的目的是让不同的VolatileLong对象处于不同的缓存行,就可以避免伪共享了,多或者少几个字节影响不大。

publicclassFalseSharingimplementsRunnable{publicfinalstaticlongITERATIONS=500L*1000L*1000L;privatefinalstaticintARRAY_INDEX=5;privatefinalstaticVolatileLong3[]LONGS=newVolatileLong3[ARRAY_INDEX];publicstaticvoidmain(finalString[]args){//初始化变量数组for(inti=0;i<LONGS.length;i++){LONGS[i]=newVolatileLong3();}//使用线程池ThreadPoolExecutorthreadPoolExecutor=newThreadPoolExecutor(5,5,0,TimeUnit.SECONDS,newLinkedBlockingQueue<>(),Executors.defaultThreadFactory(),newThreadPoolExecutor.AbortPolicy());longstart=System.nanoTime();for(inti=0;i<ARRAY_INDEX;i++){intfinalI=i;threadPoolExecutor.submit(()->{longi1=ITERATIONS+1;while(0!=--i1){//一个线程,不断地更改指定索引处的值LONGS[finalI].value=i1;}});}//判断线程池任务是否完毕do{threadPoolExecutor.shutdown();}while(!threadPoolExecutor.isTerminated());System.out.println("duration="+(System.nanoTime()-start));}@Overridepublicvoidrun(){longi=ITERATIONS+1;while(0!=--i){//不断地更改索引处的值LONGS[ARRAY_INDEX].value=i;}}//没有填充字节publicfinalstaticclassVolatileLong{publicvolatilelongvalue=0L;}//传统方法publicfinalstaticclassVolatileLong2{//以及基本类型的long占用8个字节volatilelongp0,p1,p2,p3,p4,p5,p6;publicvolatilelongvalue=0L;}//jdk1.8新方法//添加vmoptions参数-XX:-RestrictContended@sun.misc.ContendedpublicfinalstaticclassVolatileLong3{publicvolatilelongvalue=0L;}}

参考资料:

《深入理解计算机系统》

CPU高速缓存那些事儿

Cache Line 伪共享发现与优化

CPU缓存一致性协议MESI


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:/java/5455.html