1. 程式人生 > >12.《深入理解Java虛擬機器》Java記憶體模型JMM

12.《深入理解Java虛擬機器》Java記憶體模型JMM

TPS: 每秒事物處理數是衡量一個服務效能好壞高低的重要指標之一。

1. Java的記憶體模型

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

Java記憶體模型即要定義得足夠嚴謹,才能讓Java的併發記憶體訪問操作不會產生歧義;Java記憶體模型也必須定義地足夠寬鬆,才能使得虛擬機器的實現有足夠的自由空間去利用硬體的各種特性來獲取更好的執行速度。經過長時間的驗證和修補,JDK1.5(實現了JSR-133)釋出之後,Java記憶體模型已經成熟和完善起來了,下面一起來看一下:

2. 主記憶體和工作記憶體

Java記憶體模型的主要目的是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體 和 從記憶體中取出變數這樣的底層細節。注意一下,此處的變數並不包括區域性變數與方法引數,因為它們是執行緒私有的,不會被共享,自然也不會存在競爭;此處的變數應該是例項欄位、靜態欄位和構成陣列物件的元素。

Java記憶體模型規定了所有的變數都儲存在主記憶體(Main Memory)中(此處的主記憶體與介紹物理硬體時的主記憶體名字一樣,兩者可以互相類比,但此處僅僅是虛擬機器記憶體的一部分),每條執行緒還有自己的工作記憶體(Working Memory),執行緒的工作記憶體中儲存了被該執行緒使用到的變數和主記憶體副本拷貝(注意這裡絕不會是整個物件的拷貝,試想一個10M的物件,在每個用到這個物件的工作記憶體中有一個10M的拷貝,記憶體還受得了?也就是一些線上程中用到的物件中的欄位罷了),執行緒對變數所有的操作(讀取、賦值)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要通過主記憶體來完成。

執行緒、主記憶體、工作記憶體三者的互動關係如圖:
執行緒、主記憶體、工作記憶體三者的互動關係

注意:這裡講的主記憶體、工作記憶體與Java記憶體區域中的Java堆、棧、方法區等並不是一個層次的記憶體劃分,兩者並沒有什麼關係。

3. 記憶體間互動操作

主記憶體和工作記憶體之間具體的互動協議:即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體之類的實現細節。Java記憶體模型中定義了以下8種操作來完成,虛擬機器實現時必須保證下面的每一種操作都是原子的、不可再分的:
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、不允許一個執行緒無原因地把資料從執行緒的工作記憶體同步回主記憶體中。

4、一個新的變數只能從主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數。

5、一個變數在同一時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一條執行緒重複執行多次(可重入),多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖。

6、如果對同一個變數執行lock操作,那將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行load或assign操作初始化變數的值。

7、如果一個變數事先沒有被lock操作鎖定,那就不允許對它進行unlock操作,也不允許去unlock一個被其他執行緒鎖定的變數。

8、對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中。

4. volatile型變數的特殊規則

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

一個變數被定義為volatile後,它將具備兩種特性:
1、保證此變數對所有執行緒的”可見性”,所謂”可見性”是指當一條執行緒修改了這個變數的值,新值對於其它執行緒來說都是可以立即得知的。即volatile變數在各個不同執行緒中不存在一致性問題。而普通變數不能做到這一點,普通變數的值在線上程間傳遞均需要通過主記憶體來完成。再強調一遍,volatile只保證了可見性,並不保證基於volatile變數的運算在併發下是安全的,因為Java裡面的運算並非原子操作。下面通過一個簡單的示例來說明原因:

package JVM;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * Created by louyuting on 17/1/3.
 */
public class VolatileTest {

    public static volatile int race=0;

    public static void increase(){
        race++;
    }

    private static final int THREAD_COUNT=20;

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);
        for(int i=0; i<THREAD_COUNT;i++){
            executorService.submit(new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j=0; j<10000; j++){
                        increase();
                    }
                }
            }));
        }
        executorService.shutdown();
        System.out.print(race);
    }
}

上面的程式碼我建立了20個執行緒,每個執行緒對race變數進行10000次自增操作,如果能夠正確併發的話,正確的結果應該是200000.我運行了幾次之後的結果如圖所示:
執行結果1

執行結果2

執行結果3

很容易看出每次的執行結果都不一樣,甚至差別很大。問題就出現在 race++ 這條自增運算上。通過反編譯這段程式碼發現只有一行程式碼的increase()方法在Class檔案中是由4條位元組碼指令構成的:

getstatic  #13;  //Field race
iconst_1
iadd
putstatic  #13; //Field race

從位元組碼層面來分析失敗的原因:
當getstatic指令把race的值取到操作棧頂時,volatile關鍵字保證了race的值此時是正確的,但是在執行iconst_1、iadd指令的時候,其他執行緒可能已經把race的值加大了,而在操作棧頂的值就變成了過期的資料,所以putstatic指令執行後就可能把較小的race值同步回主記憶體之中。

