1. 程式人生 > >Java記憶體模型詳解

Java記憶體模型詳解

前言

Java記憶體模型(Java Memory Model,簡稱JMM),即Java虛擬機器定義的一種用來遮蔽各種硬體和作業系統的記憶體訪問差異,以實現讓java程式在各種平臺下都能夠達到一致的記憶體訪問效果的記憶體模型。本篇文章大致涉及到五個要點:Java記憶體模型的基礎,主要介紹JMM抽象結構;Java記憶體模型中記憶體屏障;Java記憶體模型中的重排序;happens-before原則;順序一致性記憶體模型。還有與JMM相關的三個同步原語(synchronized,volatile,final)將另分三篇文章介紹。

1.Java記憶體模型的抽象結構

在java中,共享變數是指所有儲存在堆記憶體中的例項欄位,靜態欄位和陣列物件元素,因為堆記憶體是所有執行緒共享的資料區。而區域性變數,方法定義引數,異常處理引數不會線上程之間共享,它們不存在記憶體可見性問題,也不會受到Java記憶體模型的影響。

Java記憶體模型決定了一個執行緒對共享變數的寫入何時對另一個執行緒可見。從抽象的角度來看,Java記憶體模型定義了執行緒與主記憶體之間的抽象關係:執行緒之間的共享變數儲存主記憶體中,每個執行緒都有一個私有的本地記憶體,也叫工作記憶體,本地記憶體儲存了該執行緒需要讀/寫的共享變數的副本。本地記憶體是JMM的一個抽象的概念,其實並不真實存在。Java記憶體模型的抽象示意圖如下:

從上圖來看,如果執行緒A和執行緒B之間要通訊的話,必須要經歷下面的兩個過程:

1.執行緒A把本地記憶體A中更新過的共享變數重新整理到主記憶體中去。
2.執行緒B到主記憶體中去讀取執行緒A之前已更新過的共享變數。

下面通過示意圖說明以上兩個過程:

如上圖:假設初始時,X的值為0,首先執行緒A要先從主記憶體中讀取共享變數x的值,並將其副本儲存在自己的本地記憶體。接著執行緒A要把共享變數x的值更新為1,也就是先把本地記憶體中的x的副本的值更新為1,然後再把本地記憶體中剛更新過的共享變數重新整理到主記憶體,此時主記憶體中共享變數x的值為1。然後執行緒A向執行緒B傳送通知:哥們兒,我已更新了共享變數的值。

隨後,執行緒B接收到執行緒A傳送的通知,也從主記憶體中讀取共享變數x的值,並將其副本儲存在自己的本地記憶體,接著執行緒B也要修改共享變數的值,先將本地記憶體B中的副本x修改為2,再將本地記憶體中的x的值重新整理到主記憶體,此時主記憶體中共享變數x的值就被更新為了2。

從整體上來看,上述的兩個過程實質上是執行緒A在向執行緒B傳送訊息,而且這個通訊過程必須要經過主記憶體。JMM通過控制主記憶體與每個執行緒的本地記憶體之間的互動,來為java程式設計師提供記憶體可見性保證。

2.Java記憶體模型的記憶體屏障

為了保證記憶體的可見性,java編譯器在生成指令序列的適當位置會插入記憶體屏障指令類禁止特定型別的處理器重排序,java記憶體模型(JMM)把記憶體屏障指令分為4類:

  • LoadLoad(Load1,LoadLoad,Load2):確保load1資料的裝載先於load2及所有後序裝載指令的裝載。

  • LoadStore(Load1,LoadStore,Store2):確保Load1資料的裝載先於Store2及所有後序儲存指令重新整理記憶體。

  • StoreStore(Store1,StoreStore,Store2):確保Store1資料重新整理記憶體先於Store2及所有後序儲存指令重新整理記憶體。

  • StoreLoad(Store1,StoreLoad,Load2):確保Store1資料重新整理記憶體先於Load2及所有後序裝載指令的裝載。該遮蔽指令會使該遮蔽之前的所有記憶體訪問指令執行完成後才執行遮蔽之後的記憶體訪問指令。並且這個指令是一個全能的指令,同時具備以上三個記憶體遮蔽指令的功能。

3.Java記憶體模型中的重排序

重排序是指編譯器和處理器為了優化程式效能而對指令序列進行重排序的一種手段。重排序分為3種類型:

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

從java原始碼到最終實際執行的指令序列,會經歷下面3種重排序:

上述1屬於編譯器重排序,編譯器將java原始碼編譯成位元組碼時進行一次重排序,2和3屬於處理器重排序。這些重排序可能會導致多執行緒程式出現記憶體可見性問題。對於編譯器,JMM的編譯器重排序規則會禁止特定型別的編譯器重排序。對於處理器重排序,JMM的處理器重排序規則會要求java編譯器在生成位元組碼指令序列時,插入特定型別的記憶體屏障指令,通過記憶體屏障指令來禁止特定型別的處理器重排序。

