Java 記憶體模型

語言: CN / TW / HK

theme: vuepress highlight: atom-one-dark


小知識,大挑戰!本文正在參與“程式設計師必備小知識”創作活動

         理解Java記憶體模型是深入學習Java併發不可或缺的部分。Java記憶體模型即Java Memory Model,簡稱為JMM,定義了多執行緒之間共享變數的可見性以及如何在需要的時候對共享變數進行同步。

        JMM規定Java執行緒間的通訊採用共享記憶體的方式。在Java中,所有成員變數、靜態變數和陣列元素都儲存在堆記憶體中,堆記憶體線上程之間共享,所以它們通常也稱為共享變數。

        JMM定義了執行緒和主記憶體之間的抽象關係:執行緒之間的共享變數儲存在主記憶體(Main Memory)中,每個執行緒都有一個私有的本地記憶體(Local Memory,或者也可以稱為工作記憶體 Work Memory),本地記憶體中儲存了該執行緒以讀/寫共享變數的副本。本地記憶體是JMM的一個抽象概念,並不真實存在。它涵蓋了快取、寫緩衝區、暫存器以及其他的硬體和編譯器優化。

JMM抽象

JMM的抽象示意圖如下所示:

jmm01.png

多個執行緒同時對同一個共享變數進行讀寫的時候會產生執行緒安全問題。那為什麼CPU不直接操作記憶體,而要在CPU和記憶體間加上各種快取和暫存器等緩衝區呢?因為CPU的運算速度要比記憶體的讀寫速度快得多,如果CPU直接操作記憶體的話勢必會花費很長時間等待資料到來,所以快取的出現主要是為了解決CPU運算速度與記憶體讀寫速度不匹配的矛盾。

記憶體間互動協議

JMM規定了主記憶體和工作記憶體間具體的互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步到主記憶體之間的實現細節,這主要包含了下面8個步驟:

jmm02.png - lock(鎖定):作用於主記憶體的變數,把一個變數標識為一條執行緒獨佔狀態。 - unlock(解鎖):作用於主記憶體變數,把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。 - read(讀取):作用於主記憶體變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用 - load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。 - use(使用):作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。 - assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦值給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。 - store(儲存):作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中,以便隨後的write的操作。 - write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中一個變數的值傳送到主記憶體的變數中。 lock,unlock需要程式碼中用鎖實現。

這8個步驟必須符合下述規則: 1. 不允許read和load,store和write操作之一單獨出現。 2. 不允許一個執行緒丟棄它最近的assign操作。即變數在工作記憶體中改變了賬號必須把變化同步回主記憶體。 3. 一個新的變數只允許在主記憶體中誕生,不允許工作記憶體直接使用未初始化的變數。 4. 一個變數同一時刻只允許一條執行緒進行lock操作,但同一執行緒可以lock多次,lock多次之後必須執行同樣次數的unlock操作。 5. 如果對一個變數進行lock操作,那麼將會清空工作記憶體中此變數的值。 6. 不允許對未lock的變數進行unlock操作,也不允許unlock一個被其它執行緒lock的變數。 7. 如果一個變數執行unlock操作,必須先把此變數同步回主記憶體中。

指令重排

在執行程式時,為了提高效能,編譯器和處理器常常會對指令做重排序。從Java原始碼到最終實際執行的指令序列,會分別經歷下面3種重排序:

  1. 編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。
  2. 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-LevelParallelism,ILP)來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
  3. 記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

如果兩個操作訪問同一個變數,其中一個為寫操作,此時這兩個操作之間存在資料依賴性。 編譯器和處理器不會改變存在資料依賴性關係的兩個操作的執行順序,即不會重排序。不管怎麼重排序,單執行緒下的執行結果不能被改變,編譯器、runtime和處理器都必須遵守as-if-serial語義。

記憶體屏障

通過插入記憶體屏障(Memory Barrier)可以阻止特定型別的指令重排。JMM將記憶體屏障劃分為四種: | 屏障型別 | 示例 | 描述 | | :------------------ | :----------------------- | :----------------------------------------------------------- | | LoadLoad Barriers | Load1-LoadLoad-Load2 | Load1資料裝載過程要先於Load2及所有後續的資料裝載過程 | | StoreStore Barriers | Store1-StoreStore-Store2 | Store1重新整理資料到記憶體的過程要先於Strore2及後續所有重新整理資料到記憶體的過程 | | LoadStore Barriers | Load1-LoadStore-Store2 | Load1資料裝載要先於Strore2及後續所有重新整理資料到記憶體的過程 | | StoreLoad Barriers | Store1-StoreLoad-Load2 | Store1重新整理資料到記憶體的過程要先於Load2及所有後續的資料裝載過程 |

Java中volatile關鍵字的實現就是通過記憶體屏障來完成的。

happens-before

從jdk5開始,java使用新的JSR-133記憶體模型,基於happens-before的概念來闡述操作之間的記憶體可見性。

在JMM中,如果一個操作的執行結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關係,這個的兩個操作既可以在同一個執行緒,也可以在不同的兩個執行緒中。

與程式設計師密切相關的happens-before規則如下:

  • 程式順序規則:一個執行緒中的每個操作,happens-before於該執行緒中任意的後續操作。
  • 監視器鎖規則:對一個鎖的解鎖操作,happens-before於隨後對這個鎖的加鎖操作。
  • volatile域規則:對一個volatile域的寫操作,happens-before於任意執行緒後續對這個volatile域的讀。
  • 傳遞性規則:如果 A happens-before B,且 B happens-before C,那麼A happens-before C。

注意:兩個操作之間具有happens-before關係,並不意味前一個操作必須要在後一個操作之前執行!僅僅要求前一個操作的執行結果,對於後一個操作是可見的,且前一個操作按順序排在後一個操作之前。