由於volatile關鍵字只能保證可視性並不能保證原子性,所以在一般的場景中仍然要通過加鎖(synchronized關鍵字或則java.util.concurrent中的原子類)來保證原子性。
一般只有在滿足如下兩個條件的變數使用volatile關鍵字不需要加鎖:
1. 運算結果不依賴於變數的當前值,或則能夠確保只有單一的執行緒修改變數的值。
2. 變數不需要與其他的狀態變數共同參與不變約束。

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

總結一下Java記憶體模型對volatile變數定義的特殊規則:
1、在工作記憶體中,每次使用某個變數的時候都必須線從主記憶體重新整理最新的值,用於保證能看見其他執行緒對該變數所做的修改之後的值

2、在工作記憶體中,每次修改完某個變數後都必須立刻同步回主記憶體中,用於保證其他執行緒能夠看見自己對該變數所做的修改

3、volatile修飾的變數不會被指令重排序優化,保證程式碼的執行順序與程式順序相同。

5. 對於long和double型變數的特殊規則

針對64位的資料型別long和double,JMM允許虛擬機器沒有被volatile修飾的64位資料的讀寫操作劃分成兩次32位的操作。這樣就會出現讀到“半個變數的值”的情況,不過十分罕見。不過虛擬機器允許把這些操作實現為具有原子性的操作,在現在實際的商業虛擬機器中基本上都是把64位資料的讀寫操作作為原子操作來對待。因此我們寫程式碼一般不需要專門把long和double變數宣告為volatile變數。

6.原子性、可見性、有序性

Java記憶體模型圍繞著併發過程中如何處理原子性、可見性和有序性這三個特徵來建立的,下面來逐個看一下:

1、原子性(Atomicity)

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

2、可見性(Visibility)

可見性是指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。volatile其實已經詳細寫了這一點。JMM是通過在變數修改之後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數的新值這種依賴於主記憶體作為傳遞媒介的方式來實現可見性。無論是普通變數還是volatile變數都是如此,唯一的區別在於volatile變數保證了新值立即能同步到主記憶體以及使用前立即從主記憶體重新整理。所以volatile保證了可視性而普通變數沒有。

其實synchronized關鍵字也是可以實現可見性的,synchronized的可見性是由”對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中”這條規則獲得的。另外,final關鍵字也可以實現可見性,因為被final修飾的欄位在構造器中一旦初始化完成,並且構造器沒有把this傳遞出去,那在其他執行緒中就能看見final欄位的值。

3、有序性(Ordering)

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

7.先行發生原則

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

所謂先行發生原則是指Java記憶體模型中定義的兩項操作之間的偏序關係,如果說操作A先行發生於操作B,那麼操作A產生的影響能夠被操作b觀察到,”影響”包括修改了記憶體中共享變數的值、傳送了訊息、呼叫了方法等。Java記憶體模型下有一些天然的,不需要任何同步協助器就已經存在的先行發生關係:
1、程式次序規則:在一個執行緒內,按照控制流順序,控制流前面的操作先行發生於控制流後面的操作,說”控制流”是因為還要考慮到分支、迴圈結構

2、管程鎖定規則:一個unlock操作先行發生於後面對同一個鎖的lock操作

3、volatile變數規則:對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作

4、執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每一個動作

5、執行緒終止規則:執行緒中的所有操作都先行發生於對此執行緒的終止檢測

6、執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生

7、物件終結規則:一個物件的初始化完成先行發生於它的finalize()方法的開始

8、傳遞新:如果操作A先行發生於操作B,操作B先行發生於操作C,那麼操作A必然先行發生於操作C

Java語言無須任何同步手段保障就能成立的先行發生規則就只有上面這些額,如果兩個操之間的關係不在此列,並且無法通過下面規則推匯出來的話,它們就沒有順序性保障。舉一個例子來看一下:

private int value = 0;

public void setValue(int value)
{
    this.value = value;
}

public int getValue()
{
    return value;
}

很普通的一組getter/setter,假設A執行緒先呼叫了setI(1),B執行緒再呼叫了同一個物件的getI(),那麼B執行緒的返回值是什麼?

依次分析一下先行發生原則中的各項規則。由於兩個方法分別由兩個執行緒分別呼叫,因此程式次序規則這裡不適用;由於沒有同步塊,所以也就沒有unlock和lock,因此管程鎖定規則這裡不適用;i沒有被關鍵字volatile修飾,因此volatile變數規則這裡不適用;後面的啟動、終止、中斷、物件終結也和這裡完全沒有關係,因此也都不適用。因為沒有一個實用的先行發生規則,所以最後一條傳遞性也無從談起,因此傳遞性也不適用。由於所有先行發生原則都不適用,因此儘管執行緒A的setI(1)操作在時間上先發生,但無法確定執行緒B的getI()的返回結果,換句話說,這裡面的操作不是執行緒安全的。

那如何修復這個問題?至少有兩種比較簡單的辦法:
1、setter/getter都定義成synchronized的,這樣可以套用管程鎖定規則

2、i定義為volatile變數,由於setter方法對i的修改不依賴於i的原值,滿足volatile關鍵字的使用場景,這樣可以套用volatile變數規則