java的伪共享

我们从上节知道, 写操作的代价很高, 特别当需要发送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的例子, 稍做修改,代码如下:

01public 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;
06 
07    public FalseSharing(final int arrayIndex) {
08        this.arrayIndex = arrayIndex;
09    }
10 
11    public static void main(final String[] args) throwsException {
12        Thread.sleep(10000);
13        System.out.println("starting....");
14        if (args.length == 1) {
15            NUM_THREADS = Integer.parseInt(args[0]);
16        }
17 
18        longs = new VolatileLong[NUM_THREADS];
19        for (int i = 0; i < longs.length; i++) {
20            longs[i] = new VolatileLong();
21        }
22        final long start = System.nanoTime();
23        runTest();
24        System.out.println("duration = " + (System.nanoTime() - start));
25    }
26 
27    private static void runTest() throwsInterruptedException {
28        Thread[] threads = new Thread[NUM_THREADS];
29        for (int i = 0; i < threads.length; i++) {
30            threads[i] = new Thread(new FalseSharing(i));
31        }
32        for (Thread t : threads) {
33            t.start();
34        }
35        for (Thread t : threads) {
36            t.join();
37        }
38    }
39 
40    public void run() {
41        long i = ITERATIONS + 1;
42        while (0 != --i) {
43            longs[arrayIndex].value = i;
44        }
45    }
46 
47    public final static class VolatileLong {
48        public volatile long value = 0L;
49        public long p1, p2, p3, p4, p5, p6; // 注释
50    }
51}

代码的逻辑是默认4个线程修改一数组不同元素的内容.  元素的类型是VolatileLong, 只有一个长整型成员value和6个没用到的长整型成员. value设为volatile是为了让value的修改所有线程都可见. 在一台Westmere(Xeon E5620 8core*2)机器上跑一下看

1$ java FalseSharing
2starting....
3duration = 9316356836

把以上代码49行注释掉, 看看结果:

1$ java FalseSharing
2starting....
3duration = 59791968514

两个逻辑一模一样的程序, 前者只需要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事件就可以证明了,步骤如下:

01# 设置捕捉L2缓存IN事件
02$ sudo  opcontrol --setup --event=L2_LINES_IN:100000
03# 清空工作区
04$ sudo opcontrol --reset
05# 开始捕捉
06$ sudo opcontrol --start
07# 运行程序
08$ java FalseSharing
09# 程序跑完后, dump捕捉到的数据
10$ sudo opcontrol --dump
11# 停止捕捉
12$ sudo opcontrol -h
13# 报告结果
14$ opreport -l `which java`

比较一下两个版本的结果, 慢的版本:

1$ opreport -l `which java`
2CPU: Intel Westmere microarchitecture, speed 2400.2 MHz (estimated)
3Counted L2_LINES_IN events (L2 lines alloacated) with a unit mask of 0x07 (any L2 lines alloacated) count 100000
4samples  %        image name               symbol name
534085    99.8447  anon (tgid:18051 range:0x7fcdee53d000-0x7fcdee7ad000) anon (tgid:18051 range:0x7fcdee53d000-0x7fcdee7ad000)
651        0.1494  anon (tgid:16054 range:0x7fa485722000-0x7fa485992000) anon (tgid:16054 range:0x7fa485722000-0x7fa485992000)
72         0.0059  anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000) anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000)
快的版本:
1$ opreport -l `which java`
2CPU: Intel Westmere microarchitecture, speed 2400.2 MHz (estimated)
3Counted L2_LINES_IN events (L2 lines alloacated) with a unit mask of 0x07 (any L2 lines alloacated) count 100000
4samples  %        image name               symbol name
522       88.0000  anon (tgid:18873 range:0x7f3e3fa8a000-0x7f3e3fcfa000) anon (tgid:18873 range:0x7f3e3fa8a000-0x7f3e3fcfa000)
63        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个长整型在编译时优化掉, 可以在程序中加入一些代码防止被编译优化。

1public static long preventFromOptimization(VolatileLong v) {
2    return v.p1 + v.p2 + v.p3 + v.p4 + v.p5 + v.p6;
3}

另外, 由于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改翻译成缓存相干性.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值