从我的前一篇博文中, 我们知道了CPU缓存及缓存行的概念, 同时用一个例子说明了编写单线程Java代码时应该注意的问题. 下面我们讨论更为复杂, 而且更符合现实情况的多核编程时将会碰到的问题. 这些问题更容易犯, 连j.u.c包作者Doug Lea大师的JDK代码里也存在这些问题.

MESI协议及RFO请求 
从前一篇我们知道, 典型的CPU微架构有3级缓存, 每个核都有自己私有的L1, L2缓存. 那么多线程编程时, 另外一个核的线程想要访问当前核内L1, L2 缓存行的数据, 该怎么办呢? 
有人说可以通过第2个核直接访问第1个核的缓存行. 这是可行的, 但这种方法不够快. 跨核访问需要通过Memory Controller(见上一篇的示意图), 典型的情况是第2个核经常访问第1个核的这条数据, 那么每次都有跨核的消耗. 更糟的情况是, 有可能第2个核与第1个核不在一个插槽内.况且Memory Controller的总线带宽是有限的, 扛不住这么多数据传输. 所以, CPU设计者们更偏向于另一种办法: 如果第2个核需要这份数据, 由第1个核直接把数据内容发过去, 数据只需要传一次.

那么什么时候会发生缓存行的传输呢? 答案很简单: 当一个核需要读取另外一个核的脏缓存行时发生. 但是前者怎么判断后者的缓存行已经被弄脏(写)了呢?

下面将详细地解答以上问题. 首先我们需要谈到一个协议--MESI协议(链接). 现在主流的处理器都是用它来保证缓存的相干性和内存的相干性. M,E,S和I代表使用MESI协议时缓存行所处的四个状态:

  • M(修改, Modified): 本地处理器已经修改缓存行, 即是脏行, 它的内容与内存中的内容不一样. 并且此cache只有本地一个拷贝(专有).
  • E(专有, Exclusive): 缓存行内容和内存中的一样, 而且其它处理器都没有这行数据
  • S(共享, Shared): 缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝
  • I(无效, Invalid): 缓存行失效, 不能使用

 
上图源自于内核开发者Ulrich Drepper著名的What Every Programmer Should Know About Memory一书(下载), 简要地展示了缓存行的四种状态转换. 不过他的书中没有说明白这四个状态是怎么转换的, 下面我用小段文字来说明一下.

初始 一开始时, 缓存行没有加载任何数据, 所以它处于I状态. 
本地写(Local Write)如果本地处理器写数据至处于I状态的缓存行, 则缓存行的状态变成M. 
本地读(Local Read) 如果本地处理器读取处于I状态的缓存行, 很明显此缓存没有数据给它. 此时分两种情况: (1)其它处理器的缓存里也没有此行数据, 则从内存加载数据到此缓存行后, 再将它设成E状态, 表示只有我一家有这条数据, 其它处理器都没有 (2)其它处理器的缓存有此行数据, 则将此缓存行的状态设为S状态.

P.S.如果处于M状态的缓存行, 再由本地处理器写入/读出, 状态是不会改变的.

远程读(Remote Read) 假设我们有两个处理器c1和c2. 如果c2需要读另外一个处理器c1的缓存行内容, c1需要把它缓存行的内容通过内存控制器(Memory Controller)发送给c2, c2接到后将相应的缓存行状态设为S. 在设置之前, 内存也得从总线上得到这份数据并保存.

远程写(Remote Write) 其实确切地说不是远程写, 而是c2得到c1的数据后, 不是为了读, 而是为了写. 也算是本地写, 只是c1也拥有这份数据的拷贝, 这该怎么办呢? c2将发出一个RFO(Request For Owner)请求, 它需要拥有这行数据的权限, 其它处理器的相应缓存行设为I, 除了它自已, 谁不能动这行数据. 这保证了数据的安全, 同时处理RFO请求以及设置I的过程将给写操作带来很大的性能消耗.

