Java併發程式設計之Java記憶體模型

weixin_34239169發表於2018-09-22

該文章屬於《Java併發程式設計》系列文章,如果想了解更多,請點選《Java併發程式設計之總目錄》

一、併發的起源

為了提高計算機處理資料的速度。現代的計算機都支援多工處理。在32位windows作業系統中 ,多工處理是指系統可同時執行多個程式,而每個程式也可同時執行多個執行緒。一個執行緒是指程式的一條執行路徑,它在系統指定的時間片中完成特定的功能。系統不停地在多個執行緒之間切換,由於時間很短,看上去多個執行緒在同時執行。或者對於線上程式可並行執行同時服務於多個使用者稱為多工處理。

二、物理計算機的記憶體模型

在理解java記憶體模型之前,我們先來了解一下,物理計算機的記憶體模型,其對Java記憶體模型有著很大的參考意義。 在物理計算機中,我們需要處理的資料都在記憶體中,處理器處理資料,需要從記憶體中獲取相應的資料,然後存入記憶體中,為了提高計算機的處理速度(讀取資料,儲存資料有IO消耗),我們常常會在CPU(處理器)中加入快取記憶體(Cache Memory),也就是將資料快取到處理器中,當處理器處理完資料後,再將處理的資料結果儲存在記憶體中。具體如下圖所示:

當CPU(處理器)要讀取一個資料時,首先從一級快取中查詢,如果沒有找到再從二級快取中查詢,如果還是沒有就從三級快取或記憶體中查詢。一般來說,每級快取的命中率大概都在80%左右,也就是說全部資料量的80%都可以在一級快取中找到,只剩下20%的總資料量才需要從二級快取、三級快取或記憶體中讀取。

快取記憶體(Cache Memory)是位於CPU與記憶體之間的臨時儲存器,它的容量比記憶體小的多但是交換速度卻比記憶體要快得多。快取記憶體的出現主要是為了解決CPU運算速度與記憶體讀寫速度不匹配的矛盾,因為CPU運算速度要比記憶體讀寫速度快很多,這樣會使CPU花費很長時間等待資料到來或把資料寫入記憶體。在快取中的資料是記憶體中的一小部分,但這一小部分是短時間內CPU即將訪問的,當CPU呼叫大量資料時,就可先快取中呼叫,從而加快讀取速度。

2.1 物理計算機的資料快取不一致的問題

雖然高速緩緩衝提高了CPU(處理器)處理資料的速度問題。在多執行緒中執行就會有問題了。在多核CPU中,每條執行緒可能執行於不同的CPU中,因此每個執行緒執行時有自己的快取記憶體(對單核CPU來說,其實也會出現這種問題,只不過是以執行緒排程的形式來分別執行的)。這時CPU快取中的值可能和快取中的值不一樣,這就會出現快取不一致的問題。為了解決該問題。物理機算計提供了兩種方案來解決該問題。具體如下圖所示:

2.1.1 通過匯流排加LOCK#鎖的方式

匯流排(Bus)是計算機各種功能部件之間傳送資訊的公共通訊幹線,它是由導線組成的傳輸線束,在計算機中資料是通過匯流排,在處理器和記憶體之間傳遞。

在早期的CPU當中,是通過在匯流排上加LOCK#鎖的形式來解決快取不一致的問題。因為CPU和其他部件進行通訊都是通過匯流排來進行的,如果對匯流排加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如記憶體),從而使得只能有一個CPU能使用這個變數的記憶體。在匯流排上發出了LCOK#鎖的訊號,那麼只有等待這段程式碼完全執行完畢之後,其他CPU才能從其記憶體讀取變數,然後進行相應的操作。這樣就解決了快取不一致的問題。

2.1.2 通過快取一致性協議

但是由於在鎖住匯流排期間,其他CPU無法訪問記憶體,會導致效率低下。因此出現了第二種解決方案,通過快取一致性協議來解決快取一致性問題。最出名的就是Intel 的MESI協議,MESI協議保證了每個快取中使用的共享變數的副本是一致的。它核心的思想是:當CPU寫資料時,如果發現操作的變數是共享變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的快取行置為無效狀態,因此當其他CPU需要讀取這個變數時,發現自己快取中快取該變數的快取行是無效的,那麼它就會從記憶體重新讀取。

2.2 CPU(處理器)的亂序執行(out-of-orderexecution)

除了使用快取記憶體來提高CPU(處理器)的資料處理速度,CPU(處理器)還採用了允許將多條指令不按程式規定的順序分開傳送給各相應電路單元處理的技術。在這期間不按規定順序執行指令,然後由重新排列單元將各執行單元結果按指令順序重新排列。採用亂序執行技術的目的是為了使CPU內部電路滿負荷運轉並相應提高了CPU的執行程式的速度。有可能大家不好理解。下面這個例子幫助大家理解。

假如請A、B、C三個名人為晚會題寫橫幅“春節聯歡晚會”六個大字,每人各寫兩個字。如果這時在一張大紙上按順序由A寫好"春節"後再交給B寫"聯歡",然後再由C寫"晚會",那麼這樣在A寫的時候,B和C必須等待,而在B寫的時候C仍然要等待而A已經沒事了。

但如果採用三個人分別用三張紙同時寫的做法, 那麼B和C都不必須等待就可以同時各寫各的了,甚至C和B還可以比A先寫好也沒關係(就象亂序執行),但當他們都寫完後就必須重新在橫幅上(自然可以由別人做,就象CPU中亂序執行後的重新排列單元)按"春節聯歡晚會"的順序排好才能掛出去。

三、Java的記憶體模型

