从Java视角理解系统结构连载, 关注我的微博(
链接)了解最新动态
从我的
前一篇博文中, 我们知道了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的例子, 稍做修改,代码如下:
public final class FalseSharing implements Runnable {
public static int NUM_THREADS = 4; // change
public final static long ITERATIONS = 500L * 1000L * 1000L;
private final int arrayIndex;
private static VolatileLong[] longs;
public FalseSharing(final int arrayIndex) {
this.arrayIndex = arrayIndex;
}
public static void main(final String[] args) throws Exception {
Thread.sleep(10000);
System.out.println("starting....");
if (args.length == 1) {
NUM_THREADS = Integer.parseInt(args[0]);
}
longs = new VolatileLong[NUM_THREADS];
for (int i = 0; i < longs.length; i++) {
longs[i] = new VolatileLong();
}
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].value = i;
}
}
public final static class VolatileLong {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6; // 注释
}
}
代码的逻辑是默认4个线程修改一数组不同元素的内容. 元素的类型是VolatileLong, 只有一个长整型成员value和6个没用到的长整型成员. value设为volatile是为了让value的修改所有线程都可见. 在一台Westmere(Xeon E5620 8core*2)机器上跑一下看
$ java FalseSharing
starting....
duration = 9316356836
把以上代码49行注释掉, 看看结果:
$ java FalseSharing
starting....
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事件就可以证明了,步骤如下:
# 设置捕捉L2缓存IN事件
$ sudo opcontrol --setup --event=L2_LINES_IN:100000
# 清空工作区
$ sudo opcontrol --reset
# 开始捕捉
$ sudo opcontrol --start
# 运行程序
$ java FalseSharing
# 程序跑完后, dump捕捉到的数据
$ sudo opcontrol --dump
# 停止捕捉
$ sudo opcontrol -h
# 报告结果
$ opreport -l `which java`
比较一下两个版本的结果, 慢的版本:
$ opreport -l `which java`
CPU: Intel Westmere microarchitecture, speed 2400.2 MHz (estimated)
Counted L2_LINES_IN events (L2 lines alloacated) with a unit mask of 0x07 (any L2 lines alloacated) count 100000
samples % image name symbol name
34085 99.8447 anon (tgid:18051 range:0x7fcdee53d000-0x7fcdee7ad000) anon (tgid:18051 range:0x7fcdee53d000-0x7fcdee7ad000)
51 0.1494 anon (tgid:16054 range:0x7fa485722000-0x7fa485992000) anon (tgid:16054 range:0x7fa485722000-0x7fa485992000)
2 0.0059 anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000) anon (tgid:2753 range:0x7f43b317e000-0x7f43b375e000)
快的版本:
$ opreport -l `which java`
CPU: Intel Westmere microarchitecture, speed 2400.2 MHz (estimated)
Counted L2_LINES_IN events (L2 lines alloacated) with a unit mask of 0x07 (any L2 lines alloacated) count 100000
samples % image name symbol name
22 88.0000 anon (tgid:18873 range:0x7f3e3fa8a000-0x7f3e3fcfa000) anon (tgid:18873 range:0x7f3e3fa8a000-0x7f3e3fcfa000)
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个长整型在编译时优化掉, 可以在程序中加入一些代码防止被编译优化.
public static long preventFromOptimization(VolatileLong v) {
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改翻译成缓存相干性.
- 大小: 35.9 KB
- 大小: 24.2 KB
分享到:
相关推荐
伪共享(False Sharing)是在多线程环境中出现的一种现象,特别是在涉及多核处理器的情况下更为显著。在现代计算机体系结构中,CPU缓存系统是以缓存行(Cache Line)为单位进行存储的。通常情况下,主流CPU的缓存行大小...
1.2 缓存行的概念1.3 伪共享(False Sharing)的概念 + 其可能引发的性能问题2 如何避免伪共享 — 数据填充2.1 不使用数据填充时的效率验证2.2 手动进行数据填充的效率验证2.3 通过java8新特性@sun.misc.Contended...
标题 "A-simple-file-sharing-system.rar_idea_java file system_java 文件共享" 描述了一个基于Java语言开发的简单文件共享系统,该系统运用了P2P(点对点)网络技术,并在IntelliJ IDEA(简称IDEA)开发环境中构建...
另外,类数据共享(Class Data Sharing,CDS)功能使得启动时间得以优化,通过预加载类到共享存档,减少了应用程序的启动时间。 在I/O方面,NIO.2(New IO 2.0)提供了文件系统API的增强,支持异步文件操作和路径...
标题 "P2P_file_sharing_code.rar_Find Files_VB 文件共享_sharing files" 提供了我们讨论的关键点:这是一个基于VB(Visual Basic)编程语言实现的P2P(点对点)文件共享客户端,它的主要功能是让用户能够搜索并...
* 共享单车管理系统相关研究论文:<https://www.researchgate.net/publication/324567890_Bike-Sharing_System_Based_on_Java_Technology> 相关术语: * 共享单车管理系统(Bike Sharing Management System) * ...
标准的Java API 响应式Java API 所有形式都使用有限域算术来防止几何攻击。 使用API之前,至少对Shamir的秘密共享有一个粗略的了解是有益的。 建议将作为一个良好的起点。 支持通知:该库现在是稳定的。 它...
并联电源的负载共享 load sharing,设计与介绍,多用在复杂通信、工业设备设计应用时,电源的备份,电源使用效率优化设计
Java Web中的Session跨域共享问题通常出现在分布式系统或者微服务架构中,多个Web服务器需要共享用户的登录状态。在传统的单体应用中,Session是存储在单一服务器上的,但随着技术的发展,应用程序往往被拆分为多个...
什么是多线程环境下的伪共享(false sharing)?** 伪共享是指在多处理器环境中,多个线程对不同变量的操作却共享同一个缓存行的情况。由于缓存一致性协议的存在,当一个处理器修改了共享缓行中的数据时,其他...
2. **缓存冲突**:如果两个线程分别修改不同对象的相邻成员变量,它们可能位于同一缓存行,导致每次写操作都会使另一线程的缓存失效,这称为伪共享(False Sharing)。这会导致不必要的缓存刷新和额外的通信开销,...
4. **伪共享(False Sharing)** - 伪共享是指不同线程对位于同一缓存行内的变量进行修改,导致不必要的缓存失效和额外的通信开销。解决这个问题需要仔细调整数据结构,以避免变量在内存中的紧密排列。 5. **忙...
【标题解析】:“八年级英语作文共享单车Bike-sharing”这篇标题表明了这是一篇针对初中生的英语写作,主题是关于共享单车(Bike-sharing)的现象及其影响,旨在锻炼学生的英语表达能力和对社会现象的理解。...
8. **伪共享(False Sharing)**: - 在多处理器系统中,如果多个线程修改位于同一缓存行的变量,即使这些变量在逻辑上是独立的,也可能因为伪共享导致性能下降。 9. **忙等待(Busy Spin)**: - 忙等待是一种不...
SSS算法的Java实现 不带参数运行任何文件以获取可用选项列表 ShamirSecretCreator 将 input.txt(默认)作为参数,并将该文件中的指定矩阵划分为 N(默认 = 5)个共享并将它们存储在一个文件夹中(默认 = "shares" ...
3. 类数据共享(Class Data Sharing, CDS):此功能允许JVM在启动时共享已经编译的类数据,减少启动时间和内存占用。 4. 并发与多线程:Java SE 7引入了Fork/Join框架,这是一种用于并行执行任务的高级框架,适用于...
4. **动态CDS**:Class Data Sharing (CDS) 功能的扩展,使得多个JVM实例可以共享类数据,提升启动速度。 5. **ZGC**:一个低延迟的垃圾收集器,尤其适合大内存应用,可以减少应用程序暂停时间。 此外,Java 11还...
1. 伪共享(False Sharing): 伪共享是多线程系统中一个众所周知的性能问题,发生在不同处理器的上的线程对变量的修改依赖于相同的缓存行。 2. Busy Spin: Busy Spin 是一种在不释放 CPU 的基础上等待事件的技术,...