熱點推薦:
您现在的位置: 電腦知識網 >> 編程 >> Java編程 >> Java核心技術 >> 正文

深入Java底層:內存屏障與JVM並發詳解

2022-06-13   來源: Java核心技術 

  內存屏障又稱內存柵欄是一組處理器指令用於實現對內存操作的順序限制本文假定讀者已經充分掌握了相關概念和Java內存模型不討論並發互斥並行機制和原子性內存屏障用來實現並發編程中稱為可見性(visibility)的同樣重要的作用

  內存屏障為何重要?

  對主存的一次訪問一般花費硬件的數百次時鐘周期處理器通過緩存(caching)能夠從數量級上降低內存延遲的成本這些緩存為了性能重新排列待定內存操 作的順序也就是說程序的讀寫操作不一定會按照它要求處理器的順序執行當數據是不可變的同時/或者數據限制在線程范圍內這些優化是無害的

  如果把這些優化與對稱多處理(symmetric multiprocessing)和共享可變狀態(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項目提供了用於SolarisLinux和BSD的插件二進制文件hsdis是另 一款可以在Windows通過源碼構建的插件

  兩次順序讀操作的第一次(第三行)的匯編指令如下指令流基於Itanium 多處理硬件JDK update 本文的所有指令流都在左手邊以行號標記相關的讀操作寫操作和內存屏障指令都以粗體標記建議讀者不要沉迷於每一行指令

    xdec:      adds r=r;;  ;

    xdea:      ldacq r=[r];;  ;ba a

    xdea:      nopm x     ; c

    xdeac:      sxt rr=r;;  ;

    xdeb:      cmpeq pp=r  ;c

    xdeb:      nopi x     ;

    xdebc:      nddpntmany xde;

  簡短的指令流其實內容豐富第一次volatile位於第二行Java內存模型確保了JVM會在第二次讀操作之前將第一次讀操作交給處理器也就是按照 程序的順序但是這單單一行指令是不夠的因為處理器仍然可以自由亂序執行這些操作為了支持Java內存模型的一致性JVM在第一次讀操作上添加了注解ldacq也就是載入獲取(load acquire)通過使用ldacq編譯器確保第二行的讀操作在接下來的讀操作之前完成問題就解決了

  請注意這影響了讀操作而不是寫內存屏障強制讀或寫操作順序限制不是單向的強制讀和寫操作順序限制的內存屏障是雙向的類似於雙向開的柵欄使用ldacq就是單向內存屏障的例子

  一致性具有兩面性如果一個讀線程在兩次讀操作之間插入了內存屏障而另外一個線程沒有在兩次寫操作之間添加內存屏障又有什麼用呢?線程為了協調必須同時 遵守這個協議就像網絡中的節點或者團隊中的成員如果某個線程破壞了這個約定那麼其他所有線程的努力都白費Dekker算法的最後兩行代碼的匯編指令應該插入一個內存屏障兩次volatile寫之間

  $ java XX:+UnlockDiagnosticVMOptions XX:PrintAssemblyOptions=hsdisprintbytes

  XX:CompileCommand=printWriterReaderwrite WriterReader

     xdec:      adds r=r;;  ;b

     xdec:      strel [r]=r  ;

     xdecc:      adds r=r;;  ;

     xded:      strel [r]=r  ; a

     xded:      mf            ;

     xdedc:      nopi x;;   ;

     xdee:      mov r=r   ;

     xdee:      movret b=rxdee

     xdeec:      movi arpfs=r  ;aa

    xdef:      mov r=r    ;

  這裡我們可以看到在第四行第二次寫操作被注解了一個顯式內存屏障通過使用strel存儲釋放(store release)編譯器確保第一次寫操作在第二次寫操作之前完成這就完成了兩邊的約定因為第一次寫操作在第二次寫操作之前發生

  strel屏障是單向的就像ldacq一樣但是在第五行編譯器設置了一個雙向內存屏障mf指令或者稱為內存柵欄是Itanium 指令集中的完整柵欄筆者認為是多余的

  內存屏障是特定於硬件的

  本文不想針對所有內存屏障做一綜述這將是一件不朽的功績但是重要的是認識到這些指令在不同的硬件體系中迥異下面的指令是連續寫操作在多處理 Intel Xeon硬件上編譯的結果本文後面的所有匯編指令除非特殊聲明否則都出自於Intel Xeon

    xfc: push   %ebp               ;

     xfd: sub    $x%esp          ;ec

     xf: mov    $xc%edi        ;bfc

     xf: movb   $xxaf(%edi)  ;cd aaf

     xff: mfence                    ;faef

     xf: mov    $x%ebp        ;bd

     xf: mov    $xd%edx        ;bad

     xfc: movsbl xaf(%edx)%ebx  ;fbea daaf

     xf: test   %ebx%ebx          ;db

    xf: jne    xf         ;

    xf: movl   $xxaf(%ebp)  ;cd aaf

    xf: movb   $xxaf(%edi)  ;cd aaf

    xf: mfence                    ;faef

    xfb: add    $x%esp          ;c

    xfe: pop    %ebp               ;d

  我們可以看到x Xeon在第行執行兩次volatile寫操作第二次寫操作後面緊跟著mfence操作顯式的雙向內存屏障下面的連續寫操作基於SPARC

   xfbecc: ldub  [ %l + x ] %l  ;ec

   xfbecc: cmp  %l                ;ae

   xfbeccc: bnepn   %icc xfbeccb  ;

   xfbecc: nop                       ;

   xfbecc: st  %l [ %l + x ]  ;e

   xfbecc: clrb  [ %l + x ]     ;cc

   xfbeccc: membar  #StoreLoad        ;e

   xfbecca: sethi  %hi(xfffc) %l  ;fcff

   xfbecca: ld  [ %l ] %g          ;c

   xfbecca: ret                       ;ce

   xfbeccac: 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=hsdisprintbytes

  XX:UseBiasedLocking XX:CompileCommand=printCounterinc Counter

     xdeda: push   %ebp               ;

     xdeda: mov    %esp%ebp          ;bec

     xdedaa: sub    $x%esp         ;ec

     xdedad: mov    $xba%esi   ;beba

     xdedb: lea    x(%esp)%edi    ;dc

     xdedb: mov    %esix(%edi)     ;

     xdedb: mov    (%esi)%eax        ;b

     xdedbb: or     $x%eax          ;c

     xdedbe: mov    %eax(%edi)        ;

    xdedc: lock cmpxchg %edi(%esi)  ;ffbe

    xdedc: je     xdedda         ;f

    xdedca: sub    %esp%eax          ;bc

    xdedcc: and    $xfffff%eax   ;ef ffff

    xdedd: mov    %eax(%edi)        ;

    xdedd: jne    xdee         ;f

    xdedda: mov    $xbab%eax   ;bbba

    xdeddf: mov    x(%eax)%esi   ;bb

    xdede: inc    %esi               ;

    xdede: mov    %esix(%eax)   ;b

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

    xdedf: mov    (%eax)%esi        ;b

    xdedf: test   %esi%esi          ;f

    xdedf: je     xdee         ;fd

    xdedfa: mov    x(%eax)%edi     ;b

    xdedfd: lock cmpxchg %esi(%edi)  ;ffb

    xdee: jne    xdeef         ;f

    xdee: mov    %ebp%esp          ;be

    xdee: pop    %ebp               ;d

  不出意外synchronized生成的指令數量比volatile多行做了一次增操作但是JVM沒有顯式插入內存屏障相反JVM通過在 第行和第行cmpxchg的lock前綴一石二鳥cmpxchg的語義超越了本文的范疇

  lock cmpxchg不僅原子性執行寫操作也會刷新等待的讀寫操作寫操作現在將在所有後續內存操作之前完成如果我們通過ncurrentatomicAtomicInteger 重構和運行Counter將看到同樣的手段

   import ncurrentatomicAtomicInteger;

  

      class Counter{

  

          static AtomicInteger counter = new AtomicInteger();

  

          public static void main(String[] args){

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

                  counterincrementAndGet();

          }

  

      }

  

  $ java XX:+UnlockDiagnosticVMOptions XX:PrintAssemblyOptions=hsdisprintbytes

  XX:CompileCommand=print*AtomicIntegerincrementAndGet Counter

     xf: push   %ebp               ;

     xf: mov    %esp%ebp          ;bec

     xfa: sub    $x%esp         ;ec

     xfd: jmp    xa         ;e

     x: xchg   %ax%ax            ;

     x: test   %eaxxbe    ;e b

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

     xd: mov    %eax%esi          ;bf

     xf: inc    %esi               ;

    x: mov    $xafd%edi   ;bfdf a

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

    xb: mov    %ecx%edi          ;bf

    xd: add    $x%edi          ;c

    x: lock cmpxchg %esi(%edi)  ;ffb

    x: mov    $x%eax          ;b

    x: je     x         ;f

    xf: mov    $x%eax          ;b

    x: cmp    $x%eax          ;f

    x: je     x         ;cb

    x: mov    %esi%eax          ;bc

    xb: mov    %ebp%esp          ;be

    xd: pop    %ebp               ;d

  我們又一次在第行看到了帶有lock前綴的寫操作這確保了變量的新值(寫操作)會在其他所有後續內存操作之前完成

  內存屏障能夠避免

  JVM非常擅於消除不必要的內存屏障通常JVM很幸運因為硬件內存模型的一致性保障強於或者等於Java內存模型在這種情況下JVM只是簡單地插 入一個no op語句而不是真實的內存屏障

  例如x和SPARC內存模型的一致性保障足夠強壯以消除讀volatile變量時所需的內存屏障還記得在 Itanium上兩次讀操作之間的顯式單向內存屏障嗎?x上的Dekker算法中連續volatile讀操作的匯編指令之間沒有任何內存屏障x平台上共享內存的連續讀操作

    xf: mov    $x%ebp        ;bd

     xf: mov    $xd%edx        ;bad

     xfc: movsbl xaf(%edx)%ebx  ;fbea daaf

     xf: test   %ebx%ebx          ;db

     xf: jne    xf         ;

     xf: movl   $xxaf(%ebp)  ;cd aaf

     xf: movb   $xxaf(%edi)  ;cd aaf

     xf: mfence                    ;faef

     xfb: add    $x%esp          ;c

    xfe: pop    %ebp               ;d

    xff: test   %eaxxbec    ;c eb

    xf: ret                       ;c

    xf: nopw   x(%eax%eax)   ;ff

    xf: mov    xaf(%ebp)%ebx  ;bdd aaf

    xf: test   %edixbec    ;dc eb

  第三行和第十四行存在volatile讀操作而且都沒有伴隨內存屏障也就是說x和SPARC上的volatile讀操作的性能下降對於代碼的優 化影響很小指令本身和常規讀操作一樣

  單向內存屏障本質上比雙向屏障性能要好一些JVM在確保單向屏障即可的情況下會避免使用雙向屏障本文的第一個例子展示了這點Itanium平台上的 連續兩次讀操作被插入單向內存屏障如果讀操作插入顯式雙向內存屏障程序仍然正確但是延遲比較長

  動態編譯

  靜態編譯器在構建階段決定的一切事情在動態編譯器那裡都可以在運行時決定甚至更多更多信息意味著存在更多機會可以優化例如讓我們看看JVM在單 處理器運行時如何對待內存屏障以下指令流來自於通過Dekker算法實現兩次連續volatile寫操作的運行時編譯程序運行於 x硬件上的單處理器模式中的VMWare工作站鏡像

    xbc: push   %ebp               ;

     xbd: sub    $x%esp          ;ec

     xb: mov    $xc%edi        ;bfc

     xb: movb   $xxf(%edi)  ;cd aaf

     xbf: mov    $x%ebp        ;bd

     xb: mov    $xd%edx        ;bad

     xb: movsbl xf(%edx)%ebx  ;fbea daaf

     xb: test   %ebx%ebx          ;db

     xb: jne    xb         ;c

    xb: movl   $xxf(%ebp)  ;cd aaf

    xb: add    $x%esp          ;c

    xb: pop    %ebp               ;d

  在單處理器系統上JVM為所有內存屏障插入了一個no op指令因為內存操作已經序列化了每一個寫操作(第行)後面都跟著一個屏障JVM針對原子條件式做了類似的優化下面的指令流來自於同一 個VMWare鏡像的AtomicIntegerincrementAndGet動態編譯結果

    xf: push   %ebp               ;

     xf: mov    %esp%ebp          ;bec

     xfa: sub    $x%esp         ;ec

     xfd: jmp    xa         ;e

     x: xchg   %ax%ax            ;

     x: test   %eaxxbb    ; bb

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

     xd: mov    %eax%esi          ;bf

     xf: inc    %esi               ;

    x: mov    $xafd%edi   ;bfdf a

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

    xb: mov    %ecx%edi          ;bf

    xd: add    $x%edi          ;c

    x: cmpxchg %esi(%edi)       ;fb

    x: mov    $x%eax          ;b

    x: je     x         ;f

    xe: mov    $x%eax          ;b

    x: cmp    $x%eax          ;f

    x: je     x         ;cc

    x: mov    %esi%eax          ;bc

    xa: mov    %ebp%esp          ;be

    xc: pop    %ebp               ;d

  注意第行的cmpxchg指令之前我們看到編譯器通過lock前綴把該指令提供給處理器由於缺少SMPJVM決定避免這種成本與靜態編譯有些不同

  結束語

  內存屏障是多線程編程的必要裝備它們形式多樣某些是顯式的某些是隱式的某些是雙向的某些是單向的JVM利用這些形式在所有平台中有效地支持Java內存模型我們希望本文能夠幫助經驗豐富的JVM開發人員了解一些代碼在底層如何運行的知識


From:http://tw.wingwit.com/Article/program/Java/hx/201311/25723.html
  • 上一篇文章:

  • 下一篇文章:
  • 推薦文章
    Copyright © 2005-2022 電腦知識網 Computer Knowledge   All rights reserved.