程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> Java理論與實踐: 修復Java內存模型,第2部分

Java理論與實踐: 修復Java內存模型,第2部分

編輯:關於JAVA

活躍了將近三年的 JSR 133,近期發布了關於如何修復 Java 內存模型 (Java Memory Model, JMM)的公開建議。在本系列文章的 第 1 部分,專欄作 者 Brian Goetz 主要介紹最初的 JMM 中的幾個嚴重缺陷,這些缺陷導致了一些 難度高得驚人的概念語義,這些概念原來被認為很簡單。這個月,他介紹在新 JMM 中 volatile 和 final 的語義是如何變化的,這些改變使它們的語義符合 大多數開發人員的直覺。其中一些改變已經在 JDK 1.4 中出現了,另一些改變 則要等到 JDK 1.5。請您在本文的討論論壇上與作者及其他讀者交流您的想法。

開始編寫並發代碼是一件困難的事情,語言不應當增加它的難度。雖然 Java 平台從一開始就包括了對線程的支持,包括一個計劃為正確同步的程序提供“一 次編寫,到處運行”保證的、跨平台的內存模型,但是原來的內存模型有一些漏 洞。雖然許多 Java 平台提供了比 JMM 所要求的更強的保證,但是 JMM 中的漏 洞使得無法容易地編寫可以在任何平台上運行的並發 Java 程序。所以在 2001 年 5 月,成立了以修復 Java 內存模型為目的的 JSR 133。 上個月,我討論了 其中一些漏洞,這個月,我們將討論如何堵住它們。

修復後的可見性

理解 JMM 所需要的一個關鍵概念是 可見性(visibility)——如何知道當 線程 A 執行 someVariable?=?3 時,其他線程是否可以看到線程 A 所寫的值 3 ?有一些原因使其他線程不能立即看到 someVariable 的值 3:可能是因為編譯 器為了執行效率更高而重新排序了指令,也可能是 someVariable 緩存在寄存器 中,或者它的值寫到寫處理器的緩存中、但是還沒有刷新到主存中,或者在讀處 理器的緩存中有一個老的(或者無效的)值。內存模型決定什麼時候一個線程可 以可靠地“看到”由其他線程對變量的寫入。特別是,內存模型定義了保證內存 操作跨線程的可見性的 volatile 、 synchronized 和 final 的語義。

當線程為釋放相關監視器而退出一個同步塊時,JMM 要求本地處理器緩沖刷 新到主存中。(實際上,內存模型不涉及緩存——它涉及一個抽象( 本地內存 ), 它包圍了緩存、注冊表和其他硬件和編譯優化。)與此類似,作為獲得監視 的一部分,當進入一個同步塊時,本地緩存失效,使之後的讀操作直接進入主內 存而不是本地緩存。這一過程保證當變量是由一個線程在由給定監視器保護的同 步塊中寫入,並由另一個線程在由同一監視器保護的同步塊中讀取時,對變量的 寫可被讀線程看到。如果沒有同步,則 JMM 不提供這種保證——這就是為什麼 在多個線程訪問同一個變量時,必須使用同步(或者它的更年輕的同胞 volatile )。

對 volatile 的新保證

volatile 原來的語義只保證 volatile 字段的讀寫直接在主存而不是寄存器 或者本地處理器緩存中進行,並且代表線程對 volatile 變量進行的這些操作是 按線程要求的順序進行的。換句話說,這意味著老的內存模型只保證正在讀或寫 的變量的可見性,不保證寫入其他變量的可見性。雖然可以容易實現它,但是它 沒有像最初設想的那麼有用。

雖然對 volatile 變量的讀和寫不能與對其他 volatile 變量的讀和寫一起 重新排序,但是它們仍然可以與對 nonvolatile 變量的讀寫一起重新排序。在 第 1 部分 中,介紹了清單 1 的代碼(在舊的內存模型中)是如何不足以保證 線程 B 看到 configOptions 及通過 configOptions 間接可及的所有變量(如 Map 元素)的正確值,因為 configOptions 的初始化可能已經隨 volatile initialized 變量進行重新排序。

清單 1. 用一個 volatile 變量作為“守護”

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 Expert Group 決定讓 volatile 讀 寫不能與其他內存操作一起重新排序是有意義的——可以准確地支持這種和其他 類似的用例。在新的內存模型下,如果當線程 A 寫入 volatile 變量 V 而線程 B 讀取 V 時,那麼在寫入 V 時,A 可見的所有變量值現在都可以保證對 B 是 可見的。結果就是作用更大的 volatile 語義,代價是訪問 volatile 字段時會 對性能產生更大的影響。

在這之前發生了什麼?

像對變量的讀寫這樣的操作,在線程中是根據所謂的“程序順序”——程序 的語義聲明它們應當發生的順序——排序的。(編譯器實際上對在線程中使用程 序順序是可以有一些自由的——只要保留了 as-if-serial 語義。)在不同線程 中的操作完全不一定要彼此排序——如果啟動兩個線程並且它們對任何公共監視 器都不用同步執行、或者涉及任何公共 volatile 變量,則完全 無法准確地預 言一個線程中的操作(或者對第三個線程可見)相對於另一個線程中操作的順序 。

