内存屏障与JVM并发

内存屏障,又称内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限 制。本文介绍了内存屏障对多线程程序的影响。我们将研究内存屏障与JVM并发机 制的关系,如易变量(volatile)、同步(synchronized)和原子条件式 (atomic conditional)。本文假定读者已经充分掌握了相关概念和Java内存模 型,不讨论并发互斥、并行机制和原子性。内存屏障用来实现并发编程中称为可 见性(visibility)的同样重要的作用。

内存屏障为何重要?

对主存的一次访问一般花费硬件的数百次时钟周期。处理器通过缓存 (caching)能够从数量级上降低内存延迟的成本这些缓存为了性能重新排列待定 内存操作的顺序。也就是说,程序的读写操作不一定会按照它要求处理器的顺序 执行。当数据是不可变的,同时/或者数据限制在线程范围内,这些优化是无害的 。如果把这些优化与对称多处理(symmetric multi-processing)和共享可变状 态(shared mutable state)结合,那么就是一场噩梦。当基于共享可变状态的 内存操作被重新排序时,程序可能行为不定。一个线程写入的数据可能被其他线 程可见,原因是数据写入的顺序不一致。适当的放置内存屏障通过强制处理器顺 序执行待定的内存操作来避免这个问题。

内存屏障的协调作用

内存屏障不直接由JVM暴露,相反它们被JVM插入到指令序列中以维持语言层并 发原语的语义。我们研究几个简单Java程序的源代码和汇编指令。首先快速 看一 下Dekker算法中的内存屏障。该算法利用volatile变量协调两个线程之间的共享 资源访问。

请不要关注该算法的出色细节。哪些部分是相关的?每个线程通过发信号试图 进入代码第一行的关键区域。如果线程在第三行意识到冲突(两个线程都要访问 ),通 过turn变量的操作来解决。在任何时刻只有一个线程可以访问关键区域。

// 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   }        }  910  criticalSection();  criticalSection();1112  turn = 1;   turn = 0;         // volatile  write13  intentFirst = false;  intentSecond = false;   //  volatile write

硬件优化可以在没有内存屏障的情况下打乱这段代码,即使编译器按照程序员 的想法顺序列出所有的内存操作。考虑第三、四行的两次顺序volatile 读操作。 每一个线程检查其他线程是否发信号想进入关键区域,然后检查轮到谁操作了。 考虑第12、13行的两次顺序写操作。每一个线程把访问权释放给其他线程,然后 撤销自己访问关键区域的意图。读线程应该从不期望在其他线程撤销访问意愿后 观察到其他线程对turn变量的写操作。这是个灾难。但是如果这些变量没有 volatile修饰符,这的确会发生!例如,没有volatile修饰符,第二个线程在第 一个线程对turn执行写操作(倒数第二行)之前可能会观察到第一个线程对 intentFirst(倒数第一行)的写操作。关键词volatile避免了这种情况,因为它在 对turn变量的写操作和对 intentFirst变量的写操作之间创建了一个先后关系。 编译器无法重新排序这些写操作,如果必要,它会利用一个内存屏障禁止处理器 重排序。让我们来看看一些实现细节。

PrintAssembly HotSpot选项是JVM的一个诊断标志,允许我们获取JIT编译器 生成的汇编指令。这需要最新的OpenJDK版本或者新HotSpot update14或者更高版 本。通过需要一个反编译插件。Kenai项目提供了用于Solaris、Linux和BSD的插 件二进制文件。hsdis是另一款可以在Windows通过源码构建的插件。

两次顺序读操作的第一次(第三行)的汇编指令如下。指令流基于Itanium 2 多处理硬件、JDK 1.6 update 17。本文的所有指令流都在左手边以行号标记。相 关的读操作、写操作和内存屏障指令都以粗体标记。建议读者不要沉迷于每一行 指令。

1 0x2000000001de819c:   adds r37=597,r36;;  ;...841125542 0x2000000001de81a0:   ld1.acq r38=[r37];; ;...0b30014a  a0103 0x2000000001de81a6:   nop.m 0x0   ;...00000002 00c04 0x2000000001de81ac:   sxt1 r38=r38;; ;...005130045 0x2000000001de81b0:   cmp4.eq p0,p6=0,r38 ;...1100004c  86396 0x2000000001de81b6:   nop.i 0x0   ;...00000002 00037 0x2000000001de81bc:   br.cond.dpnt.many  0x2000000001de8220;;

