1. 程式人生 > >(2.1.27.2)Java併發程式設計:JAVA的記憶體模型

(2.1.27.2)Java併發程式設計:JAVA的記憶體模型

  1. Java定義了自身的記憶體模型是為了遮蔽掉不同硬體和作業系統的記憶體模型差異
  2. Java為了處理記憶體的不可見性與重排序的問題,定義了Happens-Before 原則
  3. Happens-Before 原則的理解:對於兩個操作A和B(這兩個操作可以在不同的執行緒中執行),如果A Happens-Before B,那麼可以保證,當A操作執行完後,A操作的執行結果對B操作是可見的。

通過閱讀上一章,我們知道了[快取模型]和[亂序執行]帶來的問題,我們所討論的CPU快取記憶體、指令重排序等內容都是計算機體系結構方面的東西,並不是Java語言所特有的。 事實上,很多主流程式語言(如C/C++)都存在快取不一致的問題,這些語言是藉助物理硬體和作業系統的記憶體模型

來處理快取不一致問題的,因此不同平臺上記憶體模型的差異,會影響到程式的執行結果。 Java虛擬機器規範定義了自己的記憶體模型JMM(Java Memory Model)來遮蔽掉不同硬體和作業系統的記憶體模型差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問結果。 所以對於Java程式設計師,無需瞭解底層硬體和作業系統記憶體模型的知識,只要關注Java自己的記憶體模型,就能夠解決這些問題啦。

一、Java的記憶體模型

在這裡插入圖片描述 【Java記憶體模型】

  • 主記憶體
    • 主要儲存變數(包括。例項欄位,靜態欄位和構成物件的元素)
    • 對應Java記憶體中的堆
  • 工作記憶體
    • 每個執行緒都有自己的工作記憶體,儲存了對應的引用,方法引數。
    • 對應Java虛擬機器的棧

二、工作記憶體和主記憶體的互動

主記憶體與工作記憶體之間的記憶體互動,可以分為兩種,也就是:

  1. 從主記憶體的讀取資料到執行緒的私有記憶體中
  2. 從執行緒的私有記憶體資料同步到主記憶體中

Java記憶體模型定義了8種操作來完成。虛擬機器在實現時保證下面提到的每一種操作都是原子的,不可再分的

在這裡插入圖片描述 【工作記憶體和主記憶體的互動】

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

2.1 八種原子操作規則

java為了保證資料在單執行緒情形下傳輸過程中的準確性與資料一致性,規定了記憶體之間互動的一些操作規則

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

三、Java記憶體模型的需要解決的問題

前面我們已經瞭解了Java記憶體模型的大致結構與操作方式,那麼我們來看看Java記憶體模型需要解決的問題。

3.1 工作記憶體的可見性問題

工作記憶體的可見性問題 == 計算機硬體的快取不一致問題

當多個執行緒操作同一個共享變數時,如果一個執行緒修改了其中的變數的值(如果通過Java記憶體模型的原子操作來表達,一個執行緒多次use與assign 操作,而另一個執行緒經過read、load之後,另一執行緒任然保持著之前從主記憶體中獲取的值),另一個執行緒怎麼感知呢?

3.2 重排序在多執行緒中引發的問題

雖然重排序規則(as-if-serial)保證了單執行緒模型中的執行結果一致性,但是CPU(處理器)重排序在多執行緒模型中依然存在問題。具體問題我們用下列虛擬碼來闡述:

public class Demo {
    private int a = 0;
    private boolean isInit = false;//標誌是否已經初始化配置
    private Config config;

    public void init() {
        config = readConfig();//1
        isInit = true;//2
    }
	
    public void doSomething() {
        if (isInit) {//3
            doSomethingWithconfig();//4
        }
    }
}

其中1-2,3-4操作是沒有資料依賴性的。也就是說把1-2,3-4的調換順序對於單執行緒中的執行結果是沒有影響的。 那麼CPU(處理器)可能對1-2操作進行重排序,對3-4操作進行重排序。

現在我們加入執行緒A操作Init()方法,執行緒B操作doSomething()方法,那麼我們看看重排序對多執行緒情況下的影響:

在這裡插入圖片描述 【重排序帶來的問題】

上圖中2操作排在了1操作前面。當CPU時間片轉到執行緒B。執行緒B判斷 if (isInit)為true,接下來接著執行 doSomethingWithconfig(),但是實際上,我們Config還沒有初始化。

