一、伪共享含义
根据《高速缓存》可知,高速缓存中的Cache Line缓存一定数量的字节,而不是只有1个字节。一般情况下,一个Cache Line有32或者64个字节。如果属于不同资源(比如“变量”)的字节被加载到同一个Cache Line,就导致出现了伪共享。
二、伪共享带来的问题
根据以上叙述,伪共享带来问题的本质叙述是:由于Cache Line缓存一定数量的字节,使得一部分原本不处于竞争关系的资源具有了竞争关系,而这最终导致了并发性能下降。
伪共享问题是底层硬件机制问题,故存在于所有程序语言中。
比如:
- 假定变量a和b被加载到同一个Cache Line,在缓存锁定场景中,对a/b变量进行原子操作时,相应的Cache Line被缓存锁定,此时不能再并发修改和读取另外一个变量
- 假定变量a和b被加载到同一个Cache Line,在MESI协议中,对a/b变量修改时,MESI协议要求锁住相应的Cache Line(需要注意的是,“锁住”与“缓存锁定”不同),此时不能再并发修改另外一个变量
三、伪共享带来的问题解决
对于伪共享带来的问题,常见的解决方案有:
- 缓存行填充。缓存行填充的本质原理是,对于需要确保不分配在同一个Cache Line的两个资源,只需在它们中间填充字节,使得最后两者之间的字节距离大于一个Cache Line长度,此时可简单证明必然不会被分配到同一个Cache Line
- 资源对齐。自动调整使得资源在Cache Line边界开始
- …
伪共享问题的存在不可杜绝,解决伪共享问题需要一定成本,比如“Cache Line空间浪费”,“使用额外机制的复杂性引入”等,因此,是否解决伪共享问题需要具体情况具体分析,一般只有当收益远远大于成本时,才需要考虑解决伪共享问题,比如“一个队列数据结构,head和tail指针有大的并发修改概率”。
四、Java中伪共享问题解决
接下来的叙述以64位系统为环境,在64位系统中,一个Cache Line的长度为64字节。
4.1、解决方案
Java中对于伪共享问题的解决方案是“缓存行填充”,常见的具体手段有:“填充冗余变量”和“Java 8中引入的@sun.misc.Contended注解”。
在继续介绍之前,首先须明晰下Java内存结构与Cache Line的关系:
- Java内存结构(包括“栈”、“Java堆”、“方法区”等)跟Cache Line没有关系,Cache Line只跟主存数据块有关
- Java中两个变量是否会被加载到同一个Cache Line只由他们是否被分配到同一个主存数据块决定。围绕Java堆中Java对象(Java对象内存布局参见“备注”)内的变量进行说明:
- 一个Java类A,其内只有一个long变量value,那么类A的实例对象在不考虑“对齐填充字节”的前提下占据24个字节(对象头16个字节,value变量8个字节)。围绕value变量,一个Cache Line最多可以加载3个value变量(1个不完整的类A实例对象,但加载了value部分,8到16个字节;2个完整的类A实例对象共48个字节),也可以是2个value变量,1个value变量,0个value变量
- 一个Java类A,其内只有一个long变量value,一个
m = new A[3]
数组对象m,那么类A的实例对象在不考虑“对齐填充字节”的前提下占据24个字节(对象头16个字节,value变量8个字节),m在不考虑“对齐填充字节”的前提下占据32个字节(对象头20个字节,实例数据是3个引用变量12个字节)。围绕value变量和数组元素引用变量refer,一个Cache Line最多可以加载2个value变量和3个refer变量(1个不完整的类A实例对象,但加载了value部分,8个字节;1个完整的类A实例对象24个字节;1个完整的m数组对象32个字节)
备注:
Java对象的内存布局由3部分组成:
- 对象头。包含3部分:Mark Word(在64位系统中占据8字节),指向类元数据的指针(Klass Pointer,在64位系统中占据8字节),数组长度(在64位系统中占据4字节,只有数组对象才有该部分)
- 实例数据
- 对齐填充字节,Java对象大小要求是8字节的倍数,因此可能需要进行字节填充对齐
4.1.1、填充冗余变量
假定需要隔离的两个变量为a和b,那么可在a和b之间填充冗余变量。
上述方案的缺点是:
- 耦合系统位数。在32和64位系统中,冗余变量填充所需的个数不一样。在32位系统中,Cache Line的长度为32字节,Java对象头所占据字节数分别为“Mark Word(4字节)”,“指向类的指针(4字节)”,“数组长度(4字节,只有数组对象才有该部分)”;在64位系统中,Cache Line的长度为64字节,Java对象头所占据字节数分别为“Mark Word(8字节)”,“指向类的指针(8字节)”,“数组长度(4字节,只有数组对象才有该部分)”
- 一些JDK会智能去掉冗余变量,此时,该方案失效。故跟使用的JDK耦合
4.1.2、Java 8中引入的@sun.misc.Contended注解
通过使用该注解解决伪共享问题的原理是:运行时会分别在前后填充128字节。
注解的修饰目标有两个,分别是“类”和“类内成员变量”:
- 类。在类内首个成员变量前填充128字节,在类内最后一个成员变量后填充128字节
- 类内成员变量。在成员变量前填充128字节,在成员变量后填充128字节
备注:
- 以上填充策略描述只是大体轮廓,还有很多具体细节未涉及
4.2、几个案例
1、案例1
著名的Java并发编程大师Doug lea在JDK 7的并发包里新增了一个队列类LinkedTransferQueue,它的部分代码如下:
1 | /** 队列中的头部节点 */ |
在上述代码中,head和tail分别指向头部节点和尾部节点,不过需要注意的是,实际指向节点的引用变量是其中的value变量。当head和tail指向的对象相邻近时,head的value和tail的value很容易被加载到同一个Cache Line,在并发修改头节点和尾节点十分频繁的场景中,并发性能会大大降低,因此,通过填充冗余变量p0-pe(从理论上来说,其实无需填充15个冗余变量这么多,因为PaddedAtomicReference类的实例对象的对象头占据16个字节)的方式,使得两个value的字节距离必大于64个字节,从而确保两者不会被加载到同一个Cache Line。
2、案例2
高性能队列Disruptor中的RingBuffer类使用填充冗余变量的方式解决伪共享问题。
截取部分代码如下:
1 | public final class RingBuffer<E> extends RingBufferFields<E> implements Cursored, EventSequencer<E>, EventSink<E> { |
3、案例3
接下来进行一个实验,笔者机器的CPU配置是:1CPU,4核心,3.2GHz主频。
实验代码参考自网络:
1 | public final class FalseSharing implements Runnable { |
执行java FalseSharing
,结果如下:
1 | starting.... |
即25秒左右,通过填充5个冗余变量的方式解决伪共享问题,VolatileLong
类修改后的定义如下:
1 | public final static class VolatileLong { |
现在两个实例对象内的value变量字节距离至少为64个字节(对象头16个字节,value变量8个字节,5个冗余变量40个字节),执行java -XX:-UseCompressedOops FalseSharing
(这里需要加上“-XX:-UseCompressedOops”VM参数,否则上述64个字节距离的计算是错误的),结果如下:
1 | starting.... |
即15秒左右,通过填充6个冗余变量的方式解决伪共享问题,VolatileLong
类修改后的定义如下:
1 | public final static class VolatileLong { |
执行java FalseSharing
,结果如下:
1 | starting.... |
即4秒左右,这里有个奇怪的现象就是:理论上来说,填充5个冗余变量的方式跟填充6个冗余变量的方式都解决了伪共享问题,但是后者却比前者大大提升了性能。笔者百思不得其解,请知道的读者不吝赐教。
接下来使用Contended注解的方式解决伪共享问题,VolatileLong
类修改后的定义如下:
1 | public final static class VolatileLong { |
执行java -XX:-RestrictContended FalseSharing
(要想使用“@Contended”注解,须加上“-XX:-RestrictContended”VM参数),结果如下:
1 | starting.... |
参考文献
[1]https://blog.csdn.net/qq_27680317/article/details/78486220
[2]http://ifeve.com/from-javaeye-false-sharing/
[3]http://mail.openjdk.java.net/pipermail/hotspot-dev/2012-November/007309.html
[4]https://blog.csdn.net/lkforce/article/details/81128115