原文:False Shareing && Java 7 (依然是马丁的博客) 译者:杨帆 校对:方腾飞
在我前一篇有关伪共享的博文中,我提到了可以加入闲置的long字段来填充缓存行来避免伪共享。但是看起来Java 7变得更加智慧了,它淘汰或者是重新排列了无用的字段,这样我们之前的办法在Java 7下就不奏效了,但是伪共享依然会发生。我在不同的平台上实验了一些列不同的方案,并且最终发现下面的代码是最可靠的。(译者注:下面的是最终版本,马丁在大家的帮助下修改了几次代码)
import java.util.concurrent.atomic.AtomicLong; public final class FalseSharing implements Runnable { public final static int NUM_THREADS = 4; // change public final static long ITERATIONS = 500L * 1000L * 1000L; private final int arrayIndex; private static PaddedAtomicLong[] longs = new PaddedAtomicLong[NUM_THREADS]; static { for (int i = 0; i < longs.length; i++) { longs[i] = new PaddedAtomicLong(); } } public FalseSharing(final int arrayIndex) { this.arrayIndex = arrayIndex; } public static void main(final String[] args) throws Exception { final long start = System.nanoTime(); runTest(); System.out.println("duration = " + (System.nanoTime() - start)); } private static void runTest() throws InterruptedException { Thread[] threads = new Thread[NUM_THREADS]; for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(new FalseSharing(i)); } for (Thread t : threads) { t.start(); } for (Thread t : threads) { t.join(); } } public void run() { long i = ITERATIONS + 1; while (0 != --i) { longs[arrayIndex].set(i); } } // 这段代码的来历可以看4楼的回复 public static long sumPaddingToPreventOptimisation(final int index) { PaddedAtomicLong v = longs[index]; return v.p1 + v.p2 + v.p3 + v.p4 + v.p5 + v.p6; } public static class PaddedAtomicLong extends AtomicLong { public volatile long p1, p2, p3, p4, p5, p6 = 7L; } }
用以上这种办法我获得了和上一篇博客里提到的相近的性能,读者可以把PaddedAtomicLong里面那行填充物注释掉再跑测试看看效果。
我想我们大家都有权去跟Oracle投诉,让他们在JDK里默认加入缓存行对齐的函数或者是被填充好的原子类型,这和其他一些底层改变会让Java成为一门真真正正的并发编程语言。我们一直以来不断的在听到他们讲多核时代正在到来,但是我要说的是在这方面Java需要快点赶上来。
———————————————–
(译者注:博文后面的评论和交流也很精彩,也讲述了这段示例代码的进化过程,一起翻译出来:)
1楼:Ashwin Jayaprakash
在前一篇博文中你创建了一个数组来放VolatileLong,这次你又用一个数组放AtomicLongArray(译者注:此处我觉得他可能是写错了,应该是说AtomicLong吧)。
但是如何能保证AtomicLongArray或VolatileLong会被紧挨着分配在内存中?
那么,就算你在一个循环中创建他们,并且很幸运的,他们获得了连续的内存空间,但是依然无法保证这四个实例会在堆空间里紧挨着。如果他们被分布在JVM的旧生代堆里并且没有被压实的话,直到一次主要GC压实旧生代之前,重新分配填充是没必要的,因为他们在堆中是分散的。
所以你最好对读者说明,我们无法控制JVM如何在堆中对这些实例分配内存。(译者注:没办法,要精确控制内存来保证性能的话就不要用Java了,要不直接用C好了)
———————————————–
2楼:马丁
你大体上说的是对的,Ashwin,我们无法保证如何在堆空间中放置Java对象,这是伪共享问题发生的根源。如果你有一些跨线程的指针或者计数器,那么确保他们在不同的缓存行中是非常重要的,否则的话程序就无法按CPU的核数扩展。填充的根本意义在于保护这些跨线程的指针和计数器,以确保他们在不同的缓存行中。
这个是有意义的吧?
———————————————–
3楼:Ashwin Jayaprakash
嗯,有道理。那你可不可以创建一个大的AtomicLongArray,然后让不同的线程去更新第8,16,32个元素呢?(译者注:也算是消除竞争的一个办法,但是既然完全没有竞争还要多线程做什么?)而不是搞四个AtomicLongArray,而每个线程都去竞争访问同一个数组元素。
谢谢马丁花时间写了这么多。
———————————————–
4楼:马丁
如果我可以提前知道更多的业务逻辑那么你说的方式是可行的。但通常情况下在设计一个大型系统的时候,我们无法提前知道很多事情,或者我们要为其他的应用创造一个通用的类库。
我很难为很多不同的上下文场景写一个足够小巧简单的示例,而我上面写的示例是为了说明当伪共享发生的时候有多糟糕。如果你在你的数据结构中做了填充,那么你就不必担心他们在内存中如何分配。我们用一个更好的方案来替代AtomicLong,并且你可以使用AtomicLong的所有常规方法:
static class PaddedAtomicLong extends AtomicLong
{
public volatile long p1, p2, p3, p4, p5, p6, p7 = 7L;
}
我是多希望Java委员会可以认识到这个问题的严重性,并且在JDK里加入对缓存行对齐和填充的基础方法。这是在Disruptor中有关性能BUG的最大根源。
我也根据以上的反馈更新了文章。
———————————————–
5楼:Gil Tene
马丁,我很同意你的观点,如果我们有一种办法可以指定某个字段占有独自的缓存行,并且让JVM自动处理如何在对象布局上的正确填充,那这个世界会和谐的多。你搞的这个人造填充将会是很美好的一个事情,但是你也知道,实际上的对象布局情况要取决于JVM的特定实现。
我是一个偏执狂,我给你的填充方案里加了一些东西,使那些个用于填充的字段很难被JVM优化掉。一个耍小聪明的JVM还是会把你用于填充的P1-P7的字段优化掉,原理是这样滴:PaddedAtomicLong类如果只对final的FalseSharing类可见(就是说PaddedAtomicLong不能再被继承了)。这样一来编译器就会“知道”它正在审视的是所有可以看到这个填充字段的代码,这样就可以证明没有行为依赖于p1到p7这些字段。那么“聪明”的JVM会把上面这些丝毫不占地方的字段统统优化掉。
那么针对这样的情况,你可以巧妙的让PaddedAtomicLong类在FalseSharing类之外可见,比如直接加一个依赖于p1到p7的公开的访问函数,并且这个函数在理论上可以被外界访问到。
———————————————–
6楼:马丁
我根据Gil的反馈做了修改。
———————————————–
7楼:Stanimir Simeonoff
直接用一个数组并且把元素放在中间的位置上(或者直接用bytebuffer,而你却为了这个写了这么一大篇),Java是不会重排他们的,我就是这样来消除伪共享的。
———————————————–
8楼:马丁
我以前经常像你这么干,比如搞一个长度是15的数组,把元素放在正中间,但是,如果我们需要volatile这个语意就行不通了。对于你的情况来说,你只需要用AtomicLongArray或者类似的。根据我的测量,在一个算法中,这种间接引用(译者注:原词是indirection,我理解也许是指间接引用,即不是直接使用数组,而是使用AtomicLongArray这种包装过的数组)和边界检查的消耗是显著的。
据我所知,一些人建议加入@Contened注解来标记一个字段,让这个被标记的字段拥有独立的缓存行,我希望这个快点到来。
8.1楼:John
你好,马丁,我看到在Disruptor当前的版本中Sequence类用的是unsafe.compareAndSwapLong(..)来更新第七个下标的long。
为什么不数组的长度不是15或者是其他的数值?如果长度是15的话会把2级缓存的缓存行也填充掉么?
谢谢。
8.2楼:马丁
因为用7个下标保证了会有56个字节填充在数值的任何一边,56字节的填充+8字节的long数值正好装进一行64字节的缓存行。
———————————————–
9楼:Stanimir Simeonoff
是的,马丁,我指的就是AtomicLongArray,如果你不想为间接引用和边界检查买单,Unsafe 是一个选项(甚至总是这样)。
———————————————–
10楼:Mohan Radhakrishnan
哪里有一些简单硬件说明书是讲述缓存行的关键概念么?我想找一些插图什么的来理解核心和缓存直接如何交互造成了伪共享。
———————————————–
11楼:马丁
你可以参照下面这个PDF的第3和第4章:
http://img.delivery.net/cm50content/intel/ProductLibrary/100412_Parallel_Programming_02.pdf
———————————————–
12楼:ying
你的博客太NB了,多谢马丁,我关于填充有两个疑问:
1.long占8字节,对象引用占16字节,但是这个实现是
public final static class VolatileLong // 16byte</pre> { public volatile long value = 0L; // 8 byte public long p1, p2, p3, p4, p5, p6; // 6*8 = 48byte }
看起来好像是72个字节啊。
2.你是发现这个问题的?是去查汇编代码吗?
12.1楼:马丁
我不希望在缓存行中的标记字在取出锁或者垃圾回收器在老化对象的时候被修改。
就算默认启用64位模式的压缩指针,它还是会包含类指针在对象的头部。
https://wikis.oracle.com/display/HotSpotInternals/CompressedOops
这个伪共享的问题我是在多年前发现的,但是我为一个应用做性能测试,发现性能时高时低,追查原因下去发现是伪共享问题。
12.2楼:ying
那你是如何缩小问题的范围最后发现问题的呢?需要深入分析汇编代码么?
11.3楼:马丁
汇编代码是不会显示出问题的,你需要去追查为什么CPU的2级缓存总是不命中,追查下去就知道了。
———————————————–
13楼:Joachim
关于@Contended注解的提案在这里:
http://mail.openjdk.java.net/pipermail/hotspot-dev/2012-November/007309.html
牛文啊,赞!
原创文章,作者:kepupublish,如若转载,请注明出处:https://blog.ytso.com/141085.html