JMM 最最最核心的概念:Happens-before 原則

飛天小牛肉發表於2021-05-09

關於 Happens-before,《Java 併發程式設計的藝術》書中是這樣介紹的:

Happens-before 是 JMM 最核心的概念。對應 Java 程式設計師來說,理解 Happens-before 是理解 JMM 的關鍵。

《深入理解 Java 虛擬機器 - 第 3 版》書中是這樣介紹的:

Happens-before 是 JMM 的靈魂,它是判斷資料是否存在競爭,執行緒是否安全的非常有用的手段。

我想,這兩句話就已經足夠表明 Happens-before 原則的重要性。

那為什麼 Happens-before 被不約而同的稱為 JMM 的核心和靈魂呢?

生來如此。

JMM 設計者的難題與完美的解決方案

上篇文章「跬步千里」詳解 Java 記憶體模型與原子性、可見性、有序性 我們學習了 JMM 及其三大性質,事實上,從 JMM 設計者的角度來看,可見性和有序性其實是互相矛盾的兩點:

  • 一方面,對於程式設計師來說,我們希望記憶體模型易於理解、易於程式設計,為此 JMM 的設計者要為程式設計師提供足夠強的記憶體可見性保證,專業術語稱之為 “強記憶體模型”。
  • 而另一方面,編譯器和處理器則希望記憶體模型對它們的束縛越少越好,這樣它們就可以做盡可能多的優化(比如重排序)來提高效能,因此 JMM 的設計者對編譯器和處理器的限制要儘可能地放鬆,專業術語稱之為 “弱記憶體模型”。

對於這個問題,從 JDK 5 開始,也就是在 JSR-133 記憶體模型中,終於給出了一套完美的解決方案,那就是 Happens-before 原則,Happens-before 直譯為 “先行發生”,《JSR-133:Java Memory Model and Thread Specification》對 Happens-before 關係的定義如下:

1)如果一個操作 Happens-before 另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。

2)兩個操作之間存在 Happens-before 關係,並不意味著 Java 平臺的具體實現必須要按照 Happens-before 關係指定的順序來執行。如果重排序之後的執行結果,與按 Happens-before 關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM 允許這種重排序)

並不難理解,第 1 條定義是 JMM 對程式設計師強記憶體模型的承諾。從程式設計師的角度來說,可以這樣理解 Happens-before 關係:如果 A Happens-before B,那麼 JMM 將向程式設計師保證 — A 操作的結果將對 B 可見,且 A 的執行順序排在 B 之前。注意,這只是 Java記憶體模型向程式設計師做出的保證!

需要注意的是,不同於 as-if-serial 語義只能作用在單執行緒,這裡提到的兩個操作 A 和 B 既可以是在一個執行緒之內,也可以是在不同執行緒之間。也就是說,Happens-before 提供跨執行緒的記憶體可見性保證

針對這個第 1 條定義,我來舉個例子:

// 以下操作線上程 A 中執行
i = 1; // a

// 以下操作線上程 B 中執行
j = i; // b

// 以下操作線上程 C 中執行
i = 2; // c

假設執行緒 A 中的操作 a Happens-before 執行緒 B 的操作 b,那我們就可以確定操作 b 執行後,變數 j 的值一定是等於 1。

得出這個結論的依據有兩個:一是根據 Happens-before 原則,a 操作的結果對 b 可見,即 “i=1” 的結果可以被觀察到;二是執行緒 C 還沒執行,執行緒 A 操作結束之後沒有其他執行緒會修改變數 i 的值。

現在再來考慮執行緒 C,我們依然保持 a Happens-before b ,而 c 出現在 a 和 b 的操作之間,但是 c 與 b 沒有 Happens-before 關係,也就是說 b 並不一定能看到 c 的操作結果。那麼 b 操作的結果也就是 j 的值就不確定了,可能是 1 也可能是 2,那這段程式碼就是執行緒不安全的。


再來看 Happens-before 的第 2 條定義,這是 JMM 對編譯器和處理器弱記憶體模型的保證,在給予充分的可操作空間下,對編譯器和處理器的重排序進行一定的約束。也就是說,JMM 其實是在遵循一個基本原則:只要不改變程式的執行結果(指的是單執行緒程式和正確同步的多執行緒程式),編譯器和處理器怎麼優化都行

JMM 這麼做的原因是:程式設計師對於這兩個操作是否真的被重排序並不關心,程式設計師關心的是執行結果不能被改變。

文字可能不是很好理解,我們舉個例子,來解釋下第 2 條定義:雖然兩個操作之間存在 Happens-before 關係,但不意味著 Java 平臺的具體實現必須要按照 Happens-before 關係指定的順序來執行。

int a = 1; 		// A
int b = 2;		// B
int c = a + b;	// C

根據 Happens-before 規則(下文會講),上述程式碼存在 3 個 Happens-before 關係:

1)A Happens-before B

2)B Happens-before C

3)A Happens-before C

