所有访问都是原子的

原文:https://shipilev.net/blog/2014/all-accesses-are-atomic/
作者:Aleksey,Red Hat,OpenJDK开发者,https://shipilev.net/
译者:初开,http://chukai.link
这项工作还在进行(2014年),理论上是对的,但不是最终结论,本文未经同行评审,请谨慎参考。

1、前言

Java内存模型大修提案引发了各种疑问。为了回答这些问题,需要用性能测试来评估提案。当然,像可靠性,上手难度,逻辑简洁等指标也很重要,甚至更重要。这篇文章继续探讨一些经典的问题。
按照观猎,我们用面向基准测试的方式来讨论。本文本身是针对平台开发者们,但一般的开发者也可以学习到一些东西。如果你还没有了解过JMH或没有查看过JMH例子,我建议你先阅读它们,以改善阅读体验。

2、访问原子性

2.1、规范要求

在多线程的世界中存在不同的原子性概念。最着名的原子性概念是“操作”原子性,例如操作组是否仅在这两种状态下可观察:完全执行或尚未执行。其他讨论的很少,我们将专注于特定的一个:访问原子性。

如果访问结果看起来不可分割,则对变量的访问是原子的,这是直接明了,但它很难在所有计算机架构上实现,有时需要权衡考虑。最臭名昭著的例子是Java Memory Model中ong和double变量的原子性l。
也就是说,这个程序:

 long x = 0L;
 void thread1() {
    x = -1L;
 }
 void thread2() {
    println(x);
 }

结果可能是“0”,“ -1” 或其他瞬时值。JMM提供了volatile修饰符,为此行为盖上了遮羞布,从而恢复其原子性。
现在,这个程序的结果只有“0”或“1”了:

 volatile long x = 0L;
 void thread1() {
    x = -1L;
 }
 void thread2() {
    println(x);
 }

2.2、 实现细节

遇到宽度不足的本地读写情况,必须使用其他方式来是实现语言的语义,如使用扩展指令集(例如x87 FPU,SSE,VFP或其他指令),CAS,甚至锁。

译者注:这里的宽度是指CPU中运算器和寄存器之间的数据总线宽度,即一次能传多少位数据,比如某CPU的总线宽度是32位,而Java中的long是64位,要传输这个long,则只能拆为32+32两次传输,这样在CPU层次就不是原子操作了。
这些替换方案不需要内存排序担保,因此其实现很自然地分别发出访问指令序列和内存语义(如内存屏障)。这使得的volatile被重载,并变得混乱了:用户无法在不设置内存屏障的前提下实现访问原子性; 相反,在volatile不放弃访问原子性的情况下,无法消除内存屏障。
所以,性能方面也基本能确定了,正常访问比原子访问快,而原子访问比volatile访问快。下面的定性实验将回答这两个问题:

  1. 普通访问和原子访问的性能差距是多少?如果这种差距很小,那么直接让所有类型强制执行原子访问应该是明智的。
  2. 原子访问和volatile访问的性能差距是多少?如果这种差距很大,那么强制执行原子访问的看起来更好。

3、实验设置

3.1、平台

由于跨平台的需要,因此得测试广泛的体系和微体系架构,我们在这五个平台上运行了测试:

  • x86(Ivy Bridge):2插槽,12核,2超线程,Xeon E5-2697v2,Ivy Bridge,2.7 GHz
  • x86(Atom):1插槽,1核,2超线程,Atom Z530,1.6 Ghz
  • ARMv6:1插槽,1核,Broadcom BCM2835 SoC(Raspberry Pi),0.7 GHz
  • ARMv7:1插槽,4核,Exynos 4412 Prime(Odroid-U2),Cortex-A9,1.7 GHz
  • POWERv6:1插槽,8核,Freescale P4080,e500mc,1.5 GHz

我们会使用JDK8为基础环境,这包括Java SE 8 Embedded ARM / PPC端口,这些端口在OpenJDK中不可用,得从Oracle网站下载。不过修改可以在OpenJDK上运行,与机器无关。

3.2、对实验VM的修改

庆幸的是,double/long原子性机制在当前的HotSpot VM中已经可用了,并且该机制已经与其他内存模型机制(如屏障)分离。简单的VM更改就可以在C2和C1 HotSpot编译器(JDK-8033380)中无条件获得原子性。

译者注:HotSpot VM提供了分层编译优化:首先,解释执行,然后使用基线编译器(baseline compiler )(也称为“client”编译器或C1)进行编译,最后使用积极优化编译器进行编译(也称为“server”编译器,或C2)。

--- old/src/share/vm/c1/c1_LIRGenerator.cpp 2014-02-11 21:29:45.730836748 +0400
+++ new/src/share/vm/c1/c1_LIRGenerator.cpp 2014-02-11 21:29:45.566836744 +0400
@@ -1734,7 +1734,8 @@
                 (info ? new CodeEmitInfo(info) : NULL));
   }

