1. 程式人生 > >29、Java記憶體模型中的happen-before是什麼?

29、Java記憶體模型中的happen-before是什麼?

Java 語言在設計之初就引入了執行緒的概念,以充分利用現代處理器的計算能力,這既帶來了強大、靈活的多執行緒機制,也帶來了執行緒安全等令人混淆的問題,而 Java 記憶體模型(Java Memory Model,JMM)為我們提供了一個在紛亂之中達成一致的指導準則。

今天我要問你的問題是,Java 記憶體模型中的 happen-before 是什麼?

典型回答

Happen-before 關係,是 Java 記憶體模型中保證多執行緒操作可見性的機制,也是對早期語言規範中含糊的可見性概念的一個精確定義。

它的具體表現形式,包括但遠不止是我們直覺中的 synchronized、volatile、lock 操作順序等方面,例如:

  •   執行緒內執行的每個操作,都保證 happen-before 後面的操作,這就保證了基本的程式順序規則,這是開發者在書寫程式時的基本約定。
  •   對於 volatile 變數,對它的寫操作,保證 happen-before 在隨後對該變數的讀取操作。
  •   對於一個鎖的解鎖操作,保證 happen-before 加鎖操作。
  •   物件構建完成,保證 happen-before 於 finalizer 的開始動作。
  •   甚至是類似執行緒內部操作的完成,保證 happen-before 其他 Thread.join() 的執行緒等。

這些 happen-before 關係是存在著傳遞性的,如果滿足 a happen-before b 和 b happen-before c,那麼 a happen-before c 也成立。

前面我一直用 happen-before,而不是簡單說前後,是因為它不僅僅是對執行時間的保證,也包括對記憶體讀、寫操作順序的保證。僅僅是時鐘順序上的先後,並不能保證執行緒互動的可見性。

 

考點分析

今天的問題是一個常見的考察 Java 記憶體模型基本概念的問題,我前面給出的回答儘量選擇了和日常開發相關的規則。

JMM 是面試的熱點,可以看作是深入理解 Java 併發程式設計、編譯器和 JVM 內部機制的必要條件,但這同時也是個容易讓初學者無所適從的主題。對於學習 JMM,我有一些個人建議:

  •   明確目的,剋制住技術的誘惑。除非你是編譯器或者 JVM 工程師,否則我建議不要一頭扎進各種 CPU  體系結構,糾結於不同的快取、流水線、執行單元等。這些東西雖然很酷,但其複雜性是超乎想象的,很可能會無謂增加學習難度,也未必有實踐價值。
  •   剋制住對“祕籍”的誘惑。有些時候,某些程式設計方式看起來能起到特定效果,但分不清是實現差異導致的“表現”,還是“規範”要求的行為,就不要依賴於這種“表現”去程式設計,儘量遵循語言規範進行,這樣我們的應用行為才能更加可靠、可預計。


在這一講中,兼顧面試和程式設計實踐,我會結合例子梳理下面兩點:

  •   為什麼需要 JMM,它試圖解決什麼問題?
  •   JMM 是如何解決可見性等各種問題的?類似 volatile,體現在具體用例中有什麼效果?


注意,專欄中 Java 記憶體模型就是特指 JSR-133 中重新定義的 JMM 規範。在特定的上下文裡,也許會與 JVM(Java)記憶體結構等混淆,並不存在絕對的對錯,但一定要清楚面試官的本意,有的面試官也會特意考察是否清楚這兩種概念的區別。

 

知識擴充套件

為什麼需要 JMM,它試圖解決什麼問題?

Java 是最早嘗試提供記憶體模型的語言,這是簡化多執行緒程式設計、保證程式可移植性的一個飛躍。早期類似 C、C++ 等語言,並不存在記憶體模型的概念(C++ 11 中也引入了標準記憶體模型),其行為依賴於處理器本身的記憶體一致性模型,但不同的處理器可能差異很大,所以一段 C++ 程式在處理器 A 上執行正常,並不能保證其在處理器 B 上也是一致的。

即使如此,最初的 Java 語言規範仍然是存在著缺陷的,當時的目標是,希望 Java 程式可以充分利用現代硬體的計算能力,同時保持“書寫一次,到處執行”的能力。

但是,顯然問題的複雜度被低估了,隨著 Java 被執行在越來越多的平臺上,人們發現,過於泛泛的記憶體模型定義,存在很多模稜兩可之處,對 synchronized 或 volatile 等,類似指令重排序時的行為,並沒有提供清晰規範。這裡說的指令重排序,既可以是編譯器優化行為,也可能是源自於現代處理器的亂序執行等。

 

換句話說:

  •   既不能保證一些多執行緒程式的正確性,例如最著名的就是雙檢鎖(Double-Checked Locking,DCL)的失效問題,具體可以參考我在第 14 講對單例模式的說明,雙檢鎖可能導致未完整初始化的物件被訪問,理論上這叫併發程式設計中的安全釋出(Safe Publication)失敗。
  •   也不能保證同一段程式在不同的處理器架構上表現一致,例如有的處理器支援快取一致性,有的不支援,各自都有自己的記憶體排序模型。


所以,Java 迫切需要一個完善的 JMM,能夠讓普通 Java 開發者和編譯器、JVM 工程師,能夠清晰地達成共識。換句話說,可以相對簡單並準確地判斷出,多執行緒程式什麼樣的執行序列是符合規範的。

所以:

  •   對於編譯器、JVM 開發者,關注點可能是如何使用類似記憶體屏障(Memory-Barrier)之類技術,保證執行結果符合 JMM 的推斷。
  •   對於 Java 應用開發者,則可能更加關注 volatile、synchronized 等語義,如何利用類似 happen-before  的規則,寫出可靠的多執行緒應用,而不是利用一些“祕籍”去糊弄編譯器、JVM。


我畫了一個簡單的角色層次圖,不同工程師分工合作,其實所處的層面是有區別的。JMM 為 Java 工程師隔離了不同處理器記憶體排序的區別,這也是為什麼我通常不建議過早深入處理器體系結構,某種意義上來說,這樣本就違背了 JMM 的初衷。


JMM 是怎麼解決可見性等問題的呢?

在這裡,我有必要簡要介紹一下典型的問題場景。

我在第 25 講裡介紹了 JVM 內部的執行時資料區,但是真正程式執行,實際是要跑在具體的處理器核心上。你可以簡單理解為,把本地變數等資料從記憶體載入到快取、暫存器,然後運算結束寫回主記憶體。你可以從下面示意圖,看這兩種模型的對應。


看上去很美好,但是當多執行緒共享變數時,情況就複雜了。試想,如果處理器對某個共享變數進行了修改,可能只是體現在該核心的快取裡,這是個本地狀態,而執行在其他核心上的執行緒,可能還是載入的舊狀態,這很可能導致一致性的問題。從理論上來說,多執行緒共享引入了複雜的資料依賴性,不管編譯器、處理器怎麼做重排序,都必須尊重資料依賴性的要求,否則就打破了正確性!這就是正JMM 所要解決的問題。

JMM 內部的實現通常是依賴於所謂的記憶體屏障,通過禁止某些重排序的方式,提供記憶體可見性保證,也就是實現了各種 happen-before 規則。與此同時,更多複雜度在於,需要儘量確保各種編譯器、各種體系結構的處理器,都能夠提供一致的行為。

 

我以 volatile 為例,看看如何利用記憶體屏障實現 JMM 定義的可見性?

對於一個 volatile 變數:

  •   對該變數的寫操作之後,編譯器會插入一個寫屏障。
  •   對該變數的讀操作之前,編譯器會插入一個讀屏障。


記憶體屏障能夠在類似變數讀、寫操作之後,保證其他執行緒對 volatile 變數的修改對當前執行緒可見,或者本地修改對其他執行緒提供可見性。換句話說,執行緒寫入,寫屏障會通過類似強迫刷出處理器快取的方式,讓其他執行緒能夠拿到最新數值。

如果你對更多記憶體屏障的細節感興趣,或者想了解不同體系結構的處理器模型,建議參考 JSR-133相關文件,我個人認為這些都是和特定硬體相關的,記憶體屏障之類只是實現 JMM 規範的技術手段,並不是規範的要求。

 

從應用開發者的角度,JMM 提供的可見性,體現在類似 volatile 上,具體行為是什麼樣呢?

我這裡循序漸進的舉兩個例子。首先,前幾天有同學問我一個問題,請看下面的程式碼片段,希望達到的效果是,當 condition 被賦值為 false 時,執行緒 A 能夠從迴圈中退出。

// Thread A
while (condition) {
}

// Thread B
condition = false;

這裡就需要 condition 被定義為 volatile 變數,不然其數值變化,往往並不能被執行緒 A 感知,進而無法退出。當然,也可以在 while 中,新增能夠直接或間接起到類似效果的程式碼。

第二,我想舉 Brian Goetz 提供的一個經典用例,使用 volatile 作為守衛物件,實現某種程度上輕量級的同步,請看程式碼片段:

Map configOptions;
char[] configText;
volatile boolean initialized = false;
 
// Thread A
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
 
// Thread B
while (!initialized)
  sleep();
// use configOptions

JSR-133 重新定義的 JMM 模型,能夠保證執行緒 B 獲取的 configOptions 是更新後的數值。

也就是說 volatile 變數的可見性發生了增強,能夠起到守護其上下文的作用。執行緒 A 對 volatile 變數的賦值,會強制將該變數自己和當時其他變數的狀態都刷出快取,為執行緒 B 提供可見性。當然,這也是以一定的效能開銷作為代價的,但畢竟帶來了更加簡單的多執行緒行為。

我們經常會說 volatile 比 synchronized 之類更加輕量,但輕量也僅僅是相對的,volatile 的讀、寫仍然要比普通的讀寫要開銷更大,所以如果你是在效能高度敏感的場景,除非你確定需要它的語義,不然慎用。

今天,我從 happen-before 關係開始,幫你理解了什麼是 Java 記憶體模型。為了更方便理解,我作了簡化,從不同工程師的角色劃分等角度,闡述了問題的由來,以及 JMM 是如何通過類似記憶體屏障等技術實現的。最後,我以 volatile 為例,分析了可見性在多執行緒場景中的典型用例。

 

一課一練

關於今天我們討論的題目你做到心中有數了嗎?今天留給你的思考題是,給定一段程式碼,如何驗證所有符合 JMM 執行可能?有什麼工具可以輔助嗎?