深入理解JVM(③)學習Java的記憶體模型

紀莫發表於2020-07-09

前言

Java記憶體模型(Java Memory Model)用來遮蔽各種硬體和作業系統的記憶體訪問差異,這使得Java能夠變得非常靈活而不用考慮各系統間的相容性等問題。定義Java記憶體模型並非一件容易的事情,從Java出生開始經過長時間的驗證和修補,直至JDK5釋出後Java記憶體模型才終於成熟、完善起來了。

主記憶體與工作記憶體

Java記憶體模型的主要目的是定義程式中各種變數的訪問規則,即關注在虛擬機器中把變數值儲存到記憶體和從記憶體中取出變數值這樣的底層細節

Java記憶體模型規定了所有變數都儲存在主記憶體(Main Memory)中(此處的記憶體為Java虛擬機器記憶體的一部分)。每條執行緒還有自己的工作記憶體(Working Memory),執行緒的工作記憶體中儲存了被該執行緒使用的變數記憶體副本,執行緒對變數的所有操作都必須在工作記憶體中進行,也不能直接讀寫主記憶體中資料。不同的執行緒之間變數值的傳遞均需要通過主記憶體來完成。

執行緒、主記憶體、工作記憶體三者的互動關係如下圖。
在這裡插入圖片描述

記憶體間互動操作

對於主記憶體和工作記憶體之間具體的互動協議,Java記憶體模型中定義了以下8中操作拉完成。
Java虛擬機器實現時必須保證下面提及的每一種操作都是原子的、不可再分的

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

如果想要把一個變數從主記憶體複製到工作記憶體,那就要按順序執行read和load操作,如果要把變數從工作記憶體同步回主記憶體,就要按順執行store和write操作。
除此之外Java記憶體模型還規定了在執行上述8種基本操作時必須滿足如下規則

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

上面的8中基本操作,以及這些規則限定了Java程式中哪些記憶體訪問操作在併發下是安全的。雖然繁瑣但很嚴謹,大致操作也可以簡化的描述為read、write、lock、unlock四種。我們只需要理解Java記憶體模型的定義即可。

對於volatile型變數的特殊規則

關鍵字volatile可以說是Java虛擬機器提供的最輕量級的同步機制,但是它並不容易被正確、完整地理解,Java記憶體模型為volatile專門定義了一些特殊的訪問規則。
當一個變數被定義為volatile之後,它將具備兩項特性:
第一項是保證此變數對所有執行緒的“可見性可見性是指當一條執行緒修改了這個變數的值,新值對於其他執行緒來說是可以立即得知的

關於volatile變數的可見性,雖然volatile變數對所有執行緒立即可見,但是基於volatile變數的所有的寫操作都能立刻反映到其他執行緒中的。這句話理論上來說沒毛病,但是volatile變數在運算過程中也是存在不一致的情況,因為在Java裡面的運算操作符並非原子操作,這導致volatile變數的運算在併發下一樣是不安全的。
例如:

/**
 * @author jiomer
 * @date Create in 2020
 * @description volatile變數自增運算測試
 */
public class VolatileOneTest {

    public static volatile int race = 0;

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

    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) {

        Thread[] threads = new Thread[THREADS_COUNT];

        for (int i = 0;i<THREADS_COUNT;i++){

            threads[i] = new Thread(() -> {
                for(int i1 = 0; i1 <10000; i1++){
                    increase();
                }
            });

            threads[i].start();
        }

        while (Thread.activeCount() > 2){
            Thread.yield();
        }

        System.out.println(race);

    }

}

上述程式碼發起了20個執行緒,每個執行緒對race變數進行10000次自增操作,如果能夠正確併發的話,執行結果應為20000,但執行結果並不正確,並且每次輸出結果都小於20000,且都不相同。
由於volatile變數只能保證可見性,在不符合以下兩條規則的執行場景中,我們仍然要通過加鎖(synchronized、java.util.concurent中的鎖或原子類)來保證原子性:

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

volatile變數的第二項特性是:禁止指令重排序優化普通的變數金輝保證在該方法的執行過程中所有依賴複製結果的地方都能獲取到正確的結果,而不能保證變數賦值操作的順序與程式程式碼中的執行順序一致。

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

上面在介紹Java記憶體模型的記憶體互動操作時,介紹了8種操作並明確都是具有原子性的。
但是對於64位的資料型別(long和double),在模型中特別定義了一條寬鬆的規定:允許虛擬機器將沒有被volatile修飾的64位資料的讀寫操作劃分為兩次32位的操作來進行,即允許虛擬機器自行選擇是否要保證64位資料型別的load、store、read、和write這四個操作的原子性,這就是所謂的“long 和 double的非原子協定”。
從JDK9開始,HotSpot增加了一個實驗性的引數-XX:+AlwaysAtomicAccesses 來約束虛擬機器堆所有資料型別進行原子性的訪問。另外由於現代中央處理器中一般都包含專門用於處理浮點資料的浮點運算器,所以在實際開發中,除非該資料有明確可知的執行緒競爭,否則一般不用刻意的把long和double型別的變數宣告為volatile。