JMM屬於語言級別的記憶體模型,它確保在不同的編譯器和不同的處理器平臺上,通過禁止特定型別的編譯器重排序和處理器重排序,為程式設計師提供一致的的記憶體可見性保證。

3.1 資料依賴性

如果兩個操作訪問同一個共享變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。資料依賴性分為以下3種類型:

名稱 程式碼示例 說明
寫後讀 a= 1 ; b = a 寫一個變數之後,再讀這個位置的變數值
寫後寫 a = 1 ; a = 2 寫一個變數之後,再繼續寫這個記憶體位置的變數
讀後寫 a = b ; b = 1 讀一個變數之後,再寫剛讀的這個變數

上面的3種情況,只要重排序兩個操作的執行順序,程式的結果就可能發生改變。

上面介紹過,編譯器和處理器可能會對操作進行重排序。但是編譯器和處理器進行重排序時會遵循資料依賴性規則,只要兩個操作之間具有資料依賴性,那麼編譯器和處理器就不會對這兩個操作進行重排序,編譯器和處理器重排序的原則上是不改變程式的執行結果,從而提高程式執行效能。

這裡所說的資料依賴性規則僅針對單個處理器中執行的指令序列和單個執行緒中執行的操作,不同的處理器之間和不同的執行緒之間的資料依賴性是不被編譯器和處理器考慮的。

3.2 as-if-serial語義

as-if-serial語義是指:不管怎麼重排序,單執行緒的執行結果是不能被改變的。編譯器和處理器都必須遵循as-if-serial語義。

為了遵循as-if-serial語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。但如果操作之間不存在資料依賴關係,就可以被編譯器和處理器重排序。

as-if-serial語義把單執行緒程式給保護了起來,遵循as-if-serial語義的編譯器和處理器共同為編寫單執行緒程式的程式設計師創造了一個幻覺:單執行緒程式是按程式程式碼的先後順序來執行的。as-if-serial語義使程式設計師在單執行緒下無需擔心重排序會影響程式執行結果,也無需擔心記憶體可見性問題。

3.3 重排序對多執行緒的影響

重排序會可能影響多執行緒程式的執行結果,請看下面的示例程式碼:

public class ReorderExample{
    int a = 0; 
    boolean flag = false; 
    
    @Test
    public void writer(){
        a = 1;              //1
        flag = true;     //2
    }
    
    @Test
    public void reader(){
        if(flag){           //3
            int i = a;     //4
            System.out.print(i);
        }
    }
}

flag變數是個標記,用來標識變數a是否被寫入。這裡我們假設有兩個執行緒A和B,執行緒A首先執行writer方法,隨後執行緒B執行reader方法。問題是執行緒B在執行操作4時,能否看到執行緒A在操作1對共享變數a的寫入呢?

答案是否定的,並不一定能看到。

由於操作1和操作2不存在資料依賴關係,編譯器和處理器可以對這兩個操作重排序;同理,操作3和操作4也不存在資料依賴關係,編譯器和處理器也可以對這兩個操作重排序。下面我們先來看下,當操作1和操作2重排序時,會產生什麼效果。程式執行時序圖如下:

如上圖,操作1和操作2做了重排序。程式執行時,執行緒A首先將標記變數flag寫為true,隨後執行緒B讀取這個變數,由於條件為真,執行緒B將讀取共享變數a,而此時,共享變數a還沒有被執行緒A寫入,所以多執行緒程式的語義就被重排序破壞了。

下面再看下,當操作3和操作4重排序時會產生什麼效果。下面是操作3和操作4重排序後程序的執行時序圖:

在程式中,操作3和操作4存在控制依賴關係。當代碼中存在控制依賴性時,會影響指令序列執行的並行度,為此,編譯器和處理器會採用猜測執行來克服控制相關性對並行度的影響。以處理器的猜測執行為例,執行執行緒B的處理器可以提前讀取共享變數a,然後會把共享變數a的值儲存到一個名為重排序緩衝(Reorder Buffer,ROD)的硬體快取中。當操作3的條件為真時,就把儲存到ROB中的共享變數a的值寫入到變數i中。

從上圖中我們也可以看出,猜測執行實質上對操作3和操作4做了重排序。重排序破壞了多執行緒程式的語義。

在單執行緒程式中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因);但在多執行緒中程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果。

4. happens-before原則

從JDK1.5開始,Java使用新的JSR-133記憶體模型,該模型使用happens-before原則來闡述操作之間的記憶體可見性。在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在happens-before關係。這裡提到的兩個操作既可以是在單執行緒內,也可以在多執行緒之間。

happens-before規則如下:

  • 程式順序規則(Program Order Rule): 一個執行緒中的每個操作先行發生於該執行緒中的後序任意操作。
  • 監視器鎖規則(Monitor Lock Rule): 對一個鎖的解鎖先行發生於隨後對這個鎖的加鎖。
  • volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫,先行發生於任意後序對這個volatile變數的讀。
  • 傳遞性(Transitivity):如果 A happens-before B,且 B happens-before C,那麼 A happens-before C。
  • 執行緒啟動規則(Thread Start Rule):Thread物件的start()方法先行發生於此執行緒的每一個動作。
  • 執行緒終止規格(Thread Termination Rule):執行緒中所有的操作都優先發生於此執行緒的終止操作。‘’
  • 執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生。
  • 物件終結規則(Finalizer Rule):一個物件初始化完成先行發生於它的finalize()方法的開始。

happens-before與JMM的關係如下圖所示:

5.順序一致性記憶體模型

順序一致性記憶體模型是一個理論參考模型,在設計的時候,處理器的記憶體模型和程式語言的記憶體模型都會以順序一致性記憶體模型作為參照。這個記憶體模型是一個理想化了的理論參考模型。它為程式設計師提供了一個極強的記憶體可見性保證。順序一致性記憶體模型有兩大特性:

  1. 一個執行緒中的所有操作必須按照程式程式碼的順序來執行。
  2. 不管程式是否同步,所有執行緒都只能看到一個單一的操作執行順序。在順序一致性記憶體模型中,每個操作都必須原子執行且立刻對所有執行緒可見。

順序一致性記憶體模型為程式設計師提供的檢視如下圖所示:

在概念上,順序一致性模型有一個單一的全域性記憶體,這個記憶體通過一個左右擺動的開關可以連線到任意一個執行緒,同時每個執行緒必須按照程式的順序來執行記憶體讀/寫操作。從上面的示意圖可以看出,在任意時間最多隻能有一個執行緒可以連線到記憶體。當多個執行緒併發執行時,圖中的開關裝置能夠把所有 執行緒的所有記憶體讀/寫操作序列化。

為了便於大家更好的理解,下面通過兩個示意圖來對順序一致性模型的特性做進一步的說明。

假設有兩個執行緒A和B併發執行。其中A執行緒有三個操作,它們在程式中的順序是:A1 -> A2 -> A3。B執行緒也有三個操作,它們在程式中的順序是:B1 -> B2 -> B3。

假設這兩個程式使用監視器鎖來正確同步:A執行緒的三個操作執行完後釋放監視器鎖,隨後B執行緒獲取同一個監視器鎖。那麼程式在順序一致性模型中的執行效果如下圖所示:

現在我們再假設這兩個執行緒沒有做同步,那麼程式在順序一致性模型中的執行效果如下圖所示:

未同步程式在順序一致性模型中雖然整體執行順序是無序的,因為不做同步處理,執行緒B並不會等到執行緒A的所有操作都執行完後才執行,而是執行緒B會和執行緒A搶佔CPU資源,但所有執行緒都只能看到一個一致的整體執行順序。以上圖為例,執行緒A和執行緒B看到的執行順序都是:B1 -> A1 ->A2 -> B2 -> A3 -> B3。之所以能得到這個保證是因為順序一致性記憶體模型中的每個操作必須立即對任意執行緒可見。

但是,在JMM中就沒有這個保證。未同步程式在JMM中不但整體的執行順序是無序的,而且所有執行緒看到的操作執行順序也可能不一致。比如,當前執行緒把寫過的資料快取在本地記憶體中,在沒有重新整理到主記憶體之前,這個操作僅對當前執行緒可見;從其他執行緒的角度來觀察,會認為這個寫操作根本沒有被當前執行緒執行。只有當前執行緒把本地記憶體寫過的資料重新整理到主記憶體之後,這個寫操作才對其他執行緒可見。在這種情況下,當前執行緒和其他執行緒看到的操作執行順序可能不一致。

5.1 同步程式的順序一致性效果

下面,對前面的示例程式 ReorderExample用鎖來同步,看看正確同步的程式如何具有順序一致性。

public class ReorderExample{
    int a = 0; 
    boolean flag = false; 
    
    @Test
    public synchronized void writer(){  //獲取鎖
        a = 1;              
        flag = true;    
    }   //釋放鎖
    
    @Test
    public synchronized void reader(){  //獲取鎖
        if(flag){          
            int i = a;     
            System.out.print(i);
        }
    }  //釋放鎖
}

在上面的示例程式碼中,假設執行緒A執行writer方法後,執行緒B執行reader方法,這是一個正確同步的多執行緒程式。根據JMM規範,該程式的執行結果將與該程式在順序一致性記憶體模型中的執行結果相同。下面是該程式在JMM記憶體模型和順序一致性記憶體模型中的執行時序對比圖:

