jvm内存屏障
内存屏障或栅栏是一组处理器指令,用于对内存操作施加排序限制。 本文介绍了内存障碍对多线程程序确定性的影响。 我们将研究内存屏障如何与JVM并发构造(如易失性,同步和原子条件)相关联。 假定读者对这些概念和Java内存模型有扎实的理解。 这不是有关互斥,并行性或原子性本身的文章。 内存屏障用于实现并发编程的同等重要的元素,称为可见性。
感谢Brian Goetz和Eric Yew审阅了本文。 我还要感谢Christian Thalinger对SPARC硬件的访问。
为什么内存屏障很重要?
在商用硬件上,一次访问主存储器会花费数百个时钟周期。 处理器使用缓存将内存延迟的成本降低了几个数量级。 为了提高性能,这些缓存对未决的内存操作进行重新排序。 换句话说,程序的读取和写入不一定按照它们被赋予处理器的顺序执行。 当数据是不可变的和/或限制在一个线程的范围内时,这些优化是无害的。 另一方面,将这些优化与对称多处理和共享可变状态相结合可能是一场噩梦。 当对共享可变状态的内存操作进行重新排序时,程序可能无法确定地运行。 一个线程可能以与写入顺序不一致的方式来编写对另一个线程可见的值。 正确放置的内存屏障可通过迫使处理器序列化未决的内存操作来避免此问题。
内存屏障作为协议
JVM不会直接暴露内存屏障; 而是将它们插入JVM的指令序列中,以维护语言级并发原语的语义。 我们将查看一些简单Java程序的源代码和汇编指令,以了解操作方法。 让我们开始使用Dekker算法在内存壁垒中速成课程。 该算法使用三个volatile变量来协调对两个线程之间共享资源的访问。
尽量不要专注于该算法的细节。 哪些部分相关? 每个线程试图通过发出意图进入代码的第一行来进入关键部分。 如果某个线程在第三行上发现冲突(两个线程均已发出意图信号),则通过轮流解决冲突。 在给定的时间点,只有一个线程可以访问关键部分。
// code run by first thread // code run by second thread
1 intentFirst = true; intentSecond = true;
2
3 while (intentSecond) while (intentFirst) // volatile read
4 if (turn != 0) { if (turn != 1) { // volatile read
5 intentFirst = false; intentSecond = false;
6 while (turn != 0) {} while (turn != 1) {}
7 intentFirst = true; intentSecond = true;
8 } }
9
10 criticalSection(); criticalSection();
11
12 turn = 1; turn = 0; // volatile write
13 intentFirst = false; intentSecond = false; // volatile write
硬件优化可以在没有内存障碍的情况下打破此代码,即使编译器按照从程序员的角度看它们出现的顺序发出所有内存操作。 考虑第三和第四行的两个连续的易失性读操作。 每个线程都会检查另一个线程是否发出了进入关键部分的信号,然后检查该线程是谁。 考虑第12和13行上的两个连续的易失性写操作。每个线程给另一个线程“转” ,然后撤回其进入关键部分的意图。 在另一个线程撤回意图之后,读取线程永远不要期望观察另一个线程对turn变量的写入。 这将是一场灾难。 但是如果在这些变量上没有volatile修饰符,这的确会发生! 例如,在没有volatile修饰符的情况下,第二个线程可以在第一个线程的写转向(第二个到最后一行)之前观察第一个线程对intentFirst(最后一行)的写操作。 关键字volatile避免了此问题,因为它在写入turn变量和写入intentFirst变量之间建立关系之前就建立了一个条件。 编译器无法对这些写操作进行重新排序,并且在必要时必须禁止处理器使用内存屏障来重新排序。 深入了解如何操作。
PrintAssembly HotSpot选项是JVM的诊断标志,它使我们能够捕获JIT编译器生成的汇编指令。 这需要最新的OpenJDK版本或更新版本14或更高版本的HotSpot。 还需要反汇编程序插件。 Kenai项目具有用于Solaris,Linux和BSD的插件二进制文件。 hsdis插件是可以从Windows源码构建的替代方法。
第三行的两个连续读取操作中的第一个在下面的汇编指令中捕获。 此流是在运行带有更新17的JDK 1.6的多处理Itanium 2硬件上捕获的。本文中的所有指令流都按左侧的行号排序。 相关的读操作,写操作和内存屏障指令以粗体显示。 建议读者避免陷入每条指令的语义中。
1 0x2000000001de819c: adds r37=597,r36;; ;...841125542 0x2000000001de81a0: ld1.acq r38=[r37];; ;...0b30014a a010
3 0x2000000001de81a6: nop.m 0x0 ;...00000002 00c0
4 0x2000000001de81ac: sxt1 r38=r38;; ;...00513004
5 0x2000000001de81b0: cmp4.eq p0,p6=0,r38 ;...1100004c 8639
6 0x2000000001de81b6: nop.i 0x0 ;...00000002 0003
7 0x2000000001de81bc: br.cond.dpnt.many 0x2000000001de8220;;
简短的说明讲述了一个很长的故事。 第一个易失性读取在第二行。 Java内存模型保证了JVM将在第二次读取之前以“程序顺序”将读取的内容交付给处理器-但仅此还不够,因为处理器仍然可以自由地无序执行这些操作。 为了维护Java内存模型的一致性保证,JVM使用ld.acq的变体(即“负载获取”)注释了第一次读取操作。 通过使用ld.acq,编译器可以确保第二行的读取操作将在后续的读取操作之前完成。 问题解决了。
请注意,这会影响读取,而不是写入。 强制对读取或写入进行排序限制的内存屏障是单向的。 强制对读取和写入进行排序限制的内