我们从上节知道, 写操作的代价很高, 特别当需要发送RFO消息时. 我们编写程序时, 什么时候会发生RFO请求呢? 有以下两种:
1. 线程的工作从一个处理器移到另一个处理器, 它操作的所有缓存行都需要移到新的处理器上. 此后如果再写缓存行, 则此缓存行在不同核上有多个拷贝, 需要发送RFO请求了.
2. 两个不同的处理器确实都需要操作相同的缓存行
由上一篇我们知道, 在Java程序中,数组的成员在缓存中也是连续的. 其实从Java对象的相邻成员变量也会加载到同一缓存行中. 如果多个线程操作不同的成员变量, 但是相同的缓存行, 伪共享(False Sharing)问题就发生了. 下面引用Disruptor项目Lead的博文中的示例图和实验例子(偷会懒,但会加上更详细的profile方法).

一个运行在处理器core 1上的线程想要更新变量X的值, 同时另外一个运行在处理器core 2上的线程想要更新变量Y的值. 但是, 这两个频繁改动的变量都处于同一条缓存行. 两个线程就会轮番发送RFO消息, 占得此缓存行的拥有权. 当core 1取得了拥有权开始更新X, 则core 2对应的缓存行需要设为I状态. 当core 2取得了拥有权开始更新Y, 则core 1对应的缓存行需要设为I状态(失效态). 轮番夺取拥有权不但带来大量的RFO消息, 而且如果某个线程需要读此行数据时, L1和L2缓存上都是失效数据, 只有L3缓存上是同步好的数据.从前一篇我们知道, 读L3的数据非常影响性能. 更坏的情况是跨槽读取, L3都要miss,只能从内存上加载.
表面上X和Y都是被独立线程操作的, 而且两操作之间也没有任何关系.只不过它们共享了一个缓存行, 但所有竞争冲突都是来源于共享.
实验及分析
引用Martin的例子, 稍做修改,代码如下:
01 | public final class FalseSharing implements Runnable { |
02 | public static int NUM_THREADS = 4 ; // change |
03 | public final static long ITERATIONS = 500L * 1000L * 1000L; |
04 | private final int arrayIndex; |
05 | private static VolatileLong[] longs; |
07 | public FalseSharing( final int arrayIndex) { |
08 | this .arrayIndex = arrayIndex; |
11 | public static void main( final String[] args) throws Exception { |
13 | System.out.println( "starting...." ); |
14 | if (args.length == 1 ) { |
15 | NUM_THREADS = Integer.parseInt(args[ 0 ]); |
18 | longs = new VolatileLong[NUM_THREADS]; |
19 | for ( int i = 0 ; i < longs.length; i++) { |
20 | longs[i] = new VolatileLong(); |
22 | final long start = System.nanoTime(); |
24 | System.out.println( "duration = " + (System.nanoTime() - start)); |
27 | private static void runTest() throws InterruptedException { |
28 | Thread[] threads = new Thread[NUM_THREADS]; |
29 | for ( int i = 0 ; i < threads.length; i++) { |
30 | threads[i] = new Thread( new FalseSharing(i)); |
32 | for (Thread t : threads) { |
35 | for (Thread t : threads) { |
41 | long i = ITERATIONS + 1 ; |
43 | longs[arrayIndex].value = i; |
47 | public final static class VolatileLong { |
48 | public volatile long value = 0L; |
49 | public long p1, p2, p3, p4, p5, p6; // 注释 |
代码的逻辑是默认4个线程修改一数组不同元素的内容. 元素的类型是VolatileLong, 只有一个长整型成员value和6个没用到的长整型成员. value设为volatile是为了让value的修改所有线程都可见. 在一台Westmere(Xeon E5620 8core*2)机器上跑一下看
把以上代码49行注释掉, 看看结果:
两个逻辑一模一样的程序, 前者只需要9秒, 后者跑了将近一分钟, 这太不可思议了! 我们用伪共享(False Sharing)的理论来分析一下. 后面的那个程序longs数组的4个元素,由于VolatileLong只有1个长整型成员, 所以整个数组都将被加载至同一缓存行, 但有4个线程同时操作这条缓存行, 于是伪共享就悄悄地发生了. 读者可以测试一下2,4,8, 16个线程分别操作时分别是什么效果, 什么样的趋势.
那么怎么避免伪共享呢? 我们未注释的代码就告诉了我们方法. 我们知道一条缓存行有64字节, 而Java程序的对象头固定占8字节(32位系统)或12字节(64位系统默认开启压缩, 不开压缩为16字节), 详情见 链接. 我们只需要填6个无用的长整型补上6*8=48字节, 让不同的VolatileLong对象处于不同的缓存行, 就可以避免伪共享了(64位系统超过缓存行的64字节也无所谓,只要保证不同线程不要操作同一缓存行就可以). 这个办法叫做补齐(Padding).
如何从系统层面观察到这种优化是切实有效的呢? 很可惜, 由于很多计算机的微架构不同, 我们没有工具来直接探测伪共享事件(包括Intel Vtune和Valgrind). 所有的工具都是从侧面来发现的, 下面通过Linux利器OProfile来证明一下. 上面的程序的数组只是占64 * 4 = 256字节, 而且在连续的物理空间, 照理来说数据会在L1缓存上就命中, 肯定不会传入到L2缓存中, 只有在伪共享发生时才会出现. 于是, 我们可以通过观察L2缓存的IN事件就可以证明了,步骤如下:
02 | $ sudo opcontrol --setup --event=L2_LINES_IN:100000 |
04 | $ sudo opcontrol --reset |
06 | $ sudo opcontrol --start |
10 | $ sudo opcontrol --dump |
14 | $ opreport -l `which java` |
比较一下两个版本的结果, 慢的版本:
1 | $ opreport -l `which java` |
2 | CPU: Intel Westmere microarchitecture, speed 2400.2 MHz (estimated) |
3 | Counted L2_LINES_IN events (L2 lines alloacated) with a unit mask of 0x07 (any L2 lines alloacated) count 100000 |
4 | samples % image name symbol name |
5 | 34085 99.8447 anon (tgid:18051 range:0x7fcdee53d000-0x7fcdee7ad000) anon (tgid:18051 range:0x7fcdee53d000-0x7fcdee7ad000) |
6 | 51 0.1494 anon (tgid:16054 range:0x7fa485722000-0x7fa485992000) anon (tgid:16054 range:0x7fa485722000-0x7fa485992000) |
7 | 2 0.0059 anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000) anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000) |
快的版本:
1 | $ opreport -l `which java` |
2 | CPU: Intel Westmere microarchitecture, speed 2400.2 MHz (estimated) |
3 | Counted L2_LINES_IN events (L2 lines alloacated) with a unit mask of 0x07 (any L2 lines alloacated) count 100000 |
4 | samples % image name symbol name |
5 | 22 88.0000 anon (tgid:18873 range:0x7f3e3fa8a000-0x7f3e3fcfa000) anon (tgid:18873 range:0x7f3e3fa8a000-0x7f3e3fcfa000) |
6 | 3 12.0000 anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000) anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000) |
慢的版本由于False Sharing引发的L2缓存IN事件达34085次, 而快版本的为0次.
总结
伪共享在多核编程中很容易发生, 而且比较隐蔽. 例如, 在JDK的LinkedBlockingQueue中, 存在指向队列头的引用head和指向队列尾的引用last. 而这种队列经常在异步编程中使有,这两个引用的值经常的被不同的线程修改, 但它们却很可能在同一个缓存行, 于是就产生了伪共享. 线程越多, 核越多,对性能产生的负面效果就越大.
某些Java编译器会将没有使用到的补齐数据, 即示例代码中的6个长整型在编译时优化掉, 可以在程序中加入一些代码防止被编译优化。
1 | public static long preventFromOptimization(VolatileLong v) { |
2 | return v.p1 + v.p2 + v.p3 + v.p4 + v.p5 + v.p6; |
另外, 由于Java的GC问题. 数据在内存和对应的CPU缓存行的位置有可能发生变化, 所以在使用pad的时候应该注意GC的影响.
最后感谢同事撒迦, 长仁在Java对象内存布局及Profile工具上给予的帮助.
2012年4月19日更新:
发现netty和grizzly的代码中的LinkedTransferQueue中都使用了PaddedAtomicReference<QNode>来代替原来的Node, 使用了补齐的办法解决了队列伪共享的问题. 不知道是不是JSR-166的人开发的, 看来他们早就意识到这个问题了. 但是从Doug Lea JSR-166的cvs看不到这个变化, 不知道究竟是谁改的? 他们的repository到底是在哪?
2012年5月19日更新:
为了区别Cache Coherence和Cache Consistency两个概念, 不让读者混淆, 这里把Cache Coherence改翻译成缓存相干性.