简短的指令流其实内容丰富。第一次volatile位于第二行。Java内存模型确保 了JVM会在第二次读操作之前将第一次读操作交给处理器,也就是按照 “程序的 顺序”——但是这单单一行指令是不够的,因为处理器仍然可以自由乱序执行这 些操作。为了支持Java内存模型的一致性,JVM在第一次读操作上添加了注解 ld.acq,也就是“载入获取”(load acquire)。通过使用ld.acq,编译器确保 第二行的读操作在接下来的读操作之前完成。问题就解决了。

请注意这影响了读操作,而不是写。内存屏障强制读或写操作顺序限制不是单 向的。强制读和写操作顺序限制的内存屏障是双向的, 类似于双向开的栅栏。使 用ld.acq就是单向内存屏障的例子。

一致性具有两面性。如果一个读线程在两次读操作之间插入了内存屏障而另外 一个线程没有在两次写操作之间添加内存屏障又有什么用呢?线程为了协调,必 须同时遵守这个协议,就像网络中的节点或者团队中的成员。如果某个线程破坏 了这个约定,那么其他所有线程的努力都白费。Dekker算法的最后两行代码的汇 编指令应该插入一个内存屏障,两次volatile写之间。

$ java -XX:+UnlockDiagnosticVMOptions – XX:PrintAssemblyOptions=hsdis-print-bytes – XX:CompileCommand=print,WriterReader.write WriterReader

1 0x2000000001de81c0:   adds r37=592,r36;; ;...0b284149  0421  2 0x2000000001de81c6:   st4.rel [r37]=r39 ;...00389560  2380  3 0x2000000001de81cc:   adds r36=596,r36;; ;...84112544  4 0x2000000001de81d0:   st1.rel [r36]=r0 ;...09000048  a011  5 0x2000000001de81d6:   mf      ;...00000044 0000  6 0x2000000001de81dc:   nop.i 0x0;;  ;...00040000  7 0x2000000001de81e0:   mov r12=r33  ;...00600042 0021  8 0x2000000001de81e6:   mov.ret b0=r35,0x2000000001de81e0  9 0x2000000001de81ec:   mov.i ar.pfs=r34 ;...00aa022010 0x2000000001de81f0:   mov r6=r32  ;...09300040  0021

这里我们可以看到在第四行第二次写操作被注解了一个显式内存屏障。通过使 用st.rel,即“存储释放”(sTore release),编译器确保第一次写操作在第二 次写操作之前完成。这就完成了两边的约定,因为第一次写操作在第二次写操作 之前发生。

st.rel屏障是单向的——就像ld.acq一样。但是在第五行编译器设置了一个双 向内存屏障。mf指令,或者称为“内存栅栏”,是Itanium 2指令集中的完整栅栏 。笔者认为是多余的。

内存屏障是特定于硬件的

本文不想针对所有内存屏障做一综述。这将是一件不朽的功绩。但是,重要的 是认识到这些指令在不同的硬件体系中迥异。下面的指令是连续写操作在多处理 Intel Xeon硬件上编译的结果。本文后面的所有汇编指令除非特殊声明否则都出 自于Intel Xeon。

1 0x03f8340c: push  %ebp        ;...55  2 0x03f8340d: sub  $0x8,%esp     ;...81ec0800  0000  3 0x03f83413: mov  $0x14c,%edi    ;...bf4c0100 00  4 0x03f83418: movb  $0x1,-0x505a72f0(%edi) ;...c687108d  a5af01  5 0x03f8341f: mfence          ;...0faef0  6 0x03f83422: mov  $0x148,%ebp    ;...bd480100 00  7 0x03f83427: mov  $0x14d,%edx    ;...ba4d0100 00  8 0x03f8342c: movsbl -0x505a72f0(%edx),%ebx ;...0fbe9a10  8da5af  9 0x03f83433: test  %ebx,%ebx     ;...85db10 0x03f83435: jne  0x03f83460     ;...752911 0x03f83437: movl  $0x1,-0x505a72f0(%ebp) ;...c785108d  a5af0112 0x03f83441: movb  $0x0,-0x505a72f0(%edi) ;...c687108d  a5af0013 0x03f83448: mfence          ;...0faef014 0x03f8344b: add  $0x8,%esp     ;...83c40815 0x03f8344e: pop  %ebp        ;...5d

我们可以看到x86 Xeon在第11、12行执行两次volatile写操作。第二次写操作 后面紧跟着mfence操作——显式的双向内存屏障。

下面的连续写操作基于SPARC。

