1. 程式人生 > >從Java多執行緒可見性談Happens-Before原則

從Java多執行緒可見性談Happens-Before原則

這篇是轉載的,之前直接把連結嵌在文章裡,覺得還是獨立出來比較好。以下是正文:

Happens-Before是一個非常抽象的概念,然而它又是學習Java併發程式設計不可跨域的部分。本文會先闡述Happens-Before在併發程式設計中解決的問題——多執行緒可見性,然後再詳細講解Happens-Before原則本身。

Java多執行緒可見性

在現代作業系統上編寫併發程式時,除了要注意執行緒安全性(多個執行緒互斥訪問臨界資源)以外,還要注意多執行緒對共享變數的可見性,而後者往往容易被人忽略。
可見性是指當一個執行緒修改了共享變數的值,其它執行緒能夠適時得知這個修改。在單執行緒環境中,如果在程式前面修改了某個變數的值,後面的程式一定會讀取到那個變數的新值。這看起來很自然,然而當變數的寫操作和讀操作在不同的執行緒中時,情況卻並非如此。

/**
 *《Java併發程式設計實戰》27頁程式清單3-1
 */
public class NoVisibility {
    private static boolean ready; 
    private static int number;
    
    private static class ReaderThread extends Thread {
        public void run() {
            while(!ready) {
                Thread.yield();
            }
            System.out.println(number);
        }
    }
    
    public static void main(String[] args) {
        new ReaderThread().start(); //啟動一個執行緒
        number = 42;
        ready = true;
    }
}

上面的程式碼中,主執行緒和讀執行緒都訪問共享變數ready和number。程式看起來會輸出42,但事實上很可能會輸出0,或者根本無法終止。這是因為上面的程式缺少執行緒間變數可見性的保證,所以在主執行緒中寫入的變數值,可能無法被讀執行緒感知到。

為什麼會出現執行緒可見性問題

要想解釋為什麼會出現執行緒可見性問題,需要從計算機處理器結構談起。我們都知道計算機運算任務需要CPU和記憶體相互配合共同完成,其中CPU負責邏輯計算,記憶體負責資料儲存。CPU要與記憶體進行互動,如讀取運算資料、儲存運算結果等。由於記憶體和CPU的計算速度有幾個數量級的差距,為了提高CPU的利用率,現代處理器結構都加入了一層讀寫速度儘可能接近CPU運算速度的快取記憶體來作為記憶體與CPU之間的緩衝:將運算需要使用的資料複製到快取中,讓CPU運算可以快速進行,計算結束後再將計算結果從快取同步到主記憶體中,這樣處理器就無須等待緩慢的記憶體讀寫了。
快取記憶體的引入解決了CPU和記憶體之間速度的矛盾,但是在多CPU系統中也帶來了新的問題:快取一致性。在多CPU系統中,每個CPU都有自己的快取記憶體,所有的CPU又共享同一個主記憶體。如果多個CPU的運算任務都涉及到主記憶體中同一個變數時,那同步回主記憶體時以哪個CPU的快取資料為準呢?這就需要各個CPU在資料讀寫時都遵循同一個協議進行操作。

參考上圖,假設有兩個執行緒A、B分別在兩個不同的CPU上執行,它們共享同一個變數X。如果執行緒A對X進行修改後,並沒有將X更新後的結果同步到主記憶體,則變數X的修改對B執行緒是不可見的。所以CPU與記憶體之間的快取記憶體就是導致執行緒可見性問題的一個原因。
CPU和主記憶體之間的快取記憶體還會導致另一個問題——重排序。假設A、B兩個執行緒共享兩個變數X、Y,A和B分別在不同的CPU上執行。在A中先更改變數X的值,然後再更改變數Y的值。這時有可能發生Y的值被同步回主記憶體,而X的值沒有同步回主記憶體的情況,此時對於B執行緒來說是無法感知到X變數被修改的,或者可以認為對於B執行緒來說,Y變數的修改被重排序到了X變數修改的前面。上面的程式NoVisibility類中有可能輸出0就是這種情況,雖然在主執行緒中是先修改number變數,再修改ready變數,但對於讀執行緒來說,ready變數的修改有可能被重排序到number變數修改之前。
此外,為了提高程式的執行效率,編譯器在生成指令序列時和CPU執行指令序列時,都有可能對指令進行重排序。Java語言規範要求JVM只在單個執行緒內部維護一種類似序列的語義,即只要程式的最終結果與嚴格序列環境中執行的結果相同即可。所以在單執行緒環境中,我們無法察覺到重排序,因為程式重排序後的執行結果與嚴格按順序執行的結果相同。就像在類NoVisibility的主執行緒中,先修改ready變數還是先修改number變數對於主執行緒自己的執行結果是沒有影響的,但是如果number變數和ready變數的修改發生重排序,對讀執行緒是有影響的。所以在編寫併發程式時,我們一定要注意重排序對多執行緒執行結果的影響。
看到這裡大家一定會發現,我們所討論的CPU快取記憶體、指令重排序等內容都是計算機體系結構方面的東西,並不是Java語言所特有的。事實上,很多主流程式語言(如C/C++)都存在多執行緒可見性的問題,這些語言是藉助物理硬體和作業系統的記憶體模型來處理多執行緒可見性問題的,因此不同平臺上記憶體模型的差異,會影響到程式的執行結果。Java虛擬機器規範定義了自己的記憶體模型JMM(Java Memory Model)來遮蔽掉不同硬體和作業系統的記憶體模型差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問結果。所以對於Java程式設計師,無需瞭解底層硬體和作業系統記憶體模型的知識,只要關注Java自己的記憶體模型,就能夠解決Java語言中的記憶體可見性問題了。