可以看出來,在 3 個 Happens-before 關係中,第 2 個和第 3 個是必需的,但第 1 個是不必要的。

也就是說,雖然 A Happens-before B,但是 A 和 B 之間的重排序完全不會改變程式的執行結果,所以 JMM 是允許編譯器和處理器執行這種重排序的。

看下面這張 JMM 的設計圖更直觀:

圖片來源《Java 併發程式設計的藝術》

其實,可以這麼簡單的理解,為了避免 Java 程式設計師為了理解 JMM 提供的記憶體可見性保證而去學習複雜的重排序規則以及這些規則的具體實現方法,JMM 就出了這麼一個簡單易懂的 Happens-before 原則,一個 Happens-before 規則就對應於一個或多個編譯器和處理器的重排序規則,這樣,我們只需要弄明白 Happens-before 就行了。

圖片來源《Java 併發程式設計的藝術》

8 條 Happens-before 規則

《JSR-133:Java Memory Model and Thread Specification》定義瞭如下 Happens-before 規則, 這些就是 JMM 中“天然的” Happens-before 關係,這些 Happens-before 關係無須任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推匯出來,則它們就沒有順序性保障,JVM 可以對它們隨意地進行重排序:

1)程式次序規則(Program Order Rule):在一個執行緒內,按照控制流順序,書寫在前面的操作先行發生(Happens-before)於書寫在後面的操作。注意,這裡說的是控制流順序而不是程式程式碼順序,因為要考慮分支、迴圈等結構。

這個很好理解,符合我們的邏輯思維。比如我們上面舉的例子:

int a = 1; 		// A
int b = 2;		// B
int c = a + b;	// C

根據程式次序規則,上述程式碼存在 3 個 Happens-before 關係:

  • A Happens-before B
  • B Happens-before C
  • A Happens-before C

2)管程鎖定規則(Monitor Lock Rule):一個 unlock 操作先行發生於後面對同一個鎖的 lock 操作。這裡必須強調的是 “同一個鎖”,而 “後面” 是指時間上的先後。

這個規則其實就是針對 synchronized 的。JVM 並沒有把 lockunlock 操作直接開放給使用者使用,但是卻提供了更高層次的位元組碼指令 monitorentermonitorexit 來隱式地使用這兩個操作。這兩個位元組碼指令反映到 Java 程式碼中就是同步塊 — synchronized

舉個例子:

synchronized (this) { // 此處自動加鎖
	if (x < 1) {
        x = 1;
    }      
} // 此處自動解鎖

根據管程鎖定規則,假設 x 的初始值是 10,執行緒 A 執行完程式碼塊後 x 的值會變成 1,執行完自動釋放鎖,執行緒 B 進入程式碼塊時,能夠看到執行緒 A 對 x 的寫操作,也就是執行緒 B 能夠看到 x == 1。

3)volatile 變數規則(Volatile Variable Rule):對一個 volatile 變數的寫操作先行發生於後面對這個變數的讀操作,這裡的 “後面” 同樣是指時間上的先後。

這個規則就是 JDK 1.5 版本對 volatile 語義的增強,其意義之重大,靠著這個規則搞定可見性易如反掌。

舉個例子:

假設執行緒 A 執行 writer() 方法之後,執行緒 B 執行 reader() 方法。

根據根據程式次序規則:1 Happens-before 2;3 Happens-before 4。

根據 volatile 變數規則:2 Happens-before 3。

根據傳遞性規則:1 Happens-before 3;1 Happens-before 4。

也就是說,如果執行緒 B 讀到了 “flag==true” 或者 “int i = a” 那麼執行緒 A 設定的“a=42”對執行緒 B 是可見的。

看下圖:

4)執行緒啟動規則(Thread Start Rule):Thread 物件的 start() 方法先行發生於此執行緒的每一個動作。

比如說主執行緒 A 啟動子執行緒 B 後,子執行緒 B 能夠看到主執行緒在啟動子執行緒 B 前的所有操作。

5)執行緒終止規則(Thread Termination Rule):執行緒中的所有操作都先行發生於對此執行緒的終止檢測,我們可以通過 Thread 物件的 join() 方法是否結束、Thread 物件的 isAlive() 的返回值等手段檢測執行緒是否已經終止執行。

6)執行緒中斷規則(Thread Interruption Rule):對執行緒 interrupt() 方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過 Thread 物件的 interrupted() 方法檢測到是否有中斷髮生。

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

8)傳遞性(Transitivity):如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,那就可以得出操作 A 先行發生於操作 C 的結論。

“時間上的先發生” 與 “先行發生”

上述 8 種規則中,還不斷提到了時間上的先後,那麼,“時間上的先發生” 與 “先行發生(Happens-before)” 到底有啥區別?

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

很遺憾,這兩個推論都是不成立的。

舉兩個例子論證一下:

private int value = 0;

// 執行緒 A 呼叫
pubilc void setValue(int value){    
    this.value = value;
}

