缓存行填充与@sun.misc.Contended注解
1.缓存模型
CPU和主内存之间有好几层缓存,因为与cpu的速度相比,访问主内存的速度是非常慢的。如果频繁对同一个数据做运算,每次都从内存中加载,运算完之后再写回到主内存中,将会严重拖累cpu的计算资源。因此,为了充分发挥CPU的计算性能和吞吐量,平衡CPU和主内存之间的速度差距,现代CPU引入了一级缓存、二级缓存和三级缓存,结构如下图所示:
越靠近CPU的缓存存储速度越快,但是容量也越小。所以一级缓存(L1 Cache)最小最快,并且紧靠着在使用它的CPU内核。L2大一些,也慢一些,并且仍然只能被一个单独的 CPU 核使用。L3在现代多核机器中更普遍,仍然更大,更慢,并且被单个插槽上的所有 CPU 核共享。最后,你拥有一块主存,由全部插槽上的所有 CPU 核共享。
当CPU运算过程中需要加载数据时,首先去L1去寻找需要的数据,如果没有则去L2寻找,接着从L3中寻找,如果都没有,则从内存中读取数据。所以,如果某些数据需要经常被访问,那么这些数据存放在L1中的效率会最高。
而一般缓存未命中消耗的时钟周期及时间数据如下:
2.缓存行Cache Line
缓存是由缓存行组成的。一个缓存行存储字节的数量为2的倍数,在不同的机器上,缓存行大小为32字节到256字节不等,目前通常为64字节。
缓存行是按块加载到缓存中,一次性从内存中加载或者写入64字节的内容。假如一个Java对象有8个long类型的成员变量,那么一个缓存行最多可以一次性加载这8个long类型的变量到内存中。那么cpu在运算中,如果需要这8个变量,就可以直接从缓存中读取数据,而不需要从主内存中加载。
3.缓存一致性协议
由于一级缓存和二级缓存是被CPU核单独拥有的,当CPU核修改缓存中的内容时,缓存需要写回主内存,然后通知其他CPU核中对该缓存块进行失效处理。例如Core1和Core2并发读写同一个对象,该对象所属的内存地址块,会被同时缓存到一级缓存中。当Core1修改这个对象的变量时,改动会写回主内存,并且会让Core2缓存的该对象的缓存行失效。
4.伪共享(False Sharing)
伪共享,翻译为错误的共享。下面的图说明了伪共享的问题:
1.假设Core1更新了X值,那么缓存中的缓存行和内存中的值都会被改变,并且Core2的缓存行也都会失效,因为它缓存的X
不是最新值了。那么Core2仅仅只是想读X值,却需要重新从主内存去加载,从而被缓存未命中拖慢了读取速度。
2.假设在核心1上运行的线程想更新变量X,同时核心2上的线程想要更新变量Y, 而这两个变量在同一个缓存行中。每个线程都要去竞争缓存行的所有权来更新变量。如果Core1获得了所有权,改变了X值,那么Core2中对应的缓存行失效。而Core2去读取Y时,发现缓存未命中,需要重新读取缓存行。然后当Core2去更新Y值时,也会使Core1的缓存行失效,从而使Core1引发一次缓存未命中。这会来来回回的经过L3缓存,大大影响了性能。如果互相竞争的核心位于不同的插槽,就要额外横跨插槽连接,问题可能更加严重。
5.伪共享解决办法
1.缓存行填充
disrupter框架通过增加字段,补全缓存行来确保ring buffer的序列号不会和其他东西同时存在于一个缓存行中,从而解决伪共享的问题:
private long p1, p2, p3, p4, p5, p6, p7;
private final long indexMask = 0;
private long p8, p9, p10, p11, p12, p13, p14;
2.@sun.misc.Contended注解
在Java 8中,提供了@sun.misc.Contended注解来避免伪共享,原理是在使用此注解的对象或字段的前后各增加128字节大小的padding,使用2倍于大多数硬件缓存行的大小来避免相邻扇区预取导致的伪共享冲突。
/** The current seed for a ThreadLocalRandom */
@sun.misc.Contended("tlr")
long threadLocalRandomSeed;
/** Probe hash value; nonzero if threadLocalRandomSeed initialized */
@sun.misc.Contended("tlr")
int threadLocalRandomProbe;
/** Secondary seed isolated from public ThreadLocalRandom sequence */
@sun.misc.Contended("tlr")
int threadLocalRandomSecondarySeed;
例如,Thread中有三个变量threadLocalRandomSeed,threadLocalRandomProbe,threadLocalRandomSecondarySeed就是通过注解的方法来操作缓存行的。不同的是,这里是让三个变量处于同一个缓存行,从而达到任意使一个变量改变,其他两个值也必须重新加载的目的。
6总结
1.缓存时为了平衡CPU和内存之间的速度差异,cpu核独自拥有L1、L2缓存,公用L3缓存,且越靠近CPU的缓存存储速度越快、容量越小。
2.缓存中的内容是按块一次性从主内存中加载到缓存行。
3.L1、L2缓存中的缓存行修改时,会写回到L3和主内存,并且使其他核上的还缓存行失效。
4.多个CPU核一写一读,或者同时写同一块缓存行的时候,会导致伪共享问题。
5.假如存在多个线程同时修改一个类的不同字段的场景,必须考虑使用缓存行填充或者@sun.misc.Contended注解防止伪共享。
参考文章:
Java8使用@sun.misc.Contended避免伪共享
剖析Disruptor:为什么会这么快?(二)神奇的缓存行填充