以上只是列举了一些状态转换, 为下文做铺垫. 如果全部描述,需要非常大量的文字, 大家参考这张图就知道原因了, 可以通过此图了解MESI协议更详细的信息.

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

Java代码  
  1. public final class FalseSharing implements Runnable {
  2. public static int NUM_THREADS = 4; // change
  3. public final static long ITERATIONS = 500L * 1000L * 1000L;
  4. private final int arrayIndex;
  5. private static VolatileLong[] longs;
  6. public FalseSharing(final int arrayIndex) {
  7. this.arrayIndex = arrayIndex;
  8. }
  9. public static void main(final String[] args) throws Exception {
  10. Thread.sleep(10000);
  11. System.out.println("starting....");
  12. if (args.length == 1) {
  13. NUM_THREADS = Integer.parseInt(args[0]);
  14. }
  15. longs = new VolatileLong[NUM_THREADS];
  16. for (int i = 0; i < longs.length; i++) {
  17. longs[i] = new VolatileLong();
  18. }
  19. final long start = System.nanoTime();
  20. runTest();
  21. System.out.println("duration = " + (System.nanoTime() - start));
  22. }
  23. private static void runTest() throws InterruptedException {
  24. Thread[] threads = new Thread[NUM_THREADS];
  25. for (int i = 0; i < threads.length; i++) {
  26. threads[i] = new Thread(new FalseSharing(i));
  27. }
  28. for (Thread t : threads) {
  29. t.start();
  30. }
  31. for (Thread t : threads) {
  32. t.join();
  33. }
  34. }
  35. public void run() {
  36. long i = ITERATIONS + 1;
  37. while (0 != --i) {
  38. longs[arrayIndex].value = i;
  39. }
  40. }
  41. public final static class VolatileLong {
  42. public volatile long value = 0L;
  43. public long p1, p2, p3, p4, p5, p6; // 注释
  44. }
  45. }

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

Shell代码  
  1. $ java FalseSharing
  2. starting....
  3. duration = 9316356836

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

Shell代码  
  1. $ java FalseSharing
  2. starting....
  3. duration = 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事件就可以证明了,步骤如下:

Shell代码  
  1. # 设置捕捉L2缓存IN事件
  2. $ sudo  opcontrol --setup --event=L2_LINES_IN:100000
  3. # 清空工作区
  4. $ sudo opcontrol --reset
  5. # 开始捕捉
  6. $ sudo opcontrol --start
  7. # 运行程序
  8. $ java FalseSharing
  9. # 程序跑完后, dump捕捉到的数据
  10. $ sudo opcontrol --dump
  11. # 停止捕捉
  12. $ sudo opcontrol -h
  13. # 报告结果
  14. $ opreport -l `which java`

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

Shell代码  
  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  %        p_w_picpath 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)

快的版本:

Shell代码  
  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  %        p_w_picpath 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个长整型在编译时优化掉, 可以在程序中加入一些代码防止被编译优化.

Java代码  
  1. public 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改翻译成缓存相干性.

转载于:https://blog.51cto.com/w19995/1057911