1 0xfb8ecc84: ldub [ %l1 + 0x155 ], %l3  ;...e60c6155  2 0xfb8ecc88: cmp %l3, 0        ;...80a4e000  3 0xfb8ecc8c: bne,pn  %icc, 0xfb8eccb0 ;...12400009  4 0xfb8ecc90: nop            ;...01000000  5 0xfb8ecc94: st %l0, [ %l1 + 0x150 ] ;...e0246150  6 0xfb8ecc98: clrb [ %l1 + 0x154 ]   ;...c02c6154  7 0xfb8ecc9c: membar #SToreLoad    ;...8143e002  8 0xfb8ecca0: sethi %hi(0xff3fc000), %l0 ;...213fcff0  9 0xfb8ecca4: ld [ %l0 ], %g0     ;...c004200010 0xfb8ecca8: ret            ;...81c7e00811 0xfb8eccac: resTore          ;...81e80000

我们看到在第五、六行存在两次volatile写操作。第二次写操作后面是一个 membar指令——显式的双向内存屏障。

x86和SPARC的指令流与Itanium的指令流存在一个重要区别。JVM在x86和SPARC 上通过内存屏障跟踪连续写操作,但是在两次写操作之间没有放置内存屏障。另 一方面,Itanium的指令流在两次写操作之间存在内存屏障。为何JVM在不同的硬 件架构之间表现不一?因为硬件架构都有自己的内存模型,每一个内存模型有一 套一致性保障。某些内存模型,如x86和SPARC等,拥有强大的一致性保障。另一 些内存模型,如Itanium、 PowerPC和Alpha,是一种弱保障。例如,x86和SPARC 不会重新排序连续写操作——也就没有必要放置内存屏障。Itanium、 PowerPC和 Alpha将重新排序连续写操作——因此JVM必须在两者之间放置内存屏障。JVM使用 内存屏障减少Java内存模型和硬件内存模型之间的距离。

隐式内存屏障

显式屏障指令不是序列化内存操作的唯一方式。让我们再看一看Counter类这 个例子。

class Counter{     static int counter = 0;     public static void main(String[] _){       for(int i = 0; i < 100000; i++)         inc();     }     static synchronized void inc(){ counter += 1; }    }

Counter类执行了一个典型的读-修改-写的操作。静态counter字段不是 volatile的,因为所有三个操作必须要原子可见的。因此,inc 方法是 synchronized修饰的。我们可以采用下面的命令编译Counter类并查看生成的汇编 指令。Java内存模型确保了 synchronized区域的退出和volatile内存操作都是相 同的可见性,因此我们应该预料到会有另一个内存屏障。

$ java -XX:+UnlockDiagnosticVMOptions – XX:PrintAssemblyOptions=hsdis-print-bytes -XX:-UseBiasedLocking – XX:CompileCommand=print,Counter.inc Counter

1 0x04d5eda7: push  %ebp        ;...55  2 0x04d5eda8: mov  %esp,%ebp     ;...8bec  3 0x04d5edaa: sub  $0x28,%esp     ;...83ec28  4 0x04d5edad: mov  $0x95ba5408,%esi  ;...be0854ba 95  5 0x04d5edb2: lea  0x10(%esp),%edi  ;...8d7c2410  6 0x04d5edb6: mov  %esi,0x4(%edi)   ;...897704  7 0x04d5edb9: mov  (%esi),%eax    ;...8b06  8 0x04d5edbb: or   $0x1,%eax     ;...83c801  9 0x04d5edbe: mov  %eax,(%edi)    ;...890710 0x04d5edc0: lock cmpxchg %edi,(%esi) ;...f00fb13e11 0x04d5edc4: je   0x04d5edda     ;...0f841000 000012 0x04d5edca: sub  %esp,%eax     ;...2bc413 0x04d5edcc: and  $0xfffff003,%eax  ;...81e003f0 ffff14 0x04d5edd2: mov  %eax,(%edi)    ;...890715 0x04d5edd4: jne  0x04d5ee11     ;...0f853700 000016 0x04d5edda: mov  $0x95ba52b8,%eax  ;...b8b852ba 9517 0x04d5eddf: mov  0x148(%eax),%esi  ;...8bb04801 000018 0x04d5ede5: inc  %esi        ;...4619 0x04d5ede6: mov  %esi,0x148(%eax)  ;...89b04801 000020 0x04d5edec: lea  0x10(%esp),%eax  ;...8d44241021 0x04d5edf0: mov  (%eax),%esi    ;...8b3022 0x04d5edf2: test  %esi,%esi     ;...85f623 0x04d5edf4: je   0x04d5ee07     ;...0f840d00 000024 0x04d5edfa: mov  0x4(%eax),%edi   ;...8b780425 0x04d5edfd: lock cmpxchg %esi,(%edi) ;...f00fb13726 0x04d5ee01: jne  0x04d5ee1f     ;...0f851800 000027 0x04d5ee07: mov  %ebp,%esp     ;...8be528 0x04d5ee09: pop  %ebp        ;...5d

不出意外,synchronized生成的指令数量比volatile多。第18行做了一次增操 作,但是JVM没有显式插入内存屏障。相反,JVM 通过在 第10行和第25行cmpxchg 的lock前缀一石二鸟。cmpxchg的语义超越了本文的范畴。lock cmpxchg不仅原子 性执行写操作,也会刷新等待的读写操作。写操作现在将在所有后续内存操作之 前完成。如果我们通过 java.util.concurrent.atomic.AtomicInteger 重构和运 行Counter,将看到同样的手段。

import java.util.concurrent.atomic.AtomicInteger;   class Counter{     static AtomicInteger counter = new AtomicInteger (0);     public static void main(String[] args){       for(int i = 0; i < 1000000; i++)         counter.incrementAndGet();     }   }

$ java -XX:+UnlockDiagnosticVMOptions – XX:PrintAssemblyOptions=hsdis-print-bytes – XX:CompileCommand=print,*AtomicInteger.incrementAndGet Counter

1 0x024451f7: push  %ebp        ;...55  2 0x024451f8: mov  %esp,%ebp     ;...8bec  3 0x024451fa: sub  $0x38,%esp     ;...83ec38  4 0x024451fd: jmp  0x0244520a     ;...e9080000 00  5 0x02445202: xchg  %ax,%ax      ;...6690  6 0x02445204: test  %eax,0xb771e100  ;...850500e1 71b7  7 0x0244520a: mov  0x8(%ecx),%eax   ;...8b4108  8 0x0244520d: mov  %eax,%esi     ;...8bf0  9 0x0244520f: inc  %esi        ;...4610 0x02445210: mov  $0x9a3f03d0,%edi  ;...bfd0033f 9a11 0x02445215: mov  0x160(%edi),%edi  ;...8bbf6001 000012 0x0244521b: mov  %ecx,%edi     ;...8bf913 0x0244521d: add  $0x8,%edi     ;...83c70814 0x02445220: lock cmpxchg %esi,(%edi) ;...f00fb13715 0x02445224: mov  $0x1,%eax     ;...b8010000 0016 0x02445229: je   0x02445234     ;...0f840500 000017 0x0244522f: mov  $0x0,%eax     ;...b8000000 0018 0x02445234: cmp  $0x0,%eax     ;...83f80019 0x02445237: je   0x02445204     ;...74cb20 0x02445239: mov  %esi,%eax     ;...8bc621 0x0244523b: mov  %ebp,%esp     ;...8be522 0x0244523d: pop  %ebp        ;...5d

我们又一次在第14行看到了带有lock前缀的写操作。这确保了变量的新值(写 操作)会在其他所有后续内存操作之前完成。

内存屏障能够避免

JVM非常擅于消除不必要的内存屏障。通常JVM很幸运,因为硬件内存模型的一 致性保障强于或者等于Java内存模型。在这种情况下,JVM只是简单地插 入一个 no op语句,而不是真实的内存屏障。例如,x86和SPARC内存模型的一致性保障足 够强壮以消除读volatile变量时所需的内存屏障。还记得在 Itanium上两次读操 作之间的显式单向内存屏障吗?x86上的Dekker算法中连续volatile读操作的汇编 指令之间没有任何内存屏障。

x86平台上共享内存的连续读操作。

1 0x03f83422: mov  $0x148,%ebp    ;...bd480100 00  2 0x03f83427: mov  $0x14d,%edx    ;...ba4d0100 00  3 0x03f8342c: movsbl -0x505a72f0(%edx),%ebx ;...0fbe9a10  8da5af  4 0x03f83433: test  %ebx,%ebx     ;...85db  5 0x03f83435: jne  0x03f83460     ;...7529  6 0x03f83437: movl  $0x1,-0x505a72f0(%ebp) ;...c785108d  a5af01  7 0x03f83441: movb  $0x0,-0x505a72f0(%edi) ;...c687108d  a5af00  8 0x03f83448: mfence          ;...0faef0  9 0x03f8344b: add  $0x8,%esp     ;...83c40810 0x03f8344e: pop  %ebp        ;...5d11 0x03f8344f: test  %eax,0xb78ec000  ;...850500c0 8eb712 0x03f83455: ret            ;...c313 0x03f83456: nopw  0x0(%eax,%eax,1)  ;...66660f1f 84000014 0x03f83460: mov  -0x505a72f0(%ebp),%ebx ;...8b9d108d  a5af15 0x03f83466: test  %edi,0xb78ec000  ;...853d00c0  8eb7

