活躍了將近三年的 JSR 133,近期發布了關於如何修復 Java 內存模型 (Java Memory Model, JMM)的公開建議。原始 JMM 中有幾個嚴重缺陷,這導 致了一些難度高得驚人的概念語義,這些概念原來被認為很簡單,如 volatile 、final 以及 synchronized。在這一期的 Java 理論與實踐 中,Brian Goetz 展示了如何加強 volatile 和 final 的語義,以修復 JMM。這些更改有些已經 集成在 JDK 1.4 中;而另一些將會包含在 JDK 1.5 中。
Java 平台把線程和多處理技術集成到了語言中,這種集成程度比以前的大多 數編程語言都要強很多。該語言對於平台獨立的並發及多線程技術的支持是野心 勃勃並且是具有開拓性的,或許並不奇怪,這個問題要比 Java 體系結構設計者 的原始構想要稍微困難些。關於同步和線程安全的許多底層混淆是 Java 內存模 型 (JMM)的一些難以直覺到的細微差別,這些差別最初是在 Java Language Specification 的第 17 章中指定的,並且由 JSR 133 重新指定。
例如,並不是所有的多處理器系統都表現出 緩存一致性(cache coherency );假如有一個處理器有一個更新了的變量值位於其緩存中,但還沒有被存入主 存,這樣別的處理器就可能會看不到這個更新的值。在緩存缺乏一致性的情況下 ,兩個不同的處理器可以看到在內存中同一位置處有兩種不同的值。這聽起來不 太可能,但是這卻是故意的 —— 這是一種獲得較高的性能和可伸縮性的方法 —— 但是這加重了開發者和編譯器為解決這些問題而編寫代碼的負擔。
什麼是內存模型,我為什麼需要一個內存模型?
內存模型描述的是程序中各變量(實例域、靜態域和數組元素)之間的關系 ,以及在實際計算機系統中將變量存儲到內存和從內存取出變量這樣的低層細節 。對象最終存儲在內存中,但編譯器、運行庫、處理器或緩存可以有特權定時地 在變量的指定內存位置存入或取出變量值。例如,編譯器為了優化一個循環索引 變量,可能會選擇把它存儲到一個寄存器中,或者緩存會延遲到一個更適合的時 間,才把一個新的變量值存入主存。所有的這些優化是為了幫助實現更高的性能 ,通常這對於用戶來說是透明的,但是對多處理系統來說,這些復雜的事情可能 有時會完全顯現出來。
JMM 允許編譯器和緩存以數據在處理器特定的緩存(或寄存器)和主存之間 移動的次序擁有重要的特權,除非程序員已經使用 synchronized 或 final 明 確地請求了某些可見性保證。這意味著在缺乏同步的情況下,從不同的線程角度 來看,內存的操作是以不同的次序發生的。
與之相對應地,像 C 和 C++ 這些語言就沒有顯示的內存模型 —— 但 C 語 言程序繼承了執行程序處理器的內存模型(盡管一個給定體系結構的編譯器可能 知道有關底層處理器的內存模型的一些情況,並且保持一致性的一部分責任也落 到了該編譯器的頭上)。這意味著並發的 C 語言程序可以在一個,而不能在另 一個,處理器體系結構上正確地運行。雖然一開始 JMM 會有些混亂,但這有個 很大的好處 —— 根據 JMM 而被正確同步的程序能正確地運行在任何支持 Java 的平台上。
原始 JMM 的缺點
雖然在 Java Language Specification 的第 17 章指定的 JMM 是一個野心 勃勃的嘗試,它嘗試定義一個一致的、跨平台的內存模型,但它有一些細微而重 要的缺點。 synchronized 和 volatile 的語義很讓人混淆,以致於許多有見地 的開發者有時選擇忽略這些規則,因為在舊的存儲模型下編寫正確同步的代碼非 常困難。
舊的 JMM 允許一些奇怪而混亂的事情發生,比如 final 字段看起來沒有那 種設置在構造函數裡的值(這樣使得想像上的不可變對象並不是不可變的)和內 存操作重新排序的意外結果。這也防止了其他一些有效的編譯器優化。如果您閱 讀了關於雙重檢查鎖定問題(double-checked locking problem)的任何文章( 參閱 參考資料),您將會記得內存操作重新排序是多麼的混亂,以及當您沒有 正確地同步(或者沒有積極地試圖避免同步)時,細微卻嚴重的問題會如何暗藏 在您的代碼中。更糟糕的是,許多沒有正確同步的程序在某些情況下似乎工作得 很好,例如在輕微的負載下、在單處理器系統上,或者在具有比 JMM 所要求的 更強的內存模型的處理器上。
“重新排序”這個術語用於描述幾種對內存操作的真實明顯的重新排序的類 型:
當編譯器不會改變程序的語義時,作為一種優化它可以隨意地重新排序某些 指令。
在某些情況下,可以允許處理器以顛倒的次序執行一些操作。
通常允許緩存以與程序寫入變量時所不相同的次序把變量存入主存。
從另一線程的角度來看,任何這些條件都會引發一些操作以不同於程序指定 的次序發生 —— 並且忽略重新排序的源代碼時,內存模型認為所有這些條件都 是同等的。
JSR 133 的目標
JSR 133 被授權來修復 JMM,它有幾個目標:
保留現有的安全保證,包括類型安全。
提供 無中生有安全性(out-of-thin-air safety)。這意味著變量值並不是 “無中生有”地創建的 —— 所以對於一個線程來說,要觀察到一個變量具有變 量值 X,必須有某個線程以前已經真正把變量值 X 寫入了那個變量。
“正確同步的”程序的語義應該盡可能簡單直觀。這樣,“正確同步的”應 該被正式而直觀地定義(這兩種定義應該相互一致)。
程序員應該要有信心創建多線程程序。當然,我們沒有魔法使得編寫並發程 序變得很容易,但是我們的目標是為了減輕程序員理解內存模型所有細節的負擔 。
跨大范圍的流行硬件體系結構上的高性能 JVM 實現應該是可能的。現代的處 理器在它們的內存模型上有著很大的不同;JMM 應該能夠適合於實際的盡可能多 的體系結構,而不會以犧牲性能為代價。
提供一個同步習慣用法(idiom),以允許我們發布一個對象並且使得它不用 同步就可見。這是一種叫做 初始化安全(initialization safety)的新的安全 保證。
對現有代碼應該只有最小限度的影響。
值得注意的是,有漏洞的技術(如雙重檢查鎖定)在新的內存模型下仍然有 漏洞,並且“修復”雙重檢查鎖定技術並不是新內存模型所致力的一個目標。( 但是, volatile 的新語義允許通常所提出的其中一個雙重檢查鎖定的可選方法 正確地工作,盡管我們不鼓勵這種技術。)
從 JSR 133 process 變得活躍的三年來,人們發現這些問題比他們認為重要 的任何問題都要微妙得多。這就是作為一個開拓者的代價!最終正式的語義比原 來所預料的要復雜得多,實際上它采用了一種與原先預想的完全不同的形式,但 非正式的語義是清晰直觀的,將在本文的第 2 部分概要地說明。
同步和可見性
大多數程序員都知道, synchronized 關鍵字強制實施一個互斥鎖(互相排 斥),這個互斥鎖防止每次有多個線程進入一個給定監控器所保護的同步語句塊 。但是同步還有另一個方面:正如 JMM 所指定,它強制實施某些內存可見性規 則。它確保了當存在一個同步塊時緩存被更新,當輸入一個同步塊時緩存失效。 因此,在一個由給定監控器保護的同步塊期間,一個線程所寫入的值對於其余所 有的執行由同一監控器所保護的同步塊的線程來說是可見的。它也確保了編譯器 不會把指令從一個同步塊的內部移到外部(雖然在某些情況下它會把指令從同步 塊的外部移到內部)。JMM 在缺乏同步的情況下不會做這種保證 —— 這就是只 要有多個線程訪問相同的變量時必須使用同步(或者它的同胞,易失性)的原因 。
問題 1:不可變對象不是不可變的
JMM 的其中一個最驚人的缺點是,不可變對象似乎可以改變它們的值(這種 對象的不變性旨在通過使用 final 關鍵字來得到保證)。(Public Service Reminder:讓一個對象的所有字段都為 final 並不一定使得這個對象不可變 — — 所有的字段 還 必須是原語類型或是對不可變對象的引用。)不可變對象( 如 String )被認為不要求同步。但是,因為在將內存寫方面的更改從一個線程 傳播到另一個線程時存在潛在的延遲,所以有可能存在一種競態條件,即允許一 個線程首先看到不可變對象的一個值,一段時間之後看到的是一個不同的值。
這是怎麼發生的呢?考慮到 Sun 1.4 JDK 中 String 的實現,這兒基本上有 三個重要的決定性字段:對字符數組的引用、長度和描述字符串開始的字符數組 的偏移量。 String 是以這種方式實現的,而不是只有字符數組,因此字符數組 可以在多個 String 和 StringBuffer 對象之間共享,而不需要在每次創建一個 String 時都將文本拷貝到一個新的數組裡。例如, String.substring() 創建 了一個可以與原始的 String 共享同一個字符數組的新字符串,並且這兩個字符 串僅僅只是在長度和偏移量上有所不同。
假設您執行以下的代碼:
String s1 = "/usr/tmp";
String s2 = s1.substring(4); // contains "/tmp"
字符串 s2 將具有大小為 4 的長度和偏移量,但是它將同 s1 共享包含“ /usr /tmp ”的同一字符數組。在 String 構造函數運行之前, Object 的構造 函數將用它們默認的值初始化所有字段,包括決定性的長度和偏移字段。當 String 構造器運行時,字符串長度和偏移量被設置成所需要的值。但是在舊的 內存模型下,在缺乏同步的情況下,有可能另一個線程會臨時地看到偏移量字段 具有初默認值 0,而後又看到正確的值 4。結果是 s2 的值從“ /usr ”變成了 “ /tmp ”。這並不是我們所想要的,而且在所有 JVM 或平台這是不可能的, 但是舊的內存模型規范允許這樣做。
問題 2:重新排序易失性和非易失性存儲
另一個主要領域是與 volatile 字段的內存操作重新排序有關,這個領域中 現有 JMM 引起了一些非常混亂的結果。現有 JMM 表明易失性的讀和寫是直接和 主存打交道的,這樣避免了把值存儲到寄存器或者繞過處理器特定的緩存。這使 得多個線程一般能看見一個給定變量的最新的值。可是,結果是這種 volatile 定義並沒有最初所想像的那樣有用,並且它導致了 volatile 實際意義上的重大 混亂。
為了在缺乏同步的情況下提供較好的性能,編譯器、運行庫和緩存通常被允 許重新排序普通的內存操作,只要當前執行的線程分辨不出它們的區別。(這就 是所謂的 線程內似乎是串行的語義(within-thread as-if-serial semantics )。)但是,易失性的讀和寫是完全跨線程安排的,編譯器或緩存不能在彼此之 間重新排序易失性的讀和寫。遺憾的是,通過參考普通變量的讀和寫,JMM 允許 易失性的讀和寫被重新排序,這意味著我們不能使用易失性標志作為操作已完成 的指示。考慮下面的代碼,其意圖是假定易失性字段 initialized 用於表明初 始化已經完成了。
清單 1. 使用一個易失性字段作為一個“守衛”變量
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
這裡的思想是使用易失性變量 initialized 擔任守衛來表明一套別的操作已 經完成了。這是一個很好的思想,但是它不能在舊的 JMM 下工作,因為舊的 JMM 允許非易失性的寫(比如寫到 configOptions 字段,以及寫到由 configOptions 引用 Map 的字段中)與易失性的寫一起重新排序,因此另一個 線程可能會看到 initialized 為 true,但是對於 configOptions 字段或它所 引用的對象還沒有一個一致的或者說當前的視圖。 volatile 的舊語義只承諾正 在讀和寫的變量的可見性,而不承諾其他的變量。雖然這種方法更容易有效地實 現,但結果是沒有原來所想的那麼有用。
結束語
正如 Java Language Specification 第 17 章中所指定的,JMM 有一些嚴重 的缺點,即允許一些看起來合理的程序發生一些非直觀的或不合需要的事情。如 果正確地編寫並發的類太困難的話,那麼我們可以說許多並發的類不能按預期工 作,並且這是平台中的一個缺點。幸運的是,我們可以在不破壞在舊的內存模型 下正確同步的任何代碼的同時,創建一個與大多數開發者的直覺更加一致的內存 模型,並且這一切已經由 JSR 133 process 完成。下個月,我們將介紹新的內 存模型(它的大部分功能已集成到 1.4 JDK 中)的詳細信息。