知识大全 内存屏障与JVM并发详解

Posted

篇首语:志不强者智不达,言不信者行不果。本文由小常识网(cha138.com)小编为大家整理,主要介绍了知识大全 内存屏障与JVM并发详解相关的知识,希望对你有一定的参考价值。

深入Java底层:内存屏障与JVM并发详解  以下文字资料是由(全榜网网www.cha138.com)小编为大家搜集整理后发布的内容,让我们赶快一起来看一下吧!

  内存屏障 又称内存栅栏 是一组处理器指令 用于实现对内存操作的顺序限制 本文假定读者已经充分掌握了相关概念和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

  

       intentFirst = true;          intentSecond = true;

  

       while (intentSecond)   while (intentFirst)       // volatile read

        if (turn != )       if (turn != )        // volatile read

          intentFirst = false;        intentSecond = false;

          while (turn != )         while (turn != )

          intentFirst = true;        intentSecond = true;

                      

  

       criticalSection();   criticalSection();

  

       turn = ;     turn = ;                 // volatile write

       intentFirst = false;   intentSecond = false;     // volatile write

  硬件优化可以在没有内存屏障的情况下打乱这段代码 即使编译器按照程序员的想法顺序列出所有的内存操作 考虑第三 四行的两次顺序volatile读操 作 每一个线程检查其他线程是否发信号想进入关键区域 然后检查轮到谁操作了 考虑第 行的两次顺序写操作 每一个线程把访问权释放给其他线程 然后撤销自己访问关键区域的意图 读线程应该从不期望在其他线程撤销访问意愿后观察到其他线程对turn变量的写操作 这是个灾难

  但是如果这些变量没有 volatile修饰符 这的确会发生!例如 没有volatile修饰符 第二个线程在第一个线程对turn执行写操作(倒数第二行)之前可能会观察到 第一个线程对intentFirst(倒数第一行)的写操作 关键词volatile避免了这种情况 因为它在对turn变量的写操作和对 intentFirst变量的写操作之间创建了一个先后关系 编译器无法重新排序这些写操作 如果必要 它会利用一个内存屏障禁止处理器重排序 让我们来 看看一些实现细节

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

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

     x de c:      adds r = r ;;  ;

     x de a :      ld acq r =[r ];;  ; b a a

     x de a :      nop m x      ; c

     x de ac:      sxt r r =r ;;  ;

     x de b :      cmp eq p p = r   ; c

     x de b :      nop i x      ;

     x de bc:      nd dpnt many x de ;

  简短的指令流其实内容丰富 第一次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

     x de c :      adds r = r ;;  ; b

     x de c :      st rel [r ]=r   ;

     x de cc:      adds r = r ;;  ;

     x de d :      st rel [r ]=r   ; a

     x de d :      mf            ;

     x de dc:      nop i x ;;   ;

     x de e :      mov r =r    ;

     x de e :      mov ret b =r x de e

     x de ec:      mov i ar pfs=r   ; aa

     x de f :      mov r =r     ;

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

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

  内存屏障是特定于硬件的

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

     x f c: push   %ebp               ;

     x f d: sub    $ x %esp          ; ec

     x f : mov    $ x c %edi        ; bf c

     x f : movb   $ x x a f (%edi)  ; c d a af

     x f f: mfence                    ; faef

     x f : mov    $ x %ebp        ; bd

     x f : mov    $ x d %edx        ; ba d

     x f c: movsbl x a f (%edx) %ebx  ; fbe a da af

     x f : test   %ebx %ebx          ; db

     x f : jne    x f          ;

     x f : movl   $ x x a f (%ebp)  ; c d a af

     x f : movb   $ x x a f (%edi)  ; c d a af

     x f : mfence                    ; faef

     x f b: add    $ x %esp          ; c

     x f e: pop    %ebp               ; d

  我们可以看到x Xeon在第 行执行两次volatile写操作 第二次写操作后面紧跟着mfence操作 显式的双向内存屏障 下面的连续写操作基于SPARC

   xfb ecc : ldub  [ %l + x ] %l   ; e c

   xfb ecc : cmp  %l                ; a e

   xfb ecc c: bne pn   %icc xfb eccb   ;

   xfb ecc : nop                       ;

   xfb ecc : st  %l [ %l + x ]  ; e

   xfb ecc : clrb  [ %l + x ]     ; c c

   xfb ecc c: membar  #StoreLoad        ; e

   xfb ecca : sethi  %hi( xff fc ) %l   ; fcff

   xfb ecca : ld  [ %l ] %g           ; c

   xfb ecca : ret                       ; c e

   xfb eccac: restore                   ; e

  我们看到在第五 六行存在两次volatile写操作 第二次写操作后面是一个membar指令 显式的双向内存屏障 x 和SPARC的指令流与Itanium的指令流存在一个重要区别 JVM在x 和SPARC上通过内存屏障跟踪连续写操作 但是在两次写操作之间没有放置内存屏障

  另一方面 Itanium的指令流在两次写操作之间存在内存屏障 为何JVM在不同的硬件架构之间表现不一?因为硬件架构都有自己的内 存模型 每一个内存模型有一套一致性保障 某些内存模型 如x 和SPARC等 拥有强大的一致性保障 另一些内存模型 如Itanium PowerPC和Alpha 是一种弱保障

  例如 x 和SPARC不会重新排序连续写操作 也就没有必要放置内存屏障 Itanium PowerPC和Alpha将重新排序连续写操作 因此JVM必须在两者之间放置内存屏障 JVM使用内存屏障减少Java内存模型和硬件内存模型之间的距离

  隐式内存屏障

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

   class Counter

  

       static int counter = ;

  

       public static void main(String[] _)

           for(int i = ; i < ; i++)

               inc();

      

  

       static synchronized void inc() counter += ;

  

  

  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

     x d eda : push   %ebp               ;

     x d eda : mov    %esp %ebp          ; bec

     x d edaa: sub    $ x %esp         ; ec

     x d edad: mov    $ x ba %esi   ; be ba

     x d edb : lea    x (%esp) %edi    ; d c

     x d edb : mov    %esi x (%edi)     ;

     x d edb : mov    (%esi) %eax        ; b

     x d edbb: or     $ x %eax          ; c

     x d edbe: mov    %eax (%edi)        ;

     x d edc : lock cmpxchg %edi (%esi)  ; f fb e

     x d edc : je     x d edda         ; f

     x d edca: sub    %esp %eax          ; bc

     x d edcc: and    $ xfffff %eax   ; e f ffff

     x d edd : mov    %eax (%edi)        ;

     x d edd : jne    x d ee          ; f

     x d edda: mov    $ x ba b %eax   ; b b ba

     x d eddf: mov    x (%eax) %esi   ; bb

     x d ede : inc    %esi               ;

     x d ede : mov    %esi x (%eax)   ; b

     x d edec: lea    x (%esp) %eax    ; d

     x d edf : mov    (%eax) %esi        ; b

     x d edf : test   %esi %esi          ; f

     x d edf : je     x d ee          ; f d

     x d edfa: mov    x (%eax) %edi     ; b

     x d edfd: lock cmpxchg %esi (%edi)  ; f fb

     x d ee : jne    x d ee f         ; f

     x d ee : mov    %ebp %esp          ; be

     x d ee : pop    %ebp               ; d

  不出意外 synchronized生成的指令数量比volatile多 第 行做了一次增操作 但是JVM没有显式插入内存屏障 相反 JVM通过在 第 行和第 行cmpxchg的lock前缀一石二鸟 cmpxchg的语义超越了本文的范畴

  lock cmpxchg不仅原子性执行写操作 也会刷新等待的读写操作 写操作现在将在所有后续内存操作之前完成 如果我们通过ncurrent atomic AtomicInteger 重构和运行Counter 将看到同样的手段

   import ncurrent atomic AtomicInteger;

  

       class Counter

  

           static AtomicInteger counter = new AtomicInteger( );

  

           public static void main(String[] args)

               for(int i = ; i < ; i++)

                   counter incrementAndGet();

          

  

      

  

   $ java XX:+UnlockDiagnosticVMOptions XX:PrintAssemblyOptions=hsdis print bytes

   XX:CompileCommand=print *AtomicInteger incrementAndGet Counter

     x f : push   %ebp               ;

     x f : mov    %esp %ebp          ; bec

     x fa: sub    $ x %esp         ; ec

     x fd: jmp    x a         ; e

     x : xchg   %ax %ax            ;

     x : test   %eax xb e     ; e b

     x a: mov    x (%ecx) %eax     ; b

     x d: mov    %eax %esi          ; bf

     x f: inc    %esi               ;

     x : mov    $ x a f d %edi   ; bfd f a

     x : mov    x (%edi) %edi   ; bbf

     x b: mov    %ecx %edi          ; bf

     x d: add    $ x %edi          ; c

     x : lock cmpxchg %esi (%edi)  ; f fb

     x : mov    $ x %eax          ; b

     x : je     x          ; f

     x f: mov    $ x %eax          ; b

     x : cmp    $ x %eax          ; f

     x : je     x          ; cb

     x : mov    %esi %eax          ; bc

     x b: mov    %ebp %esp          ; be

     x d: pop    %ebp               ; d

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

  内存屏障能够避免

  JVM非常擅于消除不必要的内存屏障 通常JVM很幸运 因为硬件内存模型的一致性保障强于或者等于Java内存模型 在这种情况下 JVM只是简单地插 入一个no op语句 而不是真实的内存屏障

  例如 x 和SPARC内存模型的一致性保障足够强壮以消除读volatile变量时所需的内存屏障 还记得在 Itanium上两次读操作之间的显式单向内存屏障吗?x 上的Dekker算法中连续volatile读操作的汇编指令之间没有任何内存屏障 x 平台上共享内存的连续读操作

     x f : mov    $ x %ebp        ; bd

     x f : mov    $ x d %edx        ; ba d

     x f c: movsbl x a f (%edx) %ebx  ; fbe a da af

     x f : test   %ebx %ebx          ; db

     x f : jne    x f          ;

     x f : movl   $ x x a f (%ebp)  ; c d a af

     x f : movb   $ x x a f (%edi)  ; c d a af

     x f : mfence                    ; faef

     x f b: add    $ x %esp          ; c

     x f e: pop    %ebp               ; d

     x f f: test   %eax xb ec     ; c eb

     x f : ret                       ; c

     x f : nopw   x (%eax %eax )   ; f f

     x f : mov    x a f (%ebp) %ebx  ; b d d a af

     x f : test   %edi xb ec     ; d c eb

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

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

  动态编译

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

     x b c: push   %ebp               ;

     x b d: sub    $ x %esp          ; ec

     x b : mov    $ x c %edi        ; bf c

     x b : movb   $ x x f (%edi)  ; c d aaf

     x b f: mov    $ x %ebp        ; bd

     x b : mov    $ x d %edx        ; ba d

     x b : movsbl x f (%edx) %ebx  ; fbe a d aaf

     x b : test   %ebx %ebx          ; db

     x b : jne    x b          ; c

     x b : movl   $ x x f (%ebp)  ; c d aaf

     x b : add    $ x %esp          ; c

     x b : pop    %ebp               ; d

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

     x f : push   %ebp               ;

     x f : mov    %esp %ebp          ; bec

     x fa: sub    $ x %esp         ; ec

     x fd: jmp    x a         ; e

     x : xchg   %ax %ax            ;

     x : test   %eax xb b     ; bb

     x a: mov    x (%ecx) %eax     ; b

     x d: mov    %eax %esi          ; bf

     x f: inc    %esi               ;

     x : mov    $ x a f d %edi   ; bfd f a

     x : mov    x (%edi) %edi   ; bbf

     x b: mov    %ecx %edi          ; bf

     x d: add    $ x %edi          ; c

     x : cmpxchg %esi (%edi)       ; fb

     x : mov    $ x %eax          ; b

     x : je     x          ; f

     x e: mov    $ x %eax          ; b

     x : cmp    $ x %eax          ; f

     x : je     x          ; cc

     x : mov    %esi %eax          ; bc

     x a: mov    %ebp %esp          ; be

     x c: pop    %ebp               ; d

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

  结束语

