在解释【虚假分享】的概念之前,我们先跑一段代码。我的电脑有4个核心。这个程序的逻辑是4个线程共享同一个数组读写不同下标的变量。每个线程循环1亿次读写,也就是+1操作。然后统计4个线程同时运行的总耗时。看一下小编电脑上运行的结果:然后我把SharingLong中的评论代码去掉,再跑一下:评论前后的性能差距高达5比1,为什么会出现这样的性能差异大?有什么不同?这就是这篇文章【FalseSharing】的主题,英文叫做FalseSharing。SharingLong中的注释行一般称为[cachelinepadding],英文名称为CacheLinePadding。首先我们计算一下SharingLong对象占用的内存空间。我们不考虑64位场景。Java对象有一个header,有2个word,第一个word存储对象的hashcode和一些特殊的位flags,比如GC的分代年龄,偏向锁标记等,第二个word存储对象的指针地址对象,一个字是32位。然后加上v和6个p变量,一共8个long长度,也就是64字节。接下来我们将介绍CPU缓存的概念。现代处理器一般都有三级缓存结构,L1、L2、L3。CPU直接访问主存是一个比较慢的操作,所以通过三级缓存来提高内存访问性能。我们把三个缓存看成一个整体,就是CPU缓存。高速缓存的制造成本很高,而且通常比主存空间小很多。CPU读取主存时,会先从主存中加载一段数据到缓存中,然后再从缓存中读取。CPU在写主存的时候,会先写缓存,在以后的某个时刻,会一次性把缓存的数据全部刷回主存,这样可以提高写操作的性能.由于计算机程序数据操作的局部性,连续的CPU指令往往会访问相邻地址空间中的数据,因此后续的读写操作大概率直接获取到缓存中的数据。如果缓存中不存在,则加载到主存中。缓存虽小,但也不算小。CPU加载主存数据时,如果一下子把整个缓存都填满了,而下一条指令访问的数据却不在缓存中,就会导致读浪费。另外,如果只修改了几个字节的数据,却要将整个Cache写回内存,这样会造成写的浪费。所以现代的CPU缓存一般都是按行存储的,最小的处理单元就是一行。这一行的长度一般就是上面说的64字节,我们称之为【缓存行】。SharingLong对象中v的值是volatile类型,也就是说CPU必须保证v变量可以在不同线程之间读写。当CPU修改v变量后,会立即将数据写回主存,并使相应的缓存行失效。这样后续对v变量的读写操作都需要从内存中重新加载缓存行,从而保证其他线程读取到的数据是最准确的。这和我们平时在Java基础教材中提到的有点不一样。为了方便新手理解,教材上没有提到缓存,一般只说volatile变量直接读写内存。如果内存中相邻地址有两个volatile变量,两个CPU分别读写v1和v2会怎样?首先我们分解执行动作。图中的h代表对象头。1、CPU1读取v1,将内存中的v1加载到cacheline中。2、CPU2读取v2,将内存中的v2加载到cacheline中。3、CPU1写入v1,修改缓存中的v1,写回主存,并使缓存行失效。4、CPU2写入v2,修改缓存中的v2,写回主存,并使缓存行失效。步骤1必须在步骤3之前,步骤2必须在步骤4之前。它们出现的顺序可能是1->2->3->4,相当于两个CPU的重叠运算。step1加载cacheline,step2发现数据在cacheline中或者是最好的,所以省略。为了加载缓存行操作,读取操作是[共享的]。紧接着第3步,写操作正常进行,接着第4步来了。CPU2发现cacheline无效,只好重新加载cacheline,然后回写到主存使cacheline失效。这里就出现了重复加载缓存行的现象,即【写竞争】。如果不是volatile变量,第3步的写操作不会立即回写到内存中,cacheline也不会立即失效。这时CPU可以在第4步到来时直接写入缓存,不会出现浪费现象。我们称这种现象为【falsesharing】,意思是这两个变量虽然共享同一个cacheline,但是它们之间会存在写竞争。如果顺序是1->3->2->4,此时第1步和第3步的读操作是不能共享的,还是会出现浪费。当系统中的线程数增加时,写入的竞争变得更加激烈,浪费也随之增加。现在我们可以理解为什么去掉注释后程序会变慢了,因为存在写竞争现象,数组中相邻的SharingLong.v共享同一个缓存行。p1~p6这六个变量相加有什么意义?让我们看一下图片。我们发现加入6个long变量后,v1和v2会分别占用各自的cacheline,互不干扰,不会出现写竞争,效率自然会提高。但是也有缺点,就是降低了缓存的利用率,一个缓存行的空间只被使用了1/4。这是一个典型的以空间换时间的场景。例子中我们使用了volatile变量,如果改成普通变量呢?我们运行一下,结果如下。相当惊人,耗时居然减少了3个数量级,这是volatile在性能上的代价。普通变量不需要保证线程间读写的可见性。CPU修改缓存后,不需要立即回写到内存中,不存在写操作的缓存穿透现象。并且读操作不需要一直从内存中重新加载,所以这个效率几乎完全是访问缓存的效率,而对volatile变量的读写操作接近内存访问的效率,差距自然是那么明显。你可能会问,知道这些有什么用!真的没什么用,因为在现实世界中,大部分操作都涉及到IO操作。根据水桶效应,其他环节即使优化到极致,也无法提升整体质量。但并不是所有的应用都是IO操作,有些场景是纯内存操作。所以对于纯内存操作,理解【伪共享】知识可以帮助你提高数倍甚至数个数量级的性能。著名的disruptor框架使用cachelinefilling技术使其循环数组队列如此高效。看wiki上的性能报告,disruptor的RingBuffer在OPS上比Java内置的ArrayBlockingQueue高了近一个数量级,队列延迟低了近三个数量级。