Happens-Before原則

上面討論了Java中多執行緒共享變數的可見性問題及產生這種問題的原因。下面我們看一下如何解決這個問題,即當一個多執行緒共享變數被某個執行緒修改後,如何讓這個修改被需要讀取這個變數的執行緒感知到。
為了方便程式設計師開發,將底層的煩瑣細節遮蔽掉,JMM定義了Happens-Before原則。只要我們理解了Happens-Before原則,無需瞭解JVM底層的記憶體操作,就可以解決在併發程式設計中遇到的變數可見性問題。
JVM定義的Happens-Before原則是一組偏序關係:對於兩個操作A和B,這兩個操作可以在不同的執行緒中執行。如果A Happens-Before B,那麼可以保證,當A操作執行完後,A操作的執行結果對B操作是可見的。
Happens-Before的規則包括:

  1. 程式順序規則
  2. 鎖定規則
  3. volatile變數規則
  4. 執行緒啟動規則
  5. 執行緒結束規則
  6. 中斷規則
  7. 終結器規則
  8. 傳遞性規則

下面我們將詳細講述這8條規則的具體內容。

程式順序規則

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

鎖定規則

對鎖M解鎖之前的所有操作Happens-Before對鎖M加鎖之後的所有操作。

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

上面這段程式碼,setValue和getValue兩個方法共享同一個監視器鎖。假設setValue方法線上程A中執行,getValue方法線上程B中執行。setValue方法會先對value變數賦值,然後釋放鎖。getValue方法會先獲取到同一個鎖後,再讀取value的值。所以根據鎖定原則,執行緒A中對value變數的修改,可以被執行緒B感知到。
如果這個兩個方法上沒有synchronized宣告,則線上程A中執行setValue方法對value賦值後,執行緒B中getValue方法返回的value值並不能保證是最新值。
本條鎖定規則對顯示鎖(ReentrantLock)和內建鎖(synchronized)在加鎖和解鎖等操作上有著相同的記憶體語義。
對於鎖定原則,可以像下面這樣去理解:同一時刻只能有一個執行緒執行鎖中的操作,所以鎖中的操作被重排序外界是不關心的,只要最終結果能被外界感知到就好。除了重排序,剩下影響變數可見性的就是CPU快取了。在鎖被釋放時,A執行緒會把釋放鎖之前所有的操作結果同步到主記憶體中,而在獲取鎖時,B執行緒會使自己CPU的快取失效,重新從主記憶體中讀取變數的值。這樣,A執行緒中的操作結果就會被B執行緒感知到了。

volatile變數規則

對一個volatile變數的寫操作及這個寫操作之前的所有操作Happens-Before對這個變數的讀操作及這個讀操作之後的所有操作。

Map configOptions;
char[] configText; //執行緒間共享變數,用於儲存配置資訊
// 此變數必須定義為volatile
volatile boolean initialized = false;

// 假設以下程式碼線上程A中執行
// 模擬讀取配置資訊,當讀取完成後將initialized設定為true以通知其他執行緒配置可用configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

// 假設以下程式碼線上程B中執行
// 等待initialized為true,代表執行緒A已經把配置資訊初始化完成
while (!initialized) {    
    sleep();
}
//使用執行緒A中初始化好的配置資訊
doSomethingWithConfig();