第三行和第十四行存在volatile读操作,而且都没有伴随内存屏障。也就是说 ,x86和SPARC上的volatile读操作的性能下降对于代码的优 化影响很小——指令 本身和常规读操作一样。

单向内存屏障本质上比双向屏障性能要好一些。JVM在确保单向屏障即可的情 况下会避免使用双向屏障。本文的第一个例子展示了这点。Itanium平台上的 连 续两次读操作被插入单向内存屏障。如果读操作插入显式双向内存屏障,程序仍 然正确,但是延迟比较长。

动态编译

静态编译器在构建阶段决定的一切事情,在动态编译器那里都可以在运行时决 定,甚至更多。更多信息意味着存在更多机会可以优化。例如,让我们看看 JVM 在单 处理器运行时如何对待内存屏障。以下指令流来自于通过Dekker算法实现两 次连续volatile写操作的运行时编译。程序运行于 x86硬件上的单处理器模式中 的VMWare工作站镜像。

1 0x017b474c: push  %ebp        ;...55  2 0x017b474d: sub  $0x8,%esp     ;...81ec0800  0000  3 0x017b4753: mov  $0x14c,%edi    ;...bf4c0100 00  4 0x017b4758: movb  $0x1,-0x507572f0(%edi) ;...c687108d  8aaf01  5 0x017b475f: mov  $0x148,%ebp    ;...bd480100 00  6 0x017b4764: mov  $0x14d,%edx    ;...ba4d0100 00  7 0x017b4769: movsbl -0x507572f0(%edx),%ebx ;...0fbe9a10  8d8aaf  8 0x017b4770: test  %ebx,%ebx     ;...85db  9 0x017b4772: jne  0x017b4790     ;...751c10 0x017b4774: movl  $0x1,-0x507572f0(%ebp) ;...c785108d  8aaf0111 0x017b477e: movb  $0x0,-0x507572f0(%edi) ;...c687108d  8aaf0012 0x017b4785: add  $0x8,%esp     ;...83c40813 0x017b4788: pop  %ebp        ;...5d

在单处理器系统上,JVM为所有内存屏障插入了一个no op指令,因为内存操作 已经序列化了。每一个写操作(第10、11行)后面都跟着一个屏障。JVM针对原子 条件式做了类似的优化。下面的指令流来自于同一个VMWare镜像的 AtomicInteger.incrementAndGet动态编译结果。

1 0x036880f7: push  %ebp        ;...55  2 0x036880f8: mov  %esp,%ebp     ;...8bec  3 0x036880fa: sub  $0x38,%esp     ;...83ec38  4 0x036880fd: jmp  0x0368810a     ;...e9080000 00  5 0x03688102: xchg  %ax,%ax      ;...6690  6 0x03688104: test  %eax,0xb78b8100  ;...85050081 8bb7  7 0x0368810a: mov  0x8(%ecx),%eax   ;...8b4108  8 0x0368810d: mov  %eax,%esi     ;...8bf0  9 0x0368810f: inc  %esi        ;...4610 0x03688110: mov  $0x9a3f03d0,%edi  ;...bfd0033f 9a11 0x03688115: mov  0x160(%edi),%edi  ;...8bbf6001 000012 0x0368811b: mov  %ecx,%edi     ;...8bf913 0x0368811d: add  $0x8,%edi     ;...83c70814 0x03688120: cmpxchg %esi,(%edi)    ;...0fb13715 0x03688123: mov  $0x1,%eax     ;...b8010000 0016 0x03688128: je   0x03688133     ;...0f840500 000017 0x0368812e: mov  $0x0,%eax     ;...b8000000 0018 0x03688133: cmp  $0x0,%eax     ;...83f80019 0x03688136: je   0x03688104     ;...74cc20 0x03688138: mov  %esi,%eax     ;...8bc621 0x0368813a: mov  %ebp,%esp     ;...8be522 0x0368813c: pop  %ebp        ;...5d

注意第14行的cmpxchg指令。之前我们看到编译器通过lock前缀把该指令提供 给处理器。由于缺少SMP,JVM决定避免这种成本——与静态编译有些不同。

结束语

内存屏障是多线程编程的必要装备。它们形式多样,某些是显式的,某些是隐 式的。某些是双向的,某些是单向的。JVM利用这些形式在所有平台中有效地支持 Java内存模型。我希望本文能够帮助经验丰富的JVM开发人员了解一些代码在底层 如何运行的知识。

带着感恩的心启程,学会爱,爱父母,爱自己,爱朋友,爱他人。

内存屏障与JVM并发

相关文章:

你感兴趣的文章:

标签云: