歷經近三年,JSR 133 近期發布了修復 Java 記憶體模型的公開建議。本系列第一部分介紹了原始 JMM 的嚴重缺陷,導致某些本被認為簡單的概念語義(如 volatile、final、synchronized)難度驚人。本文將闡述新 JMM 中 volatile 與 final 語義的變化,使其符合大多數開發人員直覺。部分變更已在 JDK 1.4 出現,其餘將在 JDK 1.5 實現。
理解 JMM 的關鍵概念是可見性 — 即如何知道當執行緒 A 執行 someVariable = 3 時,其他執行緒是否能看到寫入的值 3?原因可能包括:編譯器為效率重排指令、變量緩存在暫存器、值寫入寫處理器緩存但未刷新至主記憶體、或讀處理器緩存中有舊值。記憶體模型決定執行緒何時能可靠「看到」其他執行緒對變量的寫入。特別是,JMM 定義了保證跨執行緒記憶體操作可見性的 volatile、synchronized 和 final 語義。
當執行緒釋放監視器退出同步區塊時,JMM 要求本地處理器緩衝刷新至主記憶體。同理,進入同步區塊獲取監視器時,本地緩存失效,後續讀操作直接進入主記憶體。這保證了由同一監視器保護的同步區塊中,寫入的變量對讀取執行緒可見。若無同步,JMM 不提供此保證 — 這就是多執行緒存取同一變量時必須使用同步(或 volatile)的原因。
舊 volatile 語義僅保證 volatile 欄位讀寫直接在主記憶體而非暫存器或本地處理器緩存進行,且按執行緒要求順序執行。換言之,舊模型僅保證正在讀寫變量的可見性,不保證其他變量寫入的可見性。雖易實作,但用處不如最初設想。
雖然 volatile 讀寫不可與其他 volatile 讀寫重排,但仍可與 non-volatile 讀寫重排。第一部分展示的程式碼(舊模型下)不足以保證執行緒 B 看到 configOptions 及其可及變量的正確值,因 configOptions 初始化可能與 volatile initialized 重排。
Map configOptions;
char[] configText;
volatile boolean initialized = false;
// In Thread A
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// In Thread B
while (!initialized)
sleep();
// use configOptions
這是 volatile 常見用例 — 用一個 volatile 欄位作為「守衛」表明一組共享變量已初始化。JSR 133 專家組決定讓 volatile 讀寫不可與其他記憶體操作重排,精準支援此類用例。新模型下,若執行緒 A 寫入 volatile 變量 V,執行緒 B 讀取 V,則寫入 V 時 A 可見的所有變量值現保證對 B 可見。結果是語義更強的 volatile,代價是存取 volatile 欄位的性能影響較大。
如變量讀寫等操作,在執行緒中按所謂「程式順序」 — 程式語義宣告的發生順序 — 排序(編譯器實際上在保留 as-if-serial 語義下有一定自由度)。不同執行緒中的操作無需彼此排序 — 若啟動兩執行緒且無同步或共享 volatile 變量,完全無法準確預測一執行緒操作相對另一執行緒的順序。
此外,排序保證在執行緒啟動、一執行緒加入另一執行緒、獲取或釋放監視器(進入/退出同步區塊)、或存取 volatile 變量時建立。JMM 描述程式使用同步或 volatile 協調多執行緒活動時的排序保證。新 JMM 非正式定義了 happens-before 排序,即程式中所有操作的部分順序:
Thread.start() 調用 happens-before 被啟動執行緒中所有操作Thread.join() 成功返回的所有其他執行緒第三條規則 — 控制 volatile 讀寫 — 是新增的,修復了清單 1 的問題。因為寫入 initialized 在初始化 configOptions 之後,使用 configOptions 在讀取 initialized 之後,且讀取在寫入之後,可推論執行緒 A 初始化 configOptions 在執行緒 B 使用之前發生,故 configOptions 及其可及變量對執行緒 B 可見。
當一變量被多執行緒讀、至少一執行緒寫、且讀寫非按 happens-before 關係排序時,程式即有資料競爭,因而非「正確同步」程式。
對雙重檢查鎖定的一種修復是使包含延遲初始化實例的欄位為 volatile。舊模型下這不足以使其執行緒安全,因 volatile 寫入仍可與其他 non-volatile 寫入(如新建構物件的欄位)重排,volatile 實例引用仍可能指向未完全建構的物件。
新模型下,此「修復」使 idiom 執行緒安全。但不意味著應使用此 idiom! 雙重檢查鎖定旨在消除公共程式碼路徑的同步,因早期 JDK 同步相對昂貴。如今非競爭同步已大幅降低成本,且新 volatile 語義在某些平台上比舊語義昂貴得多(實際上每次 volatile 讀寫如「半個」同步)。若目標是提供比直觀同步更好性能,此「修復版」幫助不大。
建議使用 Initialize-on-demand Holder Class idiom,它提供延遲初始化、執行緒安全,且比雙重檢查鎖定更快、不那麼混亂:
private static class LazySomethingHolder {
public static Something something = new Something();
}
...
public static Something getInstance() {
return LazySomethingHolder.something;
}
此 idiom 由類初始化操作(如靜態初始化器)保證對使用該類的所有執行緒可見、內部類直到有執行緒引用其欄位或方法才載入這兩事實衍生出執行緒安全性與延遲初始化。
新 JMM 還提供初始化安全性保證 — 只要物件正確建構(意即建構函式完成前不發布對該物件的引用),則所有執行緒都會看到建構函式中設置的 final 欄位值,無論是否使用同步在執行緒間傳遞引用。且所有可透過正確建構物件的 final 欄位可及的變量(如 final 引用物件的 final 欄位),也保證對其他執行緒可見。意味著若 final 欄位包含如 LinkedList 引用,除引用正確值可見外,該 LinkedList 建構時的內容在無同步情況下也對其他執行緒可見。結果是顯著增強 final 意義 — 可無同步安全存取 final 欄位,編譯器可假定 final 不變,優化多次提取。
第一部分描述了舊模型中 final 欄位值似乎可變的機制 — 另一執行緒在無同步時先看到預設值,後見正確值。
新模型中,建構函式中 final 欄位寫入與另一執行緒中對該物件共享引用的初次載入間有類似 happens-before 關係。建構函式完成時,對 final 欄位的所有寫入(及間接可及變量)變為「凍結」,所有凍結後獲得引用的執行緒保證看到所有凍結欄位的凍結值。初始化 final 欄位寫入不會與建構函式關聯的凍結後操作重排。
JSR 133 顯著增強 volatile 語義,可靠使用 volatile 標誌表明程式狀態被另一執行緒改變。作為讓 volatile 更「重量級」的結果,使用 volatile 的性能成本更接近某些情況下同步的成本,但在大多數平台上仍相當低。JSR 133 也顯著增強 final 語義。若物件引用在建構階段不允許逸出,一旦建構函式完成並發布引用,無同步條件下該物件 final 欄位保證對所有其他執行緒可見、正確且不變。
這些變更極大加強並發程式中不可變物件的效用,不可變物件終成固有執行緒安全(如其所應),即使使用資料競爭在執行緒間傳遞不可變物件引用。
初始化安全性的一項告誡:物件引用不得「逸出」其建構函式 — 建構函式不應直接或間接發布正在建構物件的引用。這包括發布 non-static 內部類引用,一般避免在建構函式中啟動執行緒。