深入理解 happens-before 原則

2022-06-27 12:03:46

在前面的文章中,我們深入瞭解了 Java 記憶體模型,知道了 Java 記憶體模型誕生的意義,以及其要解決的問題。最終我們知道:Java 記憶體模型就是定義了 8 個基本操作以及 8 個規則,只要遵守這些規則的並行操作,那麼它們就是安全的。

即使強如樹哥的人,看了這 16 條規則也很頭疼。它們太過於繁瑣了,非常不利於我們日常程式碼的編寫。為了能幫助程式設計人員理解,於是就有了與其相等價的判斷原則 —— 先行發生原則,它可以用於判斷一個存取在並行環境下是否安全。

到這裡,我們需要明白:happens-before 原則是對 Java 記憶體模型的簡化,讓我們更好地寫出並行程式碼。就像 Java 語言等高階語言,之於組合語言、機器語言等低階語言一樣,可以讓程式設計人員免受「皮肉之苦」。

什麼是 happens-before?

happens-before 指的是 Java 記憶體模型中兩項操作的順序關係。例如說操作 A 先於操作 B,也就是說操作 A 發生在操作 B 之前,操作 A 產生的影響能夠被操作 B 觀察到。這裡的「影響」包括:記憶體中共用變數的值、傳送了訊息、呼叫了方法等。

舉個很簡單的例子:下面程式碼裡 i=1 線上程 A 中執行,而 j=i 線上程 B 中執行。因為 i=1 操作先於 j=i 執行,那麼 i=1 操作的結果就應該能夠被執行緒 B 觀察到。

// 線上程 A 中執行
i = 1;
// 線上程 B 中執行
j = i;

Java 記憶體模型下一共有 8 條 happens-before 規則,如果執行緒間的操作無法從如下幾個規則推匯出來,那麼它們的操作就沒有順序性保障,虛擬機器器或者作業系統就能隨意地進行重排序,從而可能會發生並行安全問題。

  • 程式次序規則(Program Order Rule):在一個執行緒內,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作。準確地說,應該是控制流順序而不是程式程式碼順序,因為要考慮分支、迴圈等結構。
  • 管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面對同一個鎖的lock操作。這裡必須強調的是同一個鎖,而「後面」是指時間上的先後順序。
  • volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作,這裡的「後面」同樣是指時間上的先後順序。
  • 執行緒啟動規則(Thread Start Rule):Thread物件的start()方法先行發生於此執行緒的每一個動作。
  • 執行緒終止規則(Thread Termination Rule):執行緒中的所有操作都先行發生於對此執行緒的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到執行緒已經終止執行。
  • 執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生。
  • 物件終結規則(Finalizer Rule):一個物件的初始化完成(建構函式執行結束)先行發生於它的finalize()方法的開始。
  • 傳遞性(Transitivity):如果操作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 和 B,執行緒 A 先(時間上的先後)呼叫了 setValue(1),之後執行緒 B 呼叫了同一個物件的 getValue(),那麼執行緒 B 收到的返回值是什麼?

我們依次分析一下先行發生原則中的各項規則:

  1. 首先,由於兩個方法分別由執行緒A和執行緒B呼叫,不在一個執行緒中,所以程式次序規則在這裡不適用。
  2. 接著,由於沒有同步塊,自然就不會發生lock和unlock操作,所以管程鎖定規則不適用。
  3. 繼續,由於value變數沒有被volatile關鍵字修飾,所以volatile變數規則不適用。
  4. 繼續,後面的執行緒啟動、終止、中斷規則和物件終結規則也和這裡完全沒有關係。
  5. 最後,因為沒有一個適用的先行發生規則,所以最後一條傳遞性也無從談起。

因此,即使我們知道執行緒 A 在操作時間上先於執行緒 B,但我們還是無法確定執行緒 B getValue() 方法的返回結果。換句話說,這裡面的操作不是執行緒安全的。

那怎麼修復這個問題呢?

我們至少有兩種比較簡單的方案可以選擇:

  1. 第一種,要麼把 getter/setter 方法都定義為 synchronized 方法,這樣就可以套用管程鎖定規則。
  2. 第二種,要麼把 value 定義為 volatile 變數,由於 setter 方法對 value 的修改不依賴 value 的原值,滿足 volatile 關鍵字使用場景,這樣就可以套用 volatile 變數規則來實現先行發生關係。

通過上面這個案例,我們知道:一個操作時間上線發生,不代表這個操作會「先行發生」。 那如果一個操作「先行發生」,是否就能推匯出這個操作必定是時間上先發生呢?其實並不能,因為有可能發生指令重排序。

// 如下操作在同一個執行緒中執行
int j = 1;
int j = 2;

上述程式碼在同一執行緒中執行,根據程式執行次序規則,int i = 1; 的操作先行發生於 int j = 2;,但 int j =2 的程式碼有可能被處理器先執行,因為它們不相互依賴,不影響先行發生原則的正確性。

上述這兩個案例綜合起來證明了一個結論:時間先後順序與先行發生原則之間基本沒有太大的關係,所以我們衡量並行安全問題的時候不要受到時間順序的干擾,一切必須以先行發生原則為準。

總結

happens-before 原則一共有 8 條原則,它是對 Java 記憶體模型規則的簡化,幫助程式設計人員提高程式設計效率。

時間先後順序與先行發生原則之間基本沒有太大的關係,我們衡量並行安全問題的時候不要受到時間順序的干擾,一切必須以先行發生原則為準。