上面這段程式碼,讀取配置檔案的操作和使用配置資訊的操作分別在兩個不同的執行緒A、B中執行,兩個執行緒通過共享變數configOptions傳遞配置資訊,並通過共享變數initialized作為初始化是否完成的通知。initialized變數被宣告為volatile型別的,根據volatile變數規則,volatile變數的寫入操作Happens-Before對這個變數的讀操作,所以線上程A中將變數initialized設為true,執行緒B中是可以感知到這個修改操作的。
但是更牛逼的是,volatile變數不僅可以保證自己的變數可見性,還能保證書寫在volatile變數寫操作之前的操作對其它執行緒的可見性。考慮這樣一種情況,如果volatile變數僅能保證自己的變數可見性,那麼當執行緒B感知到initialized已經變成true然後執行doSomethingWithConfig操作時,可能無法獲取到configOptions最新值而導致操作結果錯誤。所以volatile變數不僅可以保證自己的變數可見性,還能保證書寫在volatile變數寫操作之前的操作Happens-Before書寫在volatile變數讀操作之後的那些操作。
可以這樣理解volatile變數的寫入和讀取操作流程:
首先,volatile變數的操作會禁止與其它普通變數的操作進行重排序,例如上面程式碼中會禁止initialized = true與它上面的兩行程式碼進行重排序(但是它上面的程式碼之間是可以重排序的),否則會導致程式結果錯誤。volatile變數的寫操作就像是一條基準線,到達這條線之後,不管之前的程式碼有沒有重排序,反正到達這條線之後,前面的操作都已完成並生成好結果。
然後,在volatile變數寫操作發生後,A執行緒會把volatile變數本身和書寫在它之前的那些操作的執行結果一起同步到主記憶體中。
最後,當B執行緒讀取volatile變數時,B執行緒會使自己的CPU快取失效,重新從主記憶體讀取所需變數的值,這樣無論是volatile本身,還是書寫在volatile變數寫操作之前的那些操作結果,都能讓B執行緒感知到,也就是上面程式中的initialized和configOptions變數的最新值都可以讓執行緒B感知到。
原子變數與volatile變數在讀操作和寫操作上有著相同的語義。

執行緒啟動規則

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

執行緒終止規則

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

中斷規則

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

終結器規則

一個物件的建構函式執行結束Happens-Before它的finalize()方法的開始。
“結束”和“開始”表明在時間上,一個物件的建構函式必須在它的finalize()方法呼叫時執行完。
根據這條原則,可以確保在物件的finalize方法執行時,該物件的所有field欄位值都是可見的。

傳遞性規則

如果操作A Happens-Before B,B Happens-Before C,那麼可以得出操作A Happens-Before C。

再次思考Happens-Before規則的真正意義

到這裡我們已經討論了執行緒的可見性問題和導致這個問題的原因,並詳細闡述了8條Happens-Before原則和它們是如何幫助我們解決變數可見性問題的。下面我們在深入思考一下,Happens-Before原則到底是如何解決變數間可見性問題的。
我們已經知道,導致多執行緒間可見性問題的兩個“罪魁禍首”是CPU快取重排序。那麼如果要保證多個執行緒間共享的變數對每個執行緒都及時可見,一種極端的做法就是禁止使用所有的重排序和CPU快取。即關閉所有的編譯器、作業系統和處理器的優化,所有指令順序全部按照程式程式碼書寫的順序執行。去掉CPU快取記憶體,讓CPU的每次讀寫操作都直接與主存互動。
當然,上面的這種極端方案是絕對不可取的,因為這會極大影響處理器的計算效能,並且對於那些非多執行緒共享的變數是不公平的。
重排序CPU快取記憶體有利於計算機效能的提高,但卻對多CPU處理的一致性帶來了影響。為了解決這個矛盾,我們可以採取一種折中的辦法。我們用分割線把整個程式劃分成幾個程式塊,在每個程式塊內部的指令是可以重排序的,但是分割線上的指令與程式塊的其它指令之間是不可以重排序的。在一個程式塊內部,CPU不用每次都與主記憶體進行互動,只需要在CPU快取中執行讀寫操作即可,但是當程式執行到分割線處,CPU必須將執行結果同步到主記憶體或從主記憶體讀取最新的變數值。那麼,Happens-Before規則就是定義了這些程式塊的分割線。下圖展示了一個使用鎖定原則作為分割線的例子:

如圖所示,這裡的unlock M和lock M就是劃分程式的分割線。在這裡,紅色區域和綠色區域的程式碼內部是可以進行重排序的,但是unlock和lock操作是不能與它們進行重排序的。即第一個圖中的紅色部分必須要在unlock M指令之前全部執行完,第二個圖中的綠色部分必須全部在lock M指令之後執行。並且在第一個圖中的unlock M指令處,紅色部分的執行結果要全部重新整理到主存中,在第二個圖中的lock M指令處,綠色部分用到的變數都要從主存中重新讀取。
在程式中加入分割線將其劃分成多個程式塊,雖然在程式塊內部程式碼仍然可能被重排序,但是保證了程式程式碼在巨集觀上是有序的。並且可以確保在分割線處,CPU一定會和主記憶體進行互動。Happens-Before原則就是定義了程式中什麼樣的程式碼可以作為分隔線。並且無論是哪條Happens-Before原則,它們所產生分割線的作用都是相同的。

小結

在寫作本文時,我主要參考的是《Java併發程式設計實戰》和《深入理解Java虛擬機器》的最後一章,此外有部分內容是我自己對併發程式設計的一些淺薄理解,希望能夠對閱讀的人有所幫助。如有錯誤的地方,歡迎大家指正。