JVM-004-内存屏障

JVM-004-内存屏障,第1张

 cpu指令乱序执行虽然提升了效率,但是在某种特定的情况,我们并不需要指令乱序执行。为了保障有序性,cpu层级提供了三条汇编的指令,也就是内存屏障

 这三条汇编指令分别是sfence、lfence、mfence。除此之外,还有lock指令。

 JVM层级的内存屏障只是一种规范,具体需要根据硬件来实现。

 JVM层级提供了四种内存屏障,LoadLoad屏障、StoreStore屏障、LoadStore屏障、StoreLoad屏障。

  volatile在字节码、JVM层面、OS和硬件层面的实现。

  synchronized在字节码、JVM层面、OS和硬件层面的实现。

内存屏障 又称内存栅栏 是一组处理器指令 用于实现对内存 *** 作的顺序限制 本文假定读者已经充分掌握了相关概念和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: mov *** l 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: mov *** l 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 : mov *** l 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决定避免这种成本 与静态编译有些不同

结束语

lishixinzhi/Article/program/Java/hx/201311/25723


欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/bake/11924824.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2023-05-19
下一篇 2023-05-19

发表评论

登录后才能评论

评论列表(0条)

保存