ConcurrentAssassin(FalseSharing)-并发程序的隐藏杀手冰淇淋的价格很高!其实并发程序中也有刺客。如果写并发程序的时候不小心,这个刺客很有可能会拖累我们的并发程序,降低我们并发程序的执行效率,让并发程序付出高昂的代价,这与《冰淇淋刺客》中“刺客”的含义是一致的。这个并发程序中的杀手是——FalseSharing。伪共享(FalseSharing)缓存行当CPU从较慢的缓存中读取数据时(三级缓存会从内存中读取数据,二级缓存会从三级缓存中读取数据,而一级缓存会从二级缓存中的Readdata中读取数据,缓存级别越低,执行速度越快),CPU并不是逐字节读取,而是一次读取一条数据,然后将这段数据缓存在CPU,而这段数据称为缓存行。有一个cacheline,它的大小是64字节,那我们为什么要做这样的优化呢?这是因为局部性原则。所谓局部性原则,简单的说就是当你当时使用了一条数据,以后很可能会使用到它附近的数据。比如我们在遍历数组的时候,通常是从前到后进行遍历,比如我们数组里面的数据大小是8个字节,如果我们的缓存行是64字节,那么一个缓存行可以缓存8个数据,那么当我们遍历第一个数据时,我们会将这8个数据加载到缓存行中,那么我们在遍历接下来的7个数据时就不需要从内存中获取数据了,直接从缓存中获取即可,这样可以节省程序执行的时间。伪共享当两个线程在CPU的两个不同核上执行代码时,如果两个线程使用同一个缓存行C,并在这个缓存行中写入两个不同的变量,比如线程A写入变量a,线程B写入到变量b。由于缓存一致性(Cachecoherence)协议的存在,如果线程A写入缓存行C中的变量a,为了保证每个CPU核的数据一致(即两个CPU核看到数据a的值是一样的,因为a的值变了,需要让其他cpu核知道,否则其他cpu核会用旧值,那程序结果就错了),缓存其他核心的line都会Invalid,如果他还想用这个cacheline,就需要重新加载三级缓存。如果数据在三级缓存中不存在,就会从内存中加载,这个重新加载的过程会极大地阻碍程序的执行效率,但实际上是线程A写变量a,线程B写变量b。它们并没有真正共享数据,而是它们需要的数据在同一个缓存行中,所以这种现象称为虚假共享。(虚假分享)。上文我们提到,当缓存行失效时,会从三级缓存或内存中加载,多个不同的CPU核心共享三级缓存(如上图所示),其中一个CPU核心已更新。数据会被刷新到三级缓存或内存中,所以此时其他CPU核心加载数据时,就是新的值。上面提到的关于CPU缓存一致性(Cachecoherence)的内容还是比较少的。如果想深入了解缓存一致性(Cachecoherence)和缓存一致性协议,可以仔细阅读这篇文章。举个更具体的例子:假设在内存中,变量a和变量b都占用四个字节,它们的内存地址是连续且相邻的。现在有两个线程A和B,线程A需要不断对变量a进行+1操作,线程B需要不断对变量进行+1操作。现在这两个数据所在的缓存行已经缓存到三级缓存中了。线程A从L3缓存加载数据到L2缓存和L1缓存然后在CPU-Core0中执行代码,线程B从L3缓存加载数据到L2缓存和L1缓存然后在CPU-Core1中执行代码。线程A不断执行a+=1,因为线程B缓存的缓存行中包含数据a,线程A修改a的值后,会在总线上发送消息让其他处理器包含变量a的缓存行失效,处理器使cacheline失效后,会在总线上发送消息,表示cacheline已经失效。线程A所在的CPU-Core0收到消息后,会将更新后的数据刷新到三级缓存中。此时线程B所在的CPU-Core1中包含a的cacheline已经失效,因为变量b和变量a在同一个cacheline中,现在线程B想给变量b加1,但是在一级和二级缓存已经不存在了,需要在三级缓存中加载这一行缓存,如果三级缓存中没有,则需要在内存中加载。如果仔细分析上面的过程,你会发现线程B并没有对变量a进行任何操作,而是它需要的缓存行失效了。虽然它和线程B共享了相同内容的缓存行,但是它们之间并没有真正的共享数据,所以这种现象称为假共享。Java代码重现假共享重现假共享下面是两个线程连续对两个变量进行++操作的代码:classData{publicvolatilelonga;publicvolatilelongb;}publicclassFalseSharing{publicstaticvoidmain(String[]args)throwsInterruptedException{Datadata=newData();长启动=System.currentTimeMillis();线程A=newThread(()->{for(inti=0;i<500_000_000;i++){data.a+=1;}},"A");线程B=newThread(()->{for(inti=0;i<500_000_000;i++){data.b+=1;}},"B");A.开始();B.开始();A.加入();B.join();长端=System.currentTimeMillis();System.out.println("花费的时间是:"+(end-start));System.out.println(data.a);System.out.println(data.b);}}上面的代码比较简单,这里就不多解释了。我笔记本上的执行时间约为17秒。在上面的代码中,变量a和变量b在内存中的位置是相邻的。它们被CPU加载后,会在同一个cacheline中。所以会出现伪共享的问题,程序的执行时间会变长。下面的代码是优化后的代码。在变量a前后添加56字节的数据,加上a的8字节(long型是8字节),所以在a前后添加a的数据数据有64字节,目前主流的缓存行是64bytes,足够一个cacheline的大小,因为数据a和datab不会在同一个cacheline,所以不会出现falsesharing的问题。以下代码在我的笔记本中执行大约需要5秒。这足以看出虚假共享对程序执行的影响有多大。类数据{publicvolatilelonga1,a2,a3,a4,a5,a6,a7;publicvolatilelonga;publicvolatilelongb1,b2,b3,b4,b5,b6,b7;publicvolatilelongb;FalseSharing{publicstaticvoidmain(String[]args)throwsInterruptedException{数据数据=newData();长启动=System.currentTimeMillis();线程A=newThread(()->{for(inti=0;i<500_000_000;i++){data.a+=1;}},"A");线程B=newThread(()->{for(inti=0;i<500_000_000;i++){data.b+=1;}},"B");A.开始();B.开始();A.加入();B.join();长端=System.currentTimeMillis();("花费的时间是:"+(end-start));System.out.println(data.a);System.out.println(data.b);}}JDK解决虚假共享问题为了解决虚假共享问题,JDK为我们提供了一个注解@Contened来解决虚假共享问题。importsun.misc.Contended;classData{//publicvolatilelonga1,a2,a3,a4,a5,a6,a7;@Contendedpublicvolatilelonga;//publicvolatilelongb1,b2,b3,b4,b5,b6,b7;@Contendedpublicvolatilelongb;}publicclassFalseSharing{publicstaticvoidmain(String[]args)throwsInterruptedException{Datadata=newData();长启动=System.currentTimeMillis();线程A=newThread(()->{for(longi=0;i<500_000_000;i++){data.a+=1;}},"A");线程B=newThread(()->{for(longi=0;i<500_000_000;i++){data.b+=1;}},"B");A.开始();B.开始();A.加入();B.join();长端=System.currentTimeMillis();System.out.println("花费时间:"+(end-start));System.out.println(data.a);System.out.println(数据.b);}}上面代码的执行时间也是5秒左右,和之前在变量左右两边插入变量的效果是一样的,但是JDK提供的接口和我们自己的实现还是有区别的。(注:以上代码是在JDK1.8下执行的,如果想让@Contended注解生效,还需要在JVM参数中加上-XX:-RestrictContended,这样上面的代码才能生效,否则不能生效)在我们自己的解决伪共享的代码中,在变量a的左右两边加了56个字节的其他变量,让他和变量b不在同一个缓存行。JDK提供给我们的注解@Contended就是在被注解的字段右边添加一定数量的空字节。默认添加128个空字节,所以变量a和变量b之间的内存地址更大,最后不在同一个cacheline中。这个字节数可以使用JVM参数-XX:ContendedPaddingWidth=64来控制,比如这个是64字节。另外,@Contended注解还可以对变量进行分组:classData{@Contended("a")publicvolatilelonga;@Contended("bc")publicvolatilelongb;@Contended("bc")publicvolatilelongc;}在解析注解时,同一组的变量在内存中会相邻,不同组之间会有一定数量的空字节。配置方法同上,每组之间默认间隔字节数为128。例如,上述变量在内存中的逻辑布局详细如下:OFFSETSIZETYPEDESCRIPTIONVALUE04(objectheader)01000000(00000001000000000000000000000000)(1)44(objectheader)00000000(000000000000000000000000)(0)84(对象标头)200A0600(001000001010100000011000000000)(395808)12132(对齐/填充间隙)14481LongData2.Alignment128longData.b02888longData.c0296128(由于下一个对象对齐而丢失)Instancesize:424bytesSpacelosses:260bytesinternal+128bytesexternal=388bytestotal以上内容由下面打印code是的,你只需要在pom文件中导入包jol:importorg.openjdk.jol.info.ClassLayout;importsun.misc.Contended;classData{@Contended("a")publicvolatilelonga;@Contended("bc")publicvolatilelongb;@Contended("bc")publicvolatilelongc;}publicclassFalseSharing{publicstaticvoidmain(String[]args)throwsInterruptedException{Datadata=newData();System.out.println(ClassLayout.parseInstance(data).toPrintable());}}从更底层的C语言看虚假共享我们使用Java语言来验证虚假共享。本节我们使用C语言的多线程程序(使用pthread)来验证伪共享。(以下代码在类Unix系统中可以执行)#include