-  if (is_volatile && !needs_patching) {
+  bool needs_atomic_access = is_volatile || AlwaysAtomicAccesses;
+  if (needs_atomic_access && !needs_patching) {
     volatile_field_store(value.result(), address, info);
   } else {
     LIR_PatchCode patch_code = needs_patching ? lir_patch_normal : lir_patch_none;
@@ -1807,7 +1808,8 @@
     address = generate_address(object.result(), x->offset(), field_type);
   }

-  if (is_volatile && !needs_patching) {
+  bool needs_atomic_access = is_volatile || AlwaysAtomicAccesses;
+  if (needs_atomic_access && !needs_patching) {
     volatile_field_load(address, reg, info);
   } else {
     LIR_PatchCode patch_code = needs_patching ? lir_patch_normal : lir_patch_none;
--- old/src/share/vm/c1/c1_Runtime1.cpp 2014-02-11 21:29:46.342836763 +0400
+++ new/src/share/vm/c1/c1_Runtime1.cpp 2014-02-11 21:29:46.178836759 +0400
@@ -809,11 +809,10 @@
   int bci = vfst.bci();
   Bytecodes::Code code = caller_method()->java_code_at(bci);

-#ifndef PRODUCT
   // this is used by assertions in the access_field_patching_id
   BasicType patch_field_type = T_ILLEGAL;
-#endif // PRODUCT
   bool deoptimize_for_volatile = false;
+  bool deoptimize_for_atomic = false;
   int patch_field_offset = -1;
   KlassHandle init_klass(THREAD, NULL); // klass needed by load_klass_patching code
   KlassHandle load_klass(THREAD, NULL); // klass needed by load_klass_patching code
@@ -839,11 +838,17 @@
     // is the path for patching field offsets.  load_klass is only
     // used for patching references to oops which don't need special
     // handling in the volatile case.
+
     deoptimize_for_volatile = result.access_flags().is_volatile();

-#ifndef PRODUCT
+    // If we are patching a field which should be atomic, then
+    // the generated code is not correct either, force deoptimizing.
+    // We need to only cover T_LONG and T_DOUBLE fields, as we can
+    // break access atomicity only for them.
+
     patch_field_type = result.field_type();
-#endif
+    deoptimize_for_atomic = (AlwaysAtomicAccesses && (patch_field_type == T_DOUBLE || patch_field_type == T_LONG));
+
   } else if (load_klass_or_mirror_patch_id) {
     Klass* k = NULL;
     switch (code) {
@@ -918,13 +923,19 @@
     ShouldNotReachHere();
   }

-  if (deoptimize_for_volatile) {
-    // At compile time we assumed the field wasn't volatile but after
-    // loading it turns out it was volatile so we have to throw the
+  if (deoptimize_for_volatile || deoptimize_for_atomic) {
+    // At compile time we assumed the field wasn't volatile/atomic but after
+    // loading it turns out it was volatile/atomic so we have to throw the
     // compiled code out and let it be regenerated.
     if (TracePatching) {
-      tty->print_cr("Deoptimizing for patching volatile field reference");
+      if (deoptimize_for_volatile) {
+        tty->print_cr("Deoptimizing for patching volatile field reference");
+      }
+      if (deoptimize_for_atomic) {
+        tty->print_cr("Deoptimizing for patching atomic field reference");
+      }
     }
+
     // It's possible the nmethod was invalidated in the last
     // safepoint, but if it's still alive then make it not_entrant.
     nmethod* nm = CodeCache::find_nmethod(caller_frame.pc());
--- old/src/share/vm/opto/parse3.cpp    2014-02-11 21:29:46.890836776 +0400
+++ new/src/share/vm/opto/parse3.cpp    2014-02-11 21:29:46.734836772 +0400
@@ -233,7 +233,8 @@
   // Build the load.
   //
   MemNode::MemOrd mo = is_vol ? MemNode::acquire : MemNode::unordered;
-  Node* ld = make_load(NULL, adr, type, bt, adr_type, mo, is_vol);
+  bool needs_atomic_access = is_vol || AlwaysAtomicAccesses;
+  Node* ld = make_load(NULL, adr, type, bt, adr_type, mo, needs_atomic_access);

   // Adjust Java stack
   if (type2size[bt] == 1)
@@ -314,7 +315,8 @@
     }
     store = store_oop_to_object(control(), obj, adr, adr_type, val, field_type, bt, mo);
   } else {
-    store = store_to_memory(control(), adr, val, bt, adr_type, mo, is_vol);
+    bool needs_atomic_access = is_vol || AlwaysAtomicAccesses;
+    store = store_to_memory(control(), adr, val, bt, adr_type, mo, needs_atomic_access);
   }

   // If reference is volatile, prevent following volatiles ops from
--- old/src/share/vm/runtime/globals.hpp    2014-02-11 21:29:47.466836790 +0400
+++ new/src/share/vm/runtime/globals.hpp    2014-02-11 21:29:47.282836785 +0400
@@ -3859,6 +3859,9 @@
           "Allocation less than this value will be allocated "              \
           "using malloc. Larger allocations will use mmap.")                \
                                                                             \
+  experimental(bool, AlwaysAtomicAccesses, false,                           \
+          "Accesses to all variables should always be atomic")              \
+                                                                            \
   product(bool, EnableTracing, false,                                       \
           "Enable event-based tracing")                                     \
                                                                             \

由于两个编译器指令细节方面的差别,普通读写也可能生成不同的代码序列,我们可以更严格地量化它。现在,这些VM修改允许我们让类型强制原子性访问-XX:+UnlockExperimentalVMOptions -XX:+AlwaysAtomicAccesses。

4、正确性测试的结果

我们使用jcstress来验证是否重新获取了原子性。根据测试的特点,我们无法直接确认是否获得了原子性,但可以估计非原子性访问的概率。jcstress提供了捆绑测试

$ java -jar jcstress.jar -t ".*atomicity.primitives.plain.(Long|Double).*"  -jvmArgs "-server -XX:+UnlockExperimentalVMOptions -XX:+AlwaysAtomicAccesses" -v -time 30000 -iters 20

为了测试更简单,我们只针对非volatile的long和double。

4.1、ARMv6(32位)

由于这个平台是单处理器,运行正确性测试似乎没用。

4.2、ARMv7(32位)

ARMv7有四个核,可以进行并发性测试。以下是测试结果。先解释下如何阅读结果:表中X/Y表示在Y次运行中观察到X个异常情况。在这里,异常情况是非原子性访问。该测试基于统计学原理,所以你只能证明某次测试不是原子的,但不能证明测试始终是原子的。

你可能会注意到:

  1. double在所有模式下都是原子的。这是因为浮点访问通过浮点单元进行了路由,并且指令宽度是64位。因此,不需要任何措施来保证原子性,直接获得。
  2. long只能在-client下有失败,而-sever没有。这是因为C2已经为64位选择了原子指令序列,而C1没有。因此,这两者的差异可能会突出潜在的性能问题。

ARMv7可以通过扩展指令集来获取访问原子性。

x86 Ivy Bridge(32位)

x86有两个核,可以进行并发性测试,以下是结果。

你可能会注意到:

  1. double都是原子的。这是因为浮点访问是通过FPU/SSE路由的,并且指令宽度至少为64位。不需要其他措施来保证原子性,直接获得。
  2. long只有-XX:-AlwaysAtomicAccesses有失败,这是因为硬件使用两个32位进行非原子访问:
  0xe7702b6f: mov    0x10(%esi),%eax
  0xe7702b72: mov    0x14(%esi),%edx
  0xe7702b75: mov    %eax,0x8(%esi)
  0xe7702b78: mov    %edx,0xc(%esi)

long使用-XX:+AlwaysAtomicAccesses可以重新获得原子性,代价是用SSE进行读写:

  0xe76af353: vmovsd 0x10(%ebx),%xmm0       ; reader: read 64-bit atomically
  0xe76af358: vmovd  %xmm0,%ebp             ; reader: read lo word
  0xe76af35c: vpsrlq $0x20,%xmm0,%xmm0      ; reader: reshuffle
  0xe76af361: vmovd  %xmm0,%edi             ; reader: read hi word

  0xe76af368: vmovd  %ebp,%xmm0             ; writer: write lo word
  0xe76af36c: vmovd  %edi,%xmm1             ; writer: write hi word
  0xe76af370: vpunpckldq %xmm1,%xmm0,%xmm0  ; writer: pack (lo, hi)
  0xe76af374: vmovsd %xmm0,0x8(%ebx)        ; writer: write 64-bit atomically

4.3、x86 Ivy Bridge(64位)

在64位模式下,所有基本类型都有本地指令来操作。正确性测试只是再一次证明了它。

4.4、x86 Atom(32位)

x86 Atom是一个有趣的性能测试平台,因为它是有序的,并且内存带宽较少。但是,从某种意义上讲,它与Ivy Bridge相同。注意到存在少量的失败情况,这是因为线程在单个超线程上竞争运行,加载/存储非常棘手。

4.5、PowerPC e500mc(32位)

PowerPC是另一个有趣的平台,它的行为与我们之前看到的类似:

  1. double看起来都是是原子的,因为用浮点ISA原子地读写64位
  2. long在-XX:-AlwaysAtomicAccesses时有失败,但-XX:+AlwaysAtomicAccesses全通过
  3. 目前还没有-server的版本,但差不多要有了。


PowerPC通过使用特殊指令获得访问原子性。

5、基准测试

我们确认了测试方案是可行的,可以用基准测试来回答我们的问题。但测量纳秒级的性能非常困难,需要用非常规的方法,否则硬件本身的开销会影响结果。
这是我们使用的基准测试。关于基准测试得注意的是:

  1. 这里没法使用小伎俩,只是一个加载+存储,执行额外操作的都会产生性能开销。相反,我们用@State注解,因为状态对象被转义了,所以不会被省略。并且仔细检查汇编代码,确保它在热循环中。
  2. 在读的时候,我们测量读字段的成本,并将其写回接收的sink字段。这里做了混合操作,读取和接收字段是不同的访问类型,这比其他防止死代码消除的方法简单多了。
  3. 在写的时候,我们测量将常量写给给字段的开销。由于常量写入不依赖于输入,所以可以在基准调用之间通用。同样检查反汇编代码,以确保它存在于热循环中。

5.1、ARMv6(32位)

不多说,让我们深入研究ARMv6的结果。(原始数据):

  1. ARMv6没有C2编译器,只有C1。
  2. int和float测试的性能是一致的,没有受任何新功能的影响。
  3. double不受影响,因为它已经是原子的。
  4. 值得注意的是,volatile操作与正常读写的比例相同。这是因为编译器没有发现屏障,运行时检测到它在单处理器上运行。
  5. 由于我们选择用更好的指令来访问该值,因此强制原子性访问long有时会稍微改善性能。

5.2 ARMv7(32位)

ARMv7更现代,更一致。(原始数据在这里):

  1. 正如预期的那样,float或int没有变化,因为操作已经是原子了。
  2. double也没有受到影响。
  3. 在C1中,long性能上升了,因为我们切换到另一个ISA访问值。这不仅提供了访问原子性,还提高了性能。
  4. 在C2中,long性能在下降,因为我们切换到另一个ISA访问64位值,即使它不需要访问原子性。这是代码生成的缺陷。
  5. 原子读写和volatile读写之间的区别非常明显:差距高达2倍。

5.3 x86 Ivy Bridge (64-bit)

让我们看看64位x86,原始数据):

  1. 注意已经原子性的普通访问和volatile访问的10倍性能差异,这本来已经是原子操作了,又加上一个多余的规则。

5.4 x86 Ivy Bridge (32-bit)

32位x86,请开始你的表演。原始数据):

  1. float或int结果没区别,操作已经原子了。
  2. double没影响,通过FPU已经原子了。
  3. 在C1中,long性能显着降低,因为生成了次优的机器码。C2的结果验证了这一点,它选择了更有效的代码。
  4. 在C2中,long性能打平,因为使用了SSE,这要快得多。
  5. 注意原子普通访问和volatile访问之间的区别:5倍差距。