原子性、可見性、有序性

Java記憶體模型是圍繞著在併發過程中如何處理原子性可見性有序性這三個特徵來建立的。

原子性

由Java記憶體模型來直接保證的原子性變數操作包括readloadassignusestorewrite這六個,我們大致可以認為,基本資料型別的訪問、讀寫都是具備原子性的。
如果需要一個更大範圍的原子性保證,Java記憶體模型還提供了lockunlock操作來滿足這種需求,這兩個操作更高層次的位元組碼指令是monitorentermonitorexit來隱式地使用這兩個操作。這兩個操作反應到Java程式碼中就是synchronized關鍵字,所以synchronized程式碼塊之間的操作也具備原子性。

可見性

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

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

有序性

Java程式中的有序性可以總結為一句話:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。
Java語言提供了volatilesynchronized兩個關鍵字拉保證執行緒之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由“一個變數在同一時刻只允許一條執行緒對其進行lock操作”這條規則獲得的,這個規則決定了持有同一個鎖的兩個同步塊只能序列地進入。

先行發生原則(Happens-Before)

Java語言中有一個“先行發生”(Happens-Before)的原則。這個原則非常重要,它是判斷資料是否存在競爭,執行緒是否安全的非常有用的手段。
先行發生原則是Java記憶體模型中定義的兩項操作之間的偏序關係,例如操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了記憶體中共享變數的值、傳送了訊息、呼叫了方法等。

下面幾項規則是Java記憶體模型中“天然的”先行發生關係,可以直接在程式碼中使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推匯出來,則它們就沒有順序性保障,虛擬機器可以對它們隨意地進行重排序。

  • 程式次序規則:在一個執行緒內,安裝控制流順序,書寫在前面的操作先行發生於書寫在後面的操作。(這裡說的是控制流順序,不是程式程式碼順序,因為要考慮分支、迴圈等)
  • 管程鎖定規則:一個unlock操作先行發生於後面對同一個鎖的lock操作。
  • volatile變數規則:對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作,這裡的“後面”同樣是指時間上的先後。
  • 執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每一個動作。
  • 執行緒終止規則:執行緒中所有操作都先行發生於對此執行緒的終止檢測,我們可以通過Thread::join()方法是否結束、Thread::isAlive()的返回值等手段檢測執行緒是否已經終止執行。
  • 執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件發生,可以通過Thread::interrupted()方法檢測到是否有中斷髮生。
  • 物件終結規則:一個物件的初始化完成(建構函式執行結束)先行發生於它的finalize()方法開始。
  • 傳遞性:如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

舉個例子說明如何判斷操作間是否具備順序性,對於讀寫共享變數的操作來說,就是執行緒是否安全。

public class HappensBeforeTest {
    
    
    private int item = 1;


    public int getItem() {
        return item;
    }

    public void setItem(int item) {
        this.item = item;
    }
}

上面的程式碼,假設執行緒A(時間上的先後)先呼叫了setItem(2),然後執行緒B呼叫了同一個物件的getItem()
先分析一下,由於兩個方法不在一個執行緒中,所以程式次序規則不適用;沒有同步塊,所以管程鎖定規則也不適用;item沒有被volatile關鍵字修飾,所以volatile變數規則不適用;後面的執行緒啟動、終止、中斷規則和物件終結規則也和這沒關係。因為之前也沒有一個適用的先行發生規則,所以也沒有傳遞性規則;
所以我們判定,儘管執行緒A操作時間上先於執行緒B,但是無法確定執行緒B中getItem()方法的返回結果,也就是這裡面的操作不是執行緒安全的。

修復方式有多種,例如把getter/setter方法都加上synchronized,這樣符合管程鎖定規則;要麼把item設定成volatile變數;要麼等setter方法執行完成後再指向getter方法,符合程式次序規則。

既然一個操作在“時間上的先發生”不代表這個操作會是“先行發生”。那麼一個操作“先行發生”是否就能推匯出這個操作必定是“時間上的先行發生”呢?

還是舉例說明吧

	// 以下操作在同一個執行緒中執行
    int item = 1;

    int value = 2;

根據程式次序規則,int item = 1;的操作先行發生於int value = 2;,但是int value = 2;的程式碼完全可能先被處理器執行,這並不影響先行發生原則的正確性。

所以經過上面兩個論證,得出時間先後順序與先行發生原則之間基本沒有因果關係,在衡量是否執行緒安全時,一切必須以先行發生原則為準。

相關文章