所以在多執行緒的情況下。重排序會影響程式的執行結果。

四、Happens-Before 原則

上面我們討論了Java記憶體模型需要解決的問題,那Java有不有一個良好的解決辦法來處理以上出現的情況呢?答案是當然的。

為了方便程式設計師開發,將底層的煩瑣細節遮蔽掉,JMM定義了Happens-Before原則。只要我們理解了Happens-Before原則,無需瞭解Java記憶體模型的記憶體操作,就可以解決這些問題(避免工作記憶體的不可見與重排序帶來的問題)。

Happens-Before原則是一組偏序關係:對於兩個操作A和B(這兩個操作可以在不同的執行緒中執行), 如果A Happens-Before B,那麼可以保證,當A操作執行完後,A操作的執行結果對B操作是可見的。

那麼有哪些滿足Happens-Before原則的呢?下面是Java記憶體模型規定的一些規則

4.1 程式次序規則

在一個執行緒內,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作。

這是因為Java語言規範要求Java記憶體模型在單個執行緒內部要維護類似嚴格序列的語義,如果多個操作之間有先後依賴關係,則不允許對這些操作進行重排序。

4.2 鎖定規則

對一個unlock操作先行發生於後面對同一個鎖的lock操作。

public class Demo {
    private int value;
    public synchronized void setValue(int value) {
        this.value = value;
    }
    public synchronized int getValue() {
        return value;
    }
}

上面這段程式碼,setValue與getValue擁有同一個鎖(也就是當前例項物件).

假設setValue方法線上程A中執行,getValue方法線上程B中執行。

執行緒A呼叫setValue方法會先對value變數賦值,然後釋放鎖。執行緒B呼叫getValue方法會先獲取到同一個鎖後,再讀取value的值。那麼B執行緒獲取的value的值一定是正確的。

4.3 volatlie變數規則

對一個volatile變數的寫操作先行發生於後面這個變數的讀操作。

public class Demo {

    private volatile boolean flag;
	
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
	
    public boolean isFlag() {
        return flag;
    }
}

假設setFlag方法線上程A中執行,isFlag方法線上程B中執行:

執行緒A呼叫setFlag方法會先對value變數賦值,然後釋放鎖。執行緒B呼叫isFlag方法再讀取value的值。那麼B執行緒獲取的flag的值一定是正確的。

4.4 執行緒啟動規則

Thread物件的start()方法先行發生於此執行緒的每個動作。

start方法和新執行緒中的動作一定是在兩個不同的執行緒中執行。

執行緒啟動規則可以這樣去理解:呼叫start方法時,會將start方法之前所有操作的結果同步到主記憶體中,新執行緒建立好後,需要從主記憶體獲取資料。這樣在start方法呼叫之前的所有操作結果對於新建立的執行緒都是可見的。

4.5 執行緒終止規則

執行緒中的所有操作都先行發生於對此執行緒的終止檢測。

這裡理解比較抽象。舉個例子,假設兩個執行緒s、t。

  1. 線上程s中呼叫t.join()方法。則執行緒s會被掛起,等待t執行緒執行結束才能恢復執行。
  2. 當t.join()成功返回時,s執行緒就知道t執行緒已經結束了。
  3. 在t執行緒中對共享變數的修改,對s執行緒都是可見的。

類似的還有Thread.isAlive方法也可以檢測到一個執行緒是否結束。也就是說當一個執行緒結束時,會把自己所有操作的結果都同步到主記憶體。而任何其它執行緒當發現這個執行緒已經執行結束了,就會從主記憶體中重新重新整理最新的變數值。所以結束的執行緒A對共享變數的修改,對於其它檢測了A執行緒是否結束的執行緒是可見的。

4.6 執行緒中斷規則

對執行緒interrupt()方法的呼叫先與被中斷執行緒的程式碼檢查到中斷事件的發生。

假設兩個執行緒A和B,A先做了一些操作operationA,然後呼叫B執行緒的interrupt方法。當B執行緒感知到自己的中斷標識被設定時(通過丟擲InterruptedException,或呼叫interrupted和isInterrupted),operationA中的操作結果對B都是可見的。

4.7 物件終結規則

一個物件的初始化完成(建構函式執行結束)先行發生於它的finalize()方法的開始。

4.8 傳遞性規則

如果操作A先行與發生於操作B,操作B先行發生於操作C,那麼就可以得出A先行發生於操作C的結論。