// 執行緒 B 呼叫
public int getValue(){
    return value;
}

假設存線上程 A 和 B,執行緒 A 先(時間上的先後)呼叫了 setValue(1),然後執行緒 B 呼叫了同一個物件的 getValue() ,那麼執行緒 B 收到的返回值是什麼?

我們根據上述 Happens-before 的 8 大規則依次分析一下:

由於兩個方法分別由執行緒 A 和 B 呼叫,不在同一個執行緒中,所以程式次序規則在這裡不適用;

由於沒有 synchronized 同步塊,自然就不會發生 lock 和 unlock 操作,所以管程鎖定規則在這裡不適用;

同樣的,volatile 變數規則,執行緒啟動、終止、中斷規則和物件終結規則也和這裡完全沒有關係。

因為沒有一個適用的 Happens-before 規則,所以第 8 條規則傳遞性也無從談起。

因此我們可以判定,儘管執行緒 A 在操作時間上來看是先於執行緒 B 的,但是並不能說 A Happens-before B,也就是 A 執行緒操作的結果 B 不一定能看到。所以,這段程式碼是執行緒不安全的。

想要修復這個問題也很簡單?既然不滿足 Happens-before 原則,那我修改下讓它滿足不就行了。比如說把 Getter/Setter 方法都用 synchronized 修飾,這樣就可以套用管程鎖定規則;再比如把 value 定義為 volatile 變數,這樣就可以套用 volatile 變數規則等。

這個例子,就論證了一個操作 “時間上的先發生” 不代表這個操作會是 “先行發生(Happens-before)”

再來看一個例子:

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

假設這段程式碼中的兩條賦值語句在同一個執行緒之中,那麼根據程式次序規則,“int i = 1” 的操作先行發生(Happens-before)於 “int j = 2”,但是,還記得 Happens-before 的第 2 條定義嗎?還記得上文說過 JMM 實際上是遵守這樣的一條原則:只要不改變程式的執行結果(指的是單執行緒程式和正確同步的多執行緒程式),編譯器和處理器怎麼優化都行。

所以,“int j=2” 這句程式碼完全可能優先被處理器執行,因為這並不影響程式的最終執行結果。

那麼,這個例子,就論證了一個操作 “先行發生(Happens-before)” 不代表這個操作一定是“時間上的先發生”

這樣,綜上兩例,我們可以得出這樣一個結論:Happens-before 原則與時間先後順序之間基本沒有因果關係,所以我們在衡量併發安全問題的時候,儘量不要受時間順序的干擾,一切必須以 Happens-before 原則為準。

Happens-before 與 as-if-serial

綜上,我覺得其實讀懂了下面這句話也就讀懂了 Happens-before 了,這句話上文也出現過幾次:JMM 其實是在遵循一個基本原則,即只要不改變程式的執行結果(指的是單執行緒程式和正確同步的多執行緒程式),編譯器和處理器怎麼優化都行。

再回顧下 as-if-serial 語義:不管怎麼重排序,單執行緒環境下程式的執行結果不能被改變。

各位發現沒有?本質上來說 Happens-before 關係和 as-if-serial 語義是一回事,都是為了在不改變程式執行結果的前提下,儘可能地提高程式執行的並行度。只不過後者只能作用在單執行緒,而前者可以作用在正確同步的多執行緒環境下:

  • as-if-serial 語義保證單執行緒內程式的執行結果不被改變,Happens-before 關係保證正確同步的多執行緒程式的執行結果不被改變。
  • as-if-serial 語義給編寫單執行緒程式的程式設計師創造了一個幻境:單執行緒程式是按程式的順序來執行的。Happens-before 關係給編寫正確同步的多執行緒程式的程式設計師創造了一個幻境:正確同步的多執行緒程式是按 Happens-before 指定的順序來執行的。

References

  • 《Java 併發程式設計的藝術》
  • 《深入理解 Java 虛擬機器 - 第 3 版》

? 關注公眾號 | 飛天小牛肉,即時獲取更新

  • 博主東南大學碩士在讀,攜程 Java 後臺開發暑期實習生,利用課餘時間運營一個公眾號『 飛天小牛肉 』,2020/12/29 日開通,專注分享計算機基礎(資料結構 + 演算法 + 計算機網路 + 資料庫 + 作業系統 + Linux)、Java 技術棧等相關原創技術好文。本公眾號的目的就是讓大家可以快速掌握重點知識,有的放矢。關注公眾號第一時間獲取文章更新,成長的路上我們一起進步
  • 並推薦個人維護的開源教程類專案: CS-Wiki(Gitee 推薦專案,現已累計 1.6k+ star), 致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習 ~ ?
  • 如果各位小夥伴春招秋招沒有拿得出手的專案的話,可以參考我寫的一個專案「開源社群系統 Echo」Gitee 官方推薦專案,目前已累計 700+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 並提供詳細的開發文件和配套教程。公眾號後臺回覆 Echo 可以獲取配套教程,目前尚在更新中。

相關文章