順序一致性記憶體模型中,所有的操作完全按程式順序序列執行。而在JMM中,臨界區內的程式碼指令執行序列可以被重排序。但JMM不允許臨界區內的程式碼逃逸到臨界區之外,那樣會破壞監視器的語義。JMM會在進入臨界區和退出臨界區這兩個關鍵時間點做一些特殊的處理,使得執行緒在這兩個時間點具有與順序一致性記憶體模型相同的記憶體檢視。雖然執行緒A在臨界區內做了重排序,但是由於監視器鎖互斥執行的特性,這裡的執行緒B無法感知到執行緒A在臨界區內做了重排序。這種重排序既提高了執行效率,又沒有改變程式的執行結果。

從這裡我們看到,JMM具有實現上的基本原則為:在不改變(正確同步的)程式執行結果的前提下,儘可能地為編譯器和處理器的優化開啟方便之門。

5.2 未同步程式的執行特性

對於未同步或未正確同步的多執行緒程式,JMM只提供最小安全性:執行緒執行時讀取到的值,要麼是之前某個執行緒寫入的值,要麼就是預設值(0,null,false),JMM保證執行緒讀操作讀取到的值不會無中生有。為了實現最小安全性,JMM在堆上為物件分配記憶體時,首先會對記憶體空間進行清零,然後才會在上面分配物件。因此,在已清零的記憶體空間分配物件時,域(欄位)的預設初始化已經完成了。

JMM不保證未同步或未正確同步的程式的執行結果與該程式在順序一致性記憶體模型中的執行結果一致。因為如果想要保住執行結果一致,JMM需要禁止大量的編譯器和處理器的優化,這對程式的效能會產生很大的影響。而且,未同步程式在這兩個模型中的執行結果一致也沒有什麼實質的意義。

未同步程式在JMM中執行時,整體上是無序的,其執行結果也是無法預知的。未同步程式在兩個模型中的執行特性有如下三個方面的差異:

1.順序一致性記憶體模型保證單執行緒內的操作是按程式的順序執行,而JMM不保證單執行緒內的操作是按程式順序執行的。
2.順序一致性記憶體模型保證所有執行緒只能看到一致的操作執行順序,而JMM是不保證這一點的。
3.JMM不保證對64位的long和double型變數的寫操作具有原子性,而順序一致性保證對所有的記憶體讀/寫操作都具有原子性。

第三個差異與處理器匯流排的工作機制密切相關。在計算機中,資料通過匯流排在處理器和記憶體之間傳遞。每次處理器和記憶體之間的資料傳遞都是通過一系列的步驟來完成的,這一系列的步驟稱之為匯流排事務。匯流排事務包括讀事務和寫事務。讀事務從記憶體傳送資料到處理器,寫事務從處理器傳送資料到記憶體,每個事務會讀/寫記憶體中的一個或多個物理上連續的字記憶體空間。這裡的關鍵是,匯流排會同步試圖併發使用匯流排的事務。在一個處理器執行匯流排事務期間,匯流排會禁止其他的處理器和IO裝置執行記憶體的讀/寫操作。下面,用示意圖說明匯流排的工作機制:

由圖可知,假設處理器A,B和C同時向匯流排發起匯流排事務,這時匯流排仲裁會對競爭做出裁決,這裡假設匯流排在仲裁後判定處理器A在競爭中獲勝。此時處理器A繼續它的匯流排事務,而其他的處理器則要等待處理器A的匯流排事務完成後才能再次執行記憶體訪問。假設處理器A執行匯流排事務期間,處理器B向匯流排發起了匯流排事務請求,此時處理器B的匯流排請求是會被禁止的。

匯流排的這個工作機制可把所有處理器對記憶體的訪問以序列化方式來執行。在任意時刻,最多隻允許一個處理器訪問記憶體。這個特性確保了單個匯流排事務之中的記憶體讀/寫操作具有原子性。

在一些32位處理器上,如果要求對64位資料的寫操作具有原子性,會有​比較大的開銷。為了照顧這種處理器,java語言規範鼓勵但不強求JVM對64位的long和double型別的變數的寫操作具有原子性。當JVM在這種處理器上執行時,可能會把64位的long或double型別的變數的寫操作拆分成兩個32位的寫操作來執行。這兩個32位的寫操作可能會別分配到不同的匯流排事務中執行,此時對這個64位變數的寫操作就不具有原子性。

參考書籍:

1.Java併發程式設計的藝術:本文主要整理了此書,這本書對Java記憶體模型的講解已經很透徹,所以將書中內容做了整理。

2.深入理解Java虛擬機器:參考了此書Java記憶體模型的部分,此書的8個記憶體互動指令在JSR133,也就是從JDK1.5起就不再使用了,所以本文不再介紹