Java記憶體模型

strind發表於2024-10-06

1. 硬體的效率與一致性

物理機遇到的併發問題與虛擬機器中的情況有很多相似之處,物理機對併發的處理方案對虛擬機器的實現也有相當大的參考意義。

“讓計算機併發執行若干個運算任務”與“更充分地利用計算機處理器的效能”之間的因果關係,看起來理所當然,實際上它們之間的關係並沒有想象中那麼簡單,其中一個重要的複雜性的來源是絕大多數的運算任務都不可能只靠處理器“計算”就能完成。處理器至少要與記憶體互動,如讀取運算資料、儲存運算結果等,這個IO操作就是很難消除的(無法僅靠暫存器來完成所有運算任務)。由於計算機的儲存裝置與處理器的運算速度有著幾個數量級的差距,所以現代計算機系統都不得不加入一層或多層讀寫速度儘可能接近處理器運算速度的快取記憶體(Cache)來作為記憶體與處理器之間的緩衝;將運算需要使用的資料複製到快取中,讓運算能快速進行,當運算結束後再從快取同步回記憶體之中,這樣處理器就無須等待緩慢的記憶體讀寫了。

基於快取記憶體的儲存互動很好地解決了處理器與記憶體速度之間的矛盾,但是也為計算機系統帶來更高的複雜度,它引人了一個新的問題:快取一致性(Cache Coherence)。在多路處理器系統中,每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體(Main Memory),這種系統稱為共享記憶體多核系統(Shared Memory Multiprocessors System)。當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自的快取資料不一致。如果真的發生這種情況,那同步回到主記憶體時該以誰的快取資料為準呢?

為了解決一致性的問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議來進行操作,這類協議有MSI、MESI(Illinois Protocol)、MOSl、Synapse、Firefly及Dragon Protocol等。

對於“記憶體模型”一詞,它可以理解為在特定的操作協議下,對特定的記憶體或快取記憶體進行讀寫訪問的過程抽象。不同架構的物理機器可以擁有不一樣的記憶體模型,而Java虛擬機器也有自己的記憶體模型,並且與這裡介紹的記憶體訪問操作及硬體的快取訪問操作具有高度的可類比性。

2. Java記憶體模型

《Java 虛擬機器規範》中曾試圖定義一種“Java 記憶體模型”(Java Memory Model JMM)來遮蔽各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問效果。在此之前,主流程式語言(如C和C++等)直接使用物理硬體和作業系統的記憶體模型。因此,由於不同平臺上記憶體模型的差異,有可能導致程式在一套平臺上併發完全正常,而在另外一套平臺上併發訪問卻經常出錯,所以在某些場景下必須針對不同的平臺來編寫程式。

2.1 主記憶體與工作記憶體

Java記憶體模型的主要目的是定義程式中各種變數的訪問規則,即關注在虛擬機器中把變數值儲存到記憶體和從記憶體中取出變數值這樣的底層細節。此處的變數與Java程式設計中所說的變數有所區別,它包括了例項欄位、靜態欄位和構成陣列物件的元素,但是不包括區域性變數與方法引數,因為後者是執行緒私有的,不會被共享,自然就不會存在競爭問題。為了獲得更好的執行效能,Java記憶體模型並沒有限制執行引擎使用處理器的特定暫存器或快取來和主記憶體進行互動,也沒有限制即時編譯器是否要進行調整程式碼執行順序這類最佳化措施。

Java記憶體模型規定了所有的變數都儲存在主記憶體(Main Memory)中(此處的主記憶體與介紹物理硬體時提到的主記憶體名字一樣,兩者也可以類比,但物理上它僅是虛擬機器記憶體的一部分)。每條執行緒還有自己的工作記憶體(Working Memory,可與前面講的處理器快取記憶體類比),執行緒的工作記憶體中儲存了被該執行緒使用的變數的主記憶體副本。執行緒對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的資料。不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要透過主記憶體來完成。

這裡所說的主記憶體、工作記憶體與Java記憶體區域中的Java堆、棧、方法區等並不是同一個層次的對記憶體的劃分,這兩者基本上是沒有任何關係的。

如果兩者一定要勉強對應起來,那麼從變數、主記憶體、工作記憶體的定義來看,主記憶體主要對應於Java堆中的物件例項資料部分,而工作記憶體則對應於虛擬機器棧中的部分割槽域。