cha138/Article/program/Java/hx/201311/25723

相关参考

知识大全 详解JVM的内存管理机制

详解JVM的内存管理机制  以下文字资料是由(全榜网网www.cha138.com)小编为大家搜集整理后发布的内容,让我们赶快一起来看一下吧!我们在深入Java核心系列文

知识大全 深入了解JVM内存结构

深入了解JVM内存结构  以下文字资料是由(全榜网网www.cha138.com)小编为大家搜集整理后发布的内容,让我们赶快一起来看一下吧!  你对JVM内存结构是否熟悉

知识大全 MyEclipse内存不足之JVM内存浅谈

MyEclipse内存不足之JVM内存浅谈  以下文字资料是由(全榜网网www.cha138.com)小编为大家搜集整理后发布的内容,让我们赶快一起来看一下吧!  MyE

知识大全 JVM内存组成及分配

JVM基础:JVM内存组成及分配  以下文字资料是由(全榜网网www.cha138.com)小编为大家搜集整理后发布的内容,让我们赶快一起来看一下吧! &nbs

知识大全 JVM内存结构

JVM内存结构  以下文字资料是由(全榜网网www.cha138.com)小编为大家搜集整理后发布的内容,让我们赶快一起来看一下吧!  ()类装载子系统  装载连接初始化

知识大全 JVM运行时内存空间结构

JVM运行时内存空间结构  以下文字资料是由(全榜网网www.cha138.com)小编为大家搜集整理后发布的内容,让我们赶快一起来看一下吧!  JVM执行Java程序的

知识大全 配置JVM内存分配的妙招

配置JVM内存分配的妙招  以下文字资料是由(全榜网网www.cha138.com)小编为大家搜集整理后发布的内容,让我们赶快一起来看一下吧!  &n

知识大全 JVM,内存回收及其他

深入探索Java工作原理:JVM,内存回收及其他  以下文字资料是由(全榜网网www.cha138.com)小编为大家搜集整理后发布的内容,让我们赶快一起来看一下吧!  

知识大全 JVM内存模型及垃圾收集策略解析(1)

JVM内存模型及垃圾收集策略解析(1)  以下文字资料是由(全榜网网www.cha138.com)小编为大家搜集整理后发布的内容,让我们赶快一起来看一下吧!  一JVM内

知识大全 Eclipse中进行JVM内存设置

Eclipse中进行JVM内存设置  以下文字资料是由(全榜网网www.cha138.com)小编为大家搜集整理后发布的内容,让我们赶快一起来看一下吧! &nb