1. 程式人生 > 實用技巧 >【Java虛擬機器6】Java記憶體模型(Java篇)

【Java虛擬機器6】Java記憶體模型(Java篇)

什麼是Java記憶體模型

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

定義Java記憶體模型並非一件容易的事情,這個模型必須定義得足夠嚴謹,才能讓Java的併發記憶體訪問操作不會產生歧義;但是也必須定義得足夠寬鬆

,使得虛擬機器的實現能有足夠的自由空間去利用硬體的各種特性(暫存器、快取記憶體和指令集中某些特有的指令)來獲取更好的執行速度。經過長時間的驗證和修補,直至JDK 5(實現了JSR-133)釋出後,Java記憶體模型才終於成熟、完善起來了。

主記憶體與工作記憶體

Java記憶體模型的主要目的是定義程式中各種變數的訪問規則,即關注在虛擬機器中把變數值儲存到記憶體和從記憶體中取出變數值這樣的底層細節。此處的變數(Variables)與Java程式設計中所說的變數有所區別,它包括了例項欄位、靜態欄位和構成陣列物件的元素,但是不包括區域性變數與方法引數,因為後者是執行緒私有的,不會被共享,自然就不會存在競爭問題。

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

8大原子指令

JSR133已經放棄這種描述,所以不再介紹。

原子性、可見性、有序性

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

1、原子性(Atomicity)
由Java記憶體模型來直接保證原子性變數操作包括read、load、assign、use、store、write,大致可以認為基本資料型別的訪問讀寫是具備原子性的。如果應用場景需要一個更大的原子性保證,Java記憶體模型還提供了lock和unlock,儘管虛擬機器沒有把lock和unlock操作直接開放給使用者使用,但是卻提供了更高層次的位元組碼指令monitorenter和monitorexit來隱式地使用這兩個操作,這兩個位元組碼指令反映到Java程式碼中就是同步塊----synchronized關鍵字

2、可見性(Visibility)
可見性是指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。volatile其實已經詳細寫了這一點,其實synchronized關鍵字也是可以實現可見性的,synchronized的可見性是由"對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中"這條規則獲得的。另外,final關鍵字也可以實現可見性,因為被final修飾的欄位在構造器中一旦初始化完成,並且構造器沒有把this傳遞出去,那在其他執行緒中就能看見final欄位的值。

3、有序性(Ordering)
Java程式中天然的有序性可以總結為一句話:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒中觀察另外一個執行緒,所有的操作都是無須的。前半句是指"執行緒內表現為穿行的語義",後半句是指"指令重排序"和"工作記憶體與主記憶體同步延遲"現象。Java語言提供了volatile和synchronized兩個關鍵字來保證執行緒之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由"一個變數在同一時刻只允許一條執行緒對其進行lock操作"這條規則獲得的,這條規則規定了持有同一個鎖的兩個同步塊只能序列地進入。

volatile的Java語義

關鍵字volatile可以說是Java虛擬機器提供的最輕量級的同步機制。
一個變數被定義為volatile後,它將具備兩種特性:
1、保證此變數對所有執行緒的"可見性",所謂"可見性"是指當一條執行緒修改了這個變數的值,新值對於其它執行緒來說都是可以立即得知的,而普通變數不能做到這一點,普通變數的值在線上程間傳遞均需要通過主記憶體來完成,關於volatile關鍵字的操作請參見volatile關鍵字使用舉例,再強調一遍,volatile只保證了可見性,並不保證基於volatile變數的運算在並罰下是安全的

2、第二個語義是禁止指令重排序優化,普通變數僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變數賦值操作的順序與程式程式碼中的執行順序一致。

總結一下Java記憶體模型對volatile變數定義的特殊規則:
1、在工作記憶體中,每次使用某個變數的時候都必須線從主記憶體重新整理最新的值,用於保證能看見其他執行緒對該變數所做的修改之後的值
2、在工作記憶體中,每次修改完某個變數後都必須立刻同步回主記憶體中,用於保證其他執行緒能夠看見自己對該變數所做的修改
3、volatile修飾的變數不會被指令重排序優化,保證程式碼的執行順序與程式順序相同

關於volatile的底層實現可以參考我的上兩篇文章:
【Java虛擬機器4】Java記憶體模型(硬體層面的併發優化基礎知識--快取一致性問題)
【Java虛擬機器5】Java記憶體模型(硬體層面的併發優化基礎知識--指令亂序問題)
搞明白硬體層面的原理,volatile原理就太簡單了:
簡單說:在Intel X86 Windows環境中volatile底層就是轉為了lock彙編指令,這個指令即保證了無法指令重排(記憶體屏障),也保證了快取一致性(匯流排鎖和快取鎖共同實現)

happens before先行發生原則

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

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

  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的結論。

示例1

i=1; (線上程A中執行)
j=i; (線上程B中執行)
i=2; (線上程C中執行)

假設執行緒A中的操作“i=1”先行發生於執行緒B的操作“j=i”,那我們就可以確定線上程B的操作執行後,變數j的值一定是等於1,得出這個結論的依據有兩個:
一是根據先行發生原則,“i=1”的結果可以被觀察到;
二是執行緒C還沒登場,執行緒A操作結束之後沒有其他執行緒會修改變數i的值。
現在再來考慮執行緒C,我們依然保持執行緒A和B之間的先行發生關係,而C出現線上程A和B的操作之間,但是C與B沒有先行發生關係,那j的值會是多少呢?
答案是不確定的,1和2都有可能,因為執行緒C對變數i的影響可能會被執行緒B觀察到,也可能不會,這時候執行緒B就存在讀取到過期資料的風險,不具備多執行緒安全性。

示例2

private int i = 0;

public void setI(int i) {
    this.i = i;
}

public int getI() {
    return i;
}

假設存線上程A和B,執行緒A先(時間上的先後)呼叫了setValue(1),然後執行緒B呼叫了同一個物件的getValue(),那麼執行緒B收到的返回值是什麼?

我們依次分析一下先行發生原則中的各項規則。由於兩個方法分別由執行緒A和B呼叫,不在一個執行緒中,所以程式次序規則在這裡不適用;
由於沒有同步塊,自然就不會發生lock和unlock操作,所以管程鎖定規則不適用;
由於value變數沒有被volatile關鍵字修飾,所以volatile變數規則不適用;
後面的執行緒啟動、終止、中斷規則和物件終結規則也和這裡完全沒有關係。

因為沒有一個適用的先行發生規則,所以最後一條傳遞性也無從談起,因此我們可以判定,儘管執行緒A在操作時間上先於執行緒B,但是無法確定執行緒B中getValue()方法的返回結果,換句話說,這裡面的操作不是執行緒安全的。

那怎麼修復這個問題呢?我們至少有兩種比較簡單的方案可以選擇:
1、把getter/setter方法都定義為synchronized方法,這樣就可以套用管程鎖定規則;
2、把value定義為volatile變數,由於setter方法對value的修改不依賴value的原值,滿足volatile關鍵字使用場景,這樣就可以套用volatile變數規則來實現先行發生關係。

參考

《深入理解Java虛擬機器:JVM高階特性與最佳實踐》--周志明老師