Java与CPU缓存是如何亲密接触的!
在解释【伪共享】这个概念之前,我们先来运行一段代码,小编的电脑上有4个core。
这个程序的逻辑是4个线程共享同一个数组读写不同下标的变量。每个线程循环1亿次读写,也就是+1操作。然后统计4个线程同时跑完总共花的时间。
下面我们来看看在小编的电脑上运行的结果:
然后我把SharingLong里面的注释代码去掉,再跑了一下:
在性能上注释前后差别高达5比1,为什么会在性能上会产生如此大的差别呢?
这就是本篇要讲的主题【伪共享】,英文名叫False Sharing。而SharingLong里面的注释行一般称之为【缓存行填充】,英文名叫Cache Line Padding。
首先我们来计算一下SharingLong对象占用的内存空间,我们不考虑64位的情景,Java的对象都有一个2个word的头部,第一个word存储对象的hashcode和一些特殊的位标志,如GC的分代年龄、偏向锁标记等,第二个word存储对象的指针地址,一个word就是32位。然后加上v和6个p变量,总共就是8个long的长度,也就是64字节。
接下来我们要引入CPU缓存的概念。
现代的处理器一般都有3级缓存结构,L1、L2和L3,CPU直接访问主存是一个相对比较慢的操作,所以通过3级缓存来提升访存性能。我们将3个缓存当成一个整体来看待,它就是CPU缓存。缓存的制造成本非常昂贵,它一般要比主存空间小的多。
CPU在读主存的时候,会先将主存的一块数据加载到缓存上,然后在缓存上读取。当CPU写主存的时候,它会首先写缓存,在未来的某个时间点再一次性将缓存的数据全部刷回主存,这样就可以提高写操作的性能。因为计算机程序数据操作的局部性,CPU连续的指令倾向于访问相邻地址空间的数据,所以后续的读写操作有很大的概率可以直接在缓存上拿到数据。如果缓存上不存在,那就再去主存上加载进来。
缓存虽然小,但是也不是太小,CPU在加载主存数据时,如果一次性将整个Cache填满,但是接下来的指令访问的数据又不在缓存上,就会导致读浪费。另外如果只修改了其中几个字节的数据,但是得回写整个Cache到内存,这又会导致写浪费。
所以现代的CPU缓存一般是分行存储的,最小处理单位是一个行,这个行的长度一般来说就是上文提到的64字节,我们称之为【缓存行】。
SharingLong对象中v的值是volatile类型的,意味着CPU要保证v变量在不同线程之间的读写可见行。当CPU对v变量进行修改的时候会将数据立即回写至主存并将相应的缓存行置为失效。这样后续对v变量进行的读写操作都需要重新从内存中加载缓存行,这样就保证了其它线程读到的数据是最新的。
这点跟我们平常在Java基础教科书里提到的有点不一样。教科书里面为了便于新手理解,不会提及缓存,一般只会说volatile变量直接读写内存。
如果内存里有两个volatile变量在相邻的地址,两个cpu分别对v1和v2进行读和写操作,会发生什么情况呢?首先我们分解执行动作。图中的h表示对象头。
1、CPU1对v1进行读操作,将内存里的v1加载到缓存行里。
2、CPU2对v2进行读操作,将内存里的v2加载到缓存行里。
3、CPU1对v1进行写操作,将缓存里的v1修改,然后回写到主存再将缓存行置为失效。
4、CPU2对v2进行写操作,将缓存里的v2修改,然后回写到主存再将缓存行置为失效。
步骤1肯定先于步骤3,步骤2肯定先于步骤4。它们发生的顺序可能是 1->2->3->4 ,相当于两个CPU交叠运行,步骤1加载缓存行,步骤2发现数据就在缓存行里还是最新的,就省去了加载缓存行操作了,这时读操作做到了【共享】。紧接着步骤3正常进行写操作,然后步骤4来了,CPU2发现缓存行失效了,所以还得重新加载缓存行,然后再回写到主存再将缓存行置为失效。这里就发生了重复加载缓存行的现象,也即【写竞争】。如果不是volatile变量,步骤3的写操作是不会立即回写内存的,缓存行也就不会立即置为失效,这个时候步骤4来了CPU可以直接对缓存进行写操作,而不会出现浪费现象。我们称这种现象为【伪共享】,就是说这两个变量虽然共享同一个缓存行,但是它们之间会发生写竞争。
如果顺序是1->3->2->4,步骤1和步骤3的读操作这时就没能实现共享,还是会有浪费。
当系统的线程数越多时,写竞争越激烈,这种浪费就越多。
现在我们能明白为什么去掉注释后,程序会变慢,因为存在写竞争现象,数组中相邻的SharingLong.v共享了同一个缓存行。
那加上p1~p6这6个变量的意义是什么呢?我们看图。
我们发现加上6个long变量后,v1和v2将分别占用自己的缓存行,互不干扰,所以写竞争也就不存在了,效率自然就提升了。
不过缺点也是有的,就是缓存的利用率降低了,一个缓存行的空间才使用了1/4。这就是典型的空间换时间的场景。
例子中我们使用了volatile变量,那如果改成普通变量呢?我们运行一下,结果如下。
相当惊人,耗时上居然少了3个量级,这就是volatile在性能上的代价。普通变量不需要保证线程之间的读写的可见性,CPU对缓存修改后不需要立即回写内存,不存在写操作缓存穿透现象。而读操作也不需要总是重新从内存加载,那这个效率几乎完全就是缓存访问的效率,而对volatile变量的读写操作则接近内存访问的效率,差距自然如此明显。
你也许会问,知道这些有什么蛋用!
确是没什么蛋用,因为在现实世界,大部分操作都涉及到IO操作。根据水桶效应,其它环节优化到了极致,也无法提升整体的质量。
但是也不完全所有的应用都是IO操作型的,有一些场景下那是纯粹的内存操作。那么对于纯内存操作来说,理解【伪共享】知识可以帮你从性能上提升几倍甚至是几个数量级。
著名的disruptor框架正是使用了缓存行填充技术,才使得它的环形数组队列能如此高效。看wiki上的性能报告,disruptor的RingBuffer相比Java内置的ArrayBlockingQueue在OPS上高出近一个数量级,在队列延迟上则低了接近3个数量级。