看到這裡大家一定會發現,我們所討論的CPU快取記憶體、指令重排序等內容都是計算機體系結構方面的東西,並不是Java語言所特有的。事實上,很多主流程式語言(如C/C++)都存在快取不一致的問題,這些語言是藉助物理硬體和作業系統的記憶體模型來處理快取不一致問題的,因此不同平臺上記憶體模型的差異,會影響到程式的執行結果。Java虛擬機器規範定義了自己的記憶體模型JMM(Java Memory Model)來遮蔽掉不同硬體和作業系統的記憶體模型差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問結果。所以對於Java程式設計師,無需瞭解底層硬體和作業系統記憶體模型的知識,只要關注Java自己的記憶體模型,就能夠解決這些問題啦。

Java記憶體模型如下圖所示:

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

如果對應與Java記憶體中堆與棧的概念的話,主記憶體對應Java記憶體中的堆,工作記憶體對應Java虛擬機器的棧。

3.1 記憶體之間互動

主記憶體與工作記憶體之間的記憶體互動,也就是從執行緒的私有記憶體資料同步到主記憶體中,從主記憶體的讀取資料到執行緒的私有記憶體中。Java記憶體模型定義了8種操作來完成。虛擬機器在實現時保證下面提到的每一種操作都是原子的,不可再分的

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

3.2 八種原子操作規則

既然Java記憶體模型規定了記憶體之間互動的一些操作。那麼我們來看看,它到底擁有哪些規則呢。

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

上述規則規定了Java記憶體之間互動的流程。保證了資料在單執行緒情形下傳輸過程中的準確性與資料一致性。

四、重排序

前面提到過,CPU(處理器)為了提高處理資料的速度,會進行亂序執行(out-of-orderexecution)。也就是重排序。但是CPU不會對任務操作進行重排序,編譯器與處理器只會對沒有資料依賴性的指令進行重排序。這裡提到了一個關鍵詞資料依賴性。什麼是資料依賴呢?

4.1 資料依賴

如果兩個操作訪問同一個變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。如下圖所示:

名稱 程式碼示例 說明
寫後讀 a=1;b=a 寫一個變數之後,再讀這個位置
寫後寫 a=1;a=2 寫一個變數之後,再寫這個位置
讀後寫 a=b;b=1 讀一個變數之後,再寫這個位置

上述三種情況,a與b存在著**“資料依賴性”**,同時大家也要注意。這裡所說的資料依賴性是指單個處理器執行的指令序列和單個執行緒中執行的操作。多處理器和不同執行緒之間是沒有資料依賴性這種關係的。

4.2 重排序規則(as-if-serial)

既然我們已經知道了CPU在處理資料時候會出現重排序。那重排序的規則是什麼呢?重排序規則:不管怎麼重排序(編譯器和處理器為了提高並行度),單執行緒(程式)執行結果不能被改變。編譯器、runtime和處理器都必須遵守。那麼我們三角形面積示例程式碼說明:

double a = 3;//底
double h = 10;//高
double s = a*h/2//面積
複製程式碼

其中上述程式碼的依賴關係如下圖所示:

如上圖所示:a與s存在資料依賴關係,同時h與s也存在依賴關係。因此在程式的最終指令執行時。s是不能排在a與h之前。因為a與h不存在著資料依賴關係。所以處理器可以對a與h之前的執行順序重排序。
經過處理器的重排序後,執行的結果並沒有發生改變。

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

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

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

工作記憶體的可見性問題(這裡和計算機硬體的快取不一致是一樣的道理)。從上文的Java記憶體模型分析。我們已經知道了當多個執行緒操作同一個共享變數時,如果一個執行緒修改了其中的變數的值(如果通過Java記憶體模型的原子操作來表達,一個執行緒多次use與assign 操作,而另一個執行緒經過read、load之後,另一執行緒任然保持著之前從主記憶體中獲取的值),另一個執行緒怎麼感知呢?

5.2 重排序帶來的問題

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
        }
    }
}
複製程式碼

isInit用來標誌是否已經初始化配置。其中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記憶體模型規定的一些規則。

6.1 程式次序規則

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

6.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的值一定是正確的。

6.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的值一定是正確的。這裡我們先不對volatlie進行講解,後面系列文章會描述。

6.4 執行緒啟動規則

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

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

6.5 執行緒終止規則

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

這裡理解比較抽象。舉個例子,假設兩個執行緒s、t。線上程s中呼叫t.join()方法。則執行緒s會被掛起,等待t執行緒執行結束才能恢復執行。當t.join()成功返回時,s執行緒就知道t執行緒已經結束了。在t執行緒中對共享變數的修改,對s執行緒都是可見的。類似的還有Thread.isAlive方法也可以檢測到一個執行緒是否結束。也就是說當一個執行緒結束時,會把自己所有操作的結果都同步到主記憶體。而任何其它執行緒當發現這個執行緒已經執行結束了,就會從主記憶體中重新重新整理最新的變數值。所以結束的執行緒A對共享變數的修改,對於其它檢測了A執行緒是否結束的執行緒是可見的。

6.6 執行緒中斷規則

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

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

6.7 物件終結規則

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

6.8 傳遞性規則

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

總結

  • 在物理計算機中CPU為了提高處理速度,新增了快取記憶體與CPU亂序執行
  • Java定義了自身的記憶體模型是為了遮蔽掉不同硬體和作業系統的記憶體模型差異
  • Java為了處理記憶體的不可見性與重排序的問題,定義了Happens-Before 原則
  • Happens-Before 原則的理解:對於兩個操作A和B,這兩個操作可以在不同的執行緒中執行。如果A Happens-Before B,那麼可以保證,當A操作執行完後,A操作的執行結果對B操作是可見的。

相關文章