從更基礎的層次上說,主記憶體直接對應於物理硬體的記憶體,而為了獲取更好的執行速度,虛擬機器(或者是硬體、作業系統本身的最佳化措施)可能會讓工作記憶體優先儲存於暫存器和快取記憶體中,因為程式執行時主要訪問的是工作記憶體。

2.2 記憶體間的互動

關於主記憶體與工作記憶體之間具體的互動協議,即一個變數如何從主記憶體複製到工作記憶體、如何從工作記憶體同步回主記憶體這一類的實現細節,Java記憶體模型中定義了以下8種操作來完成。Java虛擬機器實現時必須保證下面提及的每一種操作都是原子的、不可再分的(對於 double 和 long 型別的變數來說,load、store、read 和 write 操作在某些平臺上允許有例外)。

  1. lock(鎖定):作用於主記憶體的變數,它把一個變數標識為一條執行緒獨佔的狀態。

  2. unlock(解鎖):作用於主記憶體的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。

  3. read(讀取):作用於主記憶體的變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的 load 動作使用。

  4. load(載人):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。

  5. use(使用):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。

  6. assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收的值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。

  7. store(儲存):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳送到主記憶體中,以便隨後的 write 操作使用。

  8. write(寫人):作用於主記憶體的變數,它把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中。

    如果要把一個變數從主記憶體複製到工作記憶體,那就要按順序執行read和load操作。

    如果要把變數從工作記憶體同步回主記憶體,就要按順序執行store和write操作。(不一定連續)

除此之外,Java記憶體模型還規定了在執行上述8種基本操作時必須滿足如下規則:

  1. 不允許read和load、store和write操作之一單獨出現,即不允許一個變數從主記憶體讀取了但工作記憶體不接受,或者工作記憶體發起回寫了但主記憶體不接受的情況出現。
  2. 不允許一個執行緒丟棄它最近的assign操作,即變數在工作記憶體中改變了之後必須把該變化同步回主記憶體。
  3. 不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從執行緒的工作記憶體同步回主記憶體中。
  4. 一個新的變數只能在主記憶體中“誕生”,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數,換句話說就是對一個變數實施use、store操作之前,必須先執行 assign 和 load 操作。
  5. 一個變數在同一個時刻只允許一條執行緒對其進行1ock操作,但lock操作可以被同一條執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖。
  6. 如果對一個變數執行lock操作,那將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行load或assign操作以初始化變數的值。
  7. 如果一個變數事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他執行緒鎖定的變數。
  8. 對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store write 操作)。

2.3 對volatile 型別變數的特殊規則

關鍵字 volatile 可以說是Java虛擬機器提供的最輕量級的同步機制。

當一個變數被定義成volatile之後,它將具備兩項特性:

第一項是保證此變數對所有執行緒的可見性,這裡的“可見性”是指當一條執行緒修改了這個變數的值,新值對於其他執行緒來說是可以立即得知的。而普通變數並不能做到這一點,普通變數的值線上程間傳遞時均需要透過主記憶體來完成。比如,執行緒A修改一個普通變數的值,然後向主記憶體進行回寫另外一條執行緒B線上程A回寫完成了之後再對主記憶體進行讀取操作,新變數值才會對執行緒B 可見。

注意:volatile變數對所有執行緒是立即可見的,對volatile 變數所有的寫操作都能立刻反映到其他執行緒之中。這是正確的,但是“基於volatile 變數的運算在併發下是執行緒安全的”則是錯誤的。volatile 變數在各個執行緒的工作記憶體中是不存在一致性問題的,但是Java裡面的運算運算子並非原子操作,這導致 volatile 變數的運算在併發下一樣是不安全的。

由於volatile變數只能保證可見性,在不符合以下兩條規則的運算場景中,我們仍然要透過加鎖(使用 synchronized,java.util.concurrent 中的鎖或原子類)來保證原手性:

  1. 運算結果並不依賴變數的當前值,或者能夠確保證只有單一的執行緒修改變數的值
  2. 變數不需要與其他的狀態變數共同參與不變約束。

第二項是禁止指令重排序最佳化,普通的變數僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變數賦值操作的順序與程式程式碼中的執行順序一致。因為在同一個執行緒的方法執行過程中無法感知到這點,這就是Java記憶體模型中描述的所謂“執行緒內表現為序列的語義”(Within-Thread As-If-Serial Semantics)。