从Java视角理解伪共享(False Sharing)相关推荐

  1. 从缓存行出发理解volatile变量、伪共享False sharing、disruptor

    volatile关键字 当变量被某个线程A修改值之后,其它线程比如B若读取此变量的话,立刻可以看到原来线程A修改后的值 注:普通变量与volatile变量的区别是volatile的特殊规则保证了新值能 ...

  2. @sun.misc.Contended避免伪共享(false sharing)

    转载自:http://www.myexception.cn/program/1630142.html Java8中使用sun.misc.Contended注解来避免伪共享(false sharing) ...

  3. 伪共享(False Sharing)

    原文地址:http://ifeve.com/false-sharing/ 作者:Martin Thompson  译者:丁一 缓存系统中是以缓存行(cache line)为单位存储的.缓存行是2的整数 ...

  4. Java8中用sun.misc.Contended避免伪共享(false sharing)

    转自:http://budairenqin.iteye.com/blog/2048257 关于伪共享这个概念,请先参照http://ifeve.com/falsesharing/ 伪共享的样子: Ja ...

  5. C语言多线程教程(pthread)(线程创建pthread_t,指定线程run方法pthread_create,加mutex锁,解锁,伪共享 false sharing【假共享】)

    [C语言]多线程程序入门教程 文章目录 查看pthread_create()函数文档 · Demo1 单线程(创建线程pthread_t .创建线程run方法pthread_create) · Dem ...

  6. 从Java视角理解CPU缓存(CPU Cache)

    http://coderplay.iteye.com/blog/1485760 众所周知, CPU是计算机的大脑, 它负责执行程序的指令; 内存负责存数据, 包括程序自身数据. 同样大家都知道, 内存 ...

  7. 好理解的Java内存虚假共享(False Sharing)性能损耗以及解决方案

    虚假共享(False Sharing)也有人翻译为伪共享 参考 https://en.wikipedia.org/wiki/False_sharing 在计算机科学中,虚假共享是一种性能降低的使用模式 ...

  8. Java 中的伪共享详解及解决方案

    转载自  Java 中的伪共享详解及解决方案 1. 什么是伪共享 CPU 缓存系统中是以缓存行(cache line)为单位存储的.目前主流的 CPU Cache 的 Cache Line 大小都是 ...

  9. 从Java视角理解CPU上下文切换(Context Switch)

    在高性能编程时,经常接触到多线程. 起初我们的理解是, 多个线程并行地执行总比单个线程要快, 就像多个人一起干活总比一个人干要快. 然而实际情况是, 多线程之间需要竞争IO设备, 或者竞争锁资源,导致 ...

最新文章

  1. 力扣(LeetCode)刷题,简单题(第23期)
  2. android studio zbar,Android Studio 0.2.6和ZBar项目设置
  3. 基于 Spring Boot 和 Spring Cloud 实现微服务架构
  4. 饼图大小调整_Excel做的双层饼图,太漂亮了
  5. 机器学习-分类算法-K-近邻算法06
  6. 【实施工程师】Linux安装Windows软件:微信、QQ、TIM
  7. 深入解析PHP中逗号与点号的区别
  8. oracle 之 COMMENT
  9. getch, getche, getchar 转
  10. CentOS单用户模式及进入后只读处理,开机修改为文字界面
  11. linux下拨号时间,linux下拨号上网
  12. JUnit5 @Disabled测试示例
  13. html中如何把选择文件的那个框放到右侧_如何关闭烦人的Mac通知?
  14. c++构造函数、析构函数为什么不能取地址
  15. vs2010 如何更改界面
  16. 地图找房(四)02——小区房源列表展示和隐藏 小区房源列表数据填充 接口数据-图片地址的基准路径
  17. 交警罚没系统数据一致性问题
  18. 微信小程序入门12-微信小程序开发设置中服务器域名和业务域名
  19. 牛客练习赛34-C.(前缀和)
  20. 去掉桌面鼠标右键英特尔R显卡设置的方法

热门文章

  1. java获取excle表格对象_Java使用excel工具类导出对象功能示例
  2. 与ai计算机专业大学排名,全球大学计算机科学与人工智能排名:卡耐基梅隆大学居首...
  3. 计算机专业博士在体制内做啥工作,体制内裸辞,三十多岁大龄考博,我收获了什么?...
  4. java document select_javasript 操作option select
  5. python下载器2
  6. (2)谷歌2011校园招聘:
  7. php在一定范围内去随机整数,php-如何从随机位生成范围内的整数
  8. java学习笔记-良葛格_Java良葛格 学习笔记《二》
  9. python实现轨迹回放供应_运动轨迹回放 百度地图api示例源码
  10. 《去哪网编程题》身份证分组