5.5 x86 Atom(32位)

Ivy Bridge提供了一个有趣的结果,它可以同时进行多次内存访问,能分摊访问成本。Atom没这么奢侈,但因为Ivy Bridge和Atom为此测试生成的代码是等效的,所以有看点。(原始数据):

  1. double,float,int没有受到影响,很好。
  2. 在C1中,long性能下降超过2倍:原子long写入的额外成本甚至超过了volatile的成本(尽管对全局一致性没影响)。这很可能是由于生成的低效代码将SSE操作数移到GP寄存器上,Atom的躺着中了一箭。
  3. 在C2中,long的结果也降低了2倍以上:可能也是由于SSE-GP的传输。
  4. 普通原子访问和volatile访问的区别很小,但volatile仍然有点慢。

5.6PowerPC e500mc(32位)

最后但并非最不重要的PowerPC(原始数据):

  1. double,float,int没有影响。
  2. C2不可用,只有C1有结果。
  3. 原子访问long比非原子访问慢2倍,无论是读还是写。
  4. 普通原子访问和volatile访问之间的差异巨大:volatile存储比原子访问存储慢4倍!

总结和展望

volatile会将访问原子性语义与内存同步问题整合到一起,所以通过volatile强制执行原子访问的命中量很大。
另一方面,强制原子访问看起来已经超标了。在一些案例中,保守策略会产生一些可以承受的额外开销,而另一些甚至反而会提升性能。
在大多数场景下,性能问题是代码生成缺陷导致的,并且可以解决。但如果要求提供出的指令本身就是原子的,这仍是一个未解决的问题。
当然,64位的原子性访问可以完全纳入其他指令操作中(volatile访问则不容易),等实验性VM功能(JDK-8033380)集成到主干后,我们将进行更多性能测试。

Comments
Write a Comment