現在,我們來看看Java記憶體模型中對volatile 變數定義的特殊規則的定義。假定T表示一個執行緒,V和W分別表示兩個 volatile 型變數,那麼在進行read、load、use、assign、store 和 write 操作時需要滿足如下規則:

  1. 只有當執行緒T對變數V執行的前一個動作是load的時候,執行緒T才能對變數V執行use動作;並且,只有當執行緒T對變數V執行的後一個動作是use的時候,執行緒T才能對變數V執行load動作。執行緒T對變數V的use動作可以認為是和執行緒T對變數V的load、read動作相關聯的,必須連續且一起出現。
    這條規則要求在工作記憶體中,每次使用V前都必須先從主記憶體重新整理最新的值用於保證能看見其他執行緒對變數V所做的修改。
  2. 只有當執行緒T對變數V執行的前一個動作是assign的時候,執行緒T才能對變數V執行store動作;並且,只有當執行緒T對變數V執行的後一個動作是store的時候,執行緒T才能對變數V執行assign動作。執行緒T對變數V的assign動作可以認為是和執行緒T對變數V的store、write 動作相關聯的,必須連續且一起出現。
    這條規則要求在工作記憶體中,每次修改V後都必須立刻同步回主記憶體中,用於保證其他執行緒可以看到自己對變數V所做的修改。
  3. 假定動作A是執行緒T對變數V實施的use或assign動作,假定動作F是和動作A相關聯的load或store動作,假定動作P是和動作F相應的對變數V的read或write動作;與此類似,假定動作B是執行緒T對變數W實施的use或assign動作,假定動作G是和動作B相關聯的load或store動作,假定動作Q是和動作G相應的對變數W的read或write動作。如果A先於B,那麼P先於Q。
    這條規則要求 volatile 修飾的變數不會被指令重排序最佳化,從而保證程式碼的執行順序與程式的順序相同。

2.4 對long和double型變數的特殊規則

Java記憶體模型要求 lock、unlock、read、load、assign、use、store、write 這八種操作都具有原子性,但是對於64位的資料型別(long和double),在模型中特別定義了一條寬鬆的規定:允許虛擬機器將沒有被volatile修飾的64位資料的讀寫操作劃分為兩次32位的操作來進行,即允許虛擬機器實現自行選擇是否要保證64位資料型別的load、store、read 和write 這四個操作的原子性,這就是所謂的“long和 double 的非原子性協定”(Non-Atomic Treatment of double and long Variables )。

如果有多個執行緒共享一個並未宣告為volatile的long或double 型別的變數,並且同時對它們進行讀取和修改操作,那麼某些執行緒可能會讀取到一個既不是原值,也不是其他執行緒修改的值,僅是一個代表了“半個變數”的數值。不過這種讀取到“半個變數”的情況是非常罕見的,經過實際測試,在目前主流平臺下商用的64位Java虛擬機器中並不會出現非原子性訪問行為,但是對於32位的Java虛擬機器,譬如比較常用的32位x86平臺下的HotSpot虛擬機器,對 long 型別的資料確實存在非原子性訪問的風險。從JDK9起,HotSpot增加了一個實驗性的引數-XX:+AlwaysAtomicAccesses 來約束虛擬機器對所有資料型別進行原子性的訪問。而針對double型別,由於現代中央處理器中一般都包含專門用於處理浮點資料的浮點運算器(Floating Point Unit,FPU),用來專門處理單、雙精度的浮點資料,所以哪怕是32位虛擬機器中通常也不會出現非原子性訪問的問題,實際測試也證實了這一點。在實際開發中,除非該資料有明確可知的執行緒競爭,否則我們在編寫程式碼時一般不需要因為這個原因刻意把用到的long 和 double變數專門宣告為 volatile。

2.5 原子性,有序性,可見性

Java記憶體模型是圍繞著在併發過程中如何處理原子性、可見性和有序性這三個特徵來建立的,我們逐個來看一下哪些操作實現了這三個特性。

  1. 原子性(Atomicity)
    由Java 記憶體模型來直接保證的原子性變數操作包括read、load、assign、use、store 、write 這六個,我們大致可以認為,基本資料型別的訪問、讀寫都是具備原子性的(例外就是long和double的非原子性協定)。
    如果應用場景需要一個更大範圍的原子性保證(經常會遇到),Java記憶體模型還提供了lock和unlock操作來滿足這種需求,儘管虛擬機器未把lock和unlock操作直接開放給使用者使用,但是卻提供了更高層次的位元組碼指令monitorenter 和monitorexit來隱式地使用這兩個操作。這兩個位元組碼指令反映到Java程式碼中就是同步塊——synchronized 關鍵字,因此在synchronized 塊之間的操作也具備原子性。

  2. 可見性(Visibility)
    可見性就是指當一個執行緒修改了共享變數的值時,其他執行緒能夠立即得知這個修改。Java記憶體模型是透過在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值這種依賴主記憶體作為傳遞媒介的方式來實現可見性的,無論是普通變數還是volatile變數都是如此。普通變數與volatile變數的區別是,volatile的特殊規則保證了新值能立即同步到主記憶體,以及每次使用前立即從主記憶體重新整理。因此我們可以說volatile保證了多執行緒操作時變數的可見性,而普通變數則不能保證這一點。

    除了 volatile 之外,Java還有兩個關鍵字能實現可見性,它們是synchronized和final,同步塊的可見性是由“對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行 store、write 操作)”這條規則獲得的。而 final關鍵字的可見性是指:被 final修飾的欄位在構造器中一旦被初始化完成,並且構造器沒有把“this”的引用傳遞出去(this引用逃逸是一件很危險的事情,其他執行緒有可能透過這個引用訪問到“初始化了一半”的物件),那麼在其他執行緒中就能看見final欄位的值。

  3. 有序性(Ordering)
    Java程式中天然的有序性可以總結為一句話:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。前半句是指“執行緒內似表現為序列的語義”( Within-Thread As-If-Serial Semantics),後半句是指“指令重排序”現象和“工作記憶體與主記憶體同步延遲”現象。

    Java語言提供了volatile和synchronized兩個關鍵字來保證執行緒之間操作的有序性。volatile 關鍵字本身就包含了禁止指令重排序的語義,而synchronized 則是由“一個變數在同一個時刻只允許一條執行緒對其進行1ock操作”這條規則獲得的,這個規則決定了持有同一個鎖的兩個同步塊只能序列地進入。

2.6 Happens-Before原則

如果Java記憶體模型中所有的有序性都僅靠volatile和synchronized來完成,那麼有很多操作都將會變得非常囉嗦,但是我們在編寫Java併發程式碼的時候並沒有察覺到這一點,這是因為Java語言中有一個“先行發生”(Happens-Before)的原則。這個原則非常重要,它是判斷資料是否存在競爭,執行緒是否安全的非常有用的手段。依賴這個原則,我們可以透過幾條簡單規則一攬子解決併發環境下兩個操作之間是否可能存在衝突的所有問題,而不需要陷入 Java 記憶體模型苦澀難懂的定義之中。

先行發生是Java記憶體模型中定義的兩項操作之間的偏序關係,比如說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了記憶體中共享變數的值、傳送了訊息、呼叫了方法等。

下面是Java記憶體模型下一些“天然的”先行發生關係,這些先行發生關係無須任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推匯出來,則它們就沒有順序性保障,虛擬機器可以對它們隨意地進行排序。

  1. 程式次序規則(Program Order Rule):在一個執行緒內,按照控制流順序,書寫在前面的操作先行發生於書寫在後面的操作。注意,這裡說的是控制流順序而不是程程式碼順序,因為要考慮分支、迴圈等結構。
  2. 管程鎖定規則(Monitor Lock Rule):一個unlock 操作先行發生於後面對同一個鎖的lock 操作。這裡必須強調的是“同一個鎖”,而“後面”是指時間上的先後。
  3. volatile 變數規則(Volatile Variable Rule):對一個 volatile 變數的寫操作先行發生於後面對這個變數的讀操作,這裡的“後面”同樣是指時間上的先後。
  4. 執行緒啟動規則(Thread Start Rule):Thread 物件的 start()方法先行發生於此執行緒的每一個動作。
  5. 執行緒終止規則(Thread Termination Rule):執行緒中的所有操作都先行發生於對此執行緒的終止檢測,我們可以透過Thread::join()方法是否結束、Thread::isAlive( )的返回值等手段檢測執行緒是否已經終止執行。
  6. 執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以透過Thread::interrupted()方法檢測到是否有中斷髮生。
  7. 物件終結規則(Finalizer Rule):一個物件的初始化完成(建構函式執行結束)先行發生於它的 finalize()方法的開始。
  8. 傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

相關文章