此外,排序保證是在線程啟動、一個線程參與另一個線程、一個線程獲得或 者釋放一個監視器(進入或者退出一個同步塊)、或者一個線程訪問一個 volatile 變量時創建的。JMM 描述了程序使用同步或者 volatile 變量以協調 多個線程中的活動時所進行的的順序保證。新的 JMM 非正式地定義了一個名為 happens-before 的排序,它是程序中所有操作的部分順序,如下所示:

線程中的每一個操作 happens-before這個線程中在程序順序中後面出現的每 一個操作

對監視器的解鎖 happens-before同一監視器上的所有後續鎖定

對 volatile 字段的寫 happens-before同一 volatile 的每一個後續讀

對一個線程的 Thread.start() 調用 happens-before在啟動的線程中的所有 操作

線程中的所有操作 happens-before 從這個線程的 Thread.join() 成功返回 的所有其他線程

這些規則中的第三個——控制對 volatile 變量的讀寫,是新的並且修正了 清單 1 中的例子的問題。因為對 volatile 變量 initialized 的寫是在初始化 configOptions 之後發生的, configOptions 的使用是在 initialized 的讀後 發生的,而對 initialized 的讀是在對 initialized 的寫後發生的,因此可以 得出結論,線程 A 對 configOptions 的初始化是在線程 B 使用 configOptions 之前發生的。因而 configOptions 和通過它可及的變量對於線 程 B 是可見的。

圖 1. 用同步保證跨線程的內存寫的可見性

數據爭用

當有一個變量被多個線程讀、被至少一個線程寫、並且讀和寫不是按 hanppens-before 關系排序的時,程序就稱為有 數據爭取(data race),因而 不是一個“正確同步”的程序。

這是否修改了雙重檢查鎖定的問題?

對雙重檢查鎖定問題提出的一種修復是使包含遲緩初始化的實例的字段為一 個 volatile 字段。(有關雙重檢查鎖定的問題和對為什麼所建議的算法修復不 能解決問題的說明請參閱 參考資料。)在舊的內存模型中,這不能使雙重檢查 鎖定成為線程安全的,因為對 volatile 字段的寫仍然會與對其他 nonvolatile 字段的寫(如新構造的對象的字段)一起重新排序,因而 volatile 實例引用仍 然可能包含對一個未構造完的對象的引用。

在新的內存模型中,對雙重檢查鎖定的這個“修復”使 idiom 線程安全。但 是仍然不意味著應當使用這個 idiom!雙重檢查鎖定的要點是,它假定是性能優 化的,設計用於消除公共代碼路徑的同步,很大程度上因為對於早期的 JDK 來 說,同步是相對昂貴的。不僅非競爭的同步已經便宜 多 了,而且對 volatile 語義的新改變也使它在某些平台上比舊的語義昂貴得多。(實際上,對 volatile 字段的每一次讀或者寫都像是“半個”同步——對 volatile 的讀有 與監視器所獲得的同樣的內存語義,對 volatile 的寫有與監視器所釋放的同樣 的語義。)所以如果雙重檢查鎖定的目標是提供比更直觀的同步方式更好的性能 ,那麼這個“修復的”版本也沒有多大幫助。

不使用雙重檢查鎖定,而使用 Initialize-on-demand Holder Class idiom ,它提供了遲緩初始化,是線程安全的,而且比雙重檢查鎖定更快且沒那麼混亂 :

清單 2. 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 意味著最終

在 第 1 部分描述了在舊的內存模型中,final 字段的值似乎可以改變的一 種機制——在不使用同步時,另一個線程會首先看到 final 字段的默認值,然後 看到正確的值。

在新的內存模型中,在構造函數的 final 字段的寫與在另一個線程中對這個 對象的共享引用的初次裝載之間有一個類似於 happens-before 的關系。當構造 函數完成任務時,對 final 字段的所有寫(以及通過這些 final 字段間接可及 的變量)變為“凍結”,所有在凍結之後獲得對這個對象的引用的線程都會保證 看到所有凍結字段的凍結值。初始化 final 字段的寫將不會與構造函數關聯的 凍結後面的操作一起重新排序。

結束語

JSR 133 顯著增強了 volatile 的語義,這樣就可以可靠地使用 volatile 標志表明程序狀態被另一個線程改變了。作為使 volatile 更“重量級”的結果 ,使用 volatile 的性能成本更接近於某些情況下同步的性能成本,但是在大多 數平台上性能成本仍然相當低。JSR 133 還顯著地增強了 final 的語義。如果 一個對象的引用在構造階段不允許逸出(escape),那麼一旦構造函數完成,並 且線程發布了對另一個對象的引用,那麼在不使用同步的條件下,這個對象的 final 字段就保證對所有其他線程是可見的、正確的並且是不變的。

這些改變極大地加強了並發程序中不變對象的效用,不變對象最終成為固有 的線程安全(就像它們所要成為的那樣),即使使用數據爭用在線程之間將引用 傳遞給不變對象。

初始化安全性的一個告誡是對象的引用不許“逸出”其構造函數——構造函 數不應直接或者間接發布對正在構造的對象的引用。這包括發布對 nonstatic 內部類的引用,並一般要避免在構造函數中啟動線程。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved