Java 平臺把線程和多處理技術集成到了語言中,這種集成程度比之前的大多數編程語言都要強不少。該語言對於平臺獨立的併發及多線程技術的支持是野心勃勃而且是具備開拓性的,或許並不奇怪,這個問題要比 Java 體系結構設計者的原始構想要稍微困難些。關於同步和線程安全的許多底層混淆是 Java 內存模型 (JMM)的一些難以直覺到的細微差異,這些差異最初是在 Java Language Specification 的第 17 章中指定的,而且由 JSR 133 從新指定。 編程
例如,並非全部的多處理器系統都表現出 緩存一致性(cache coherency);假若有一個處理器有一個更新了的變量值位於其緩存中,但尚未被存入主存,這樣別的處理器就可能會看不到這個更新的值。在緩存缺少一致性的狀況下,兩個不一樣的處理器能夠看到在內存中同一位置處有兩種不一樣的值。這聽起來不太可能,可是這倒是故意的 —— 這是一種得到較高的性能和可伸縮性的方法 —— 可是這加劇了開發者和編譯器爲解決這些問題而編寫代碼的負擔。 數組
內存模型描述的是程序中各變量(實例域、靜態域和數組元素)之間的關係,以及在實際計算機系統中將變量存儲到內存和從內存取出變量這樣的低層細節。對象最終存儲在內存中,但編譯器、運行庫、處理器或緩存能夠有特權定時地在變量的指定內存位置存入或取出變量值。例如,編譯器爲了優化一個循環索引變量,可能會選擇把它存儲到一個寄存器中,或者緩存會延遲到一個更適合的時間,才把一個新的變量值存入主存。全部的這些優化是爲了幫助實現更高的性能,一般這對於用戶來講是透明的,可是對多處理系統來講,這些複雜的事情可能有時會徹底顯現出來。 安全
JMM 容許編譯器和緩存以數據在處理器特定的緩存(或寄存器)和主存之間移動的次序擁有重要的特權,除非程序員已經使用 synchronized
或 final
明確地請求了某些可見性保證。這意味着在缺少同步的狀況下,從不一樣的線程角度來看,內存的操做是以不一樣的次序發生的。 多線程
與之相對應地,像 C 和 C++ 這些語言就沒有顯示的內存模型 —— 但 C 語言程序繼承了執行程序處理器的內存模型(儘管一個給定體系結構的編譯器可能知道有關底層處理器的內存模型的一些狀況,而且保持一致性的一部分責任也落到了該編譯器的頭上)。這意味着併發的 C 語言程序能夠在一個,而不能在另外一個,處理器體系結構上正確地運行。雖然一開始 JMM 會有些混亂,但這有個很大的好處 —— 根據 JMM 而被正確同步的程序能正確地運行在任何支持 Java 的平臺上。 併發
回頁首編程語言
雖然在 Java Language Specification 的第 17 章指定的 JMM 是一個野心勃勃的嘗試,它嘗試定義一個一致的、跨平臺的內存模型,但它有一些細微而重要的缺點。 synchronized
和 volatile
的語義很讓人混淆,以至於許多有見地的開發者有時選擇忽略這些規則,由於在舊的存儲模型下編寫正確同步的代碼很是困難。
舊的 JMM 容許一些奇怪而混亂的事情發生,好比 final 字段看起來沒有那種設置在構造函數裏的值(這樣使得想像上的不可變對象並非不可變的)和內存操做從新排序的意外結果。這也防止了其餘一些有效的編譯器優化。若是您閱讀了關於雙重檢查鎖定問題(double-checked locking problem)的任何文章(參閱 參考資料),您將會記得內存操做從新排序是多麼的混亂,以及當您沒有正確地同步(或者沒有積極地試圖避免同步)時,細微卻嚴重的問題會如何暗藏在您的代碼中。更糟糕的是,許多沒有正確同步的程序在某些狀況下彷佛工做得很好,例如在輕微的負載下、在單處理器系統上,或者在具備比 JMM 所要求的更強的內存模型的處理器上。
「從新排序」這個術語用於描述幾種對內存操做的真實明顯的從新排序的類型:
- 當編譯器不會改變程序的語義時,做爲一種優化它能夠隨意地從新排序某些指令。
- 在某些狀況下,能夠容許處理器以顛倒的次序執行一些操做。
- 一般容許緩存以與程序寫入變量時所不相同的次序把變量存入主存。
從另外一線程的角度來看,任何這些條件都會引起一些操做以不一樣於程序指定的次序發生 —— 而且忽略從新排序的源代碼時,內存模型認爲全部這些條件都是同等的。
JSR 133 被受權來修復 JMM,它有幾個目標:
- 保留現有的安全保證,包括類型安全。
- 提供 無中生有安全性(out-of-thin-air safety)。這意味着變量值並非「無中生有」地建立的 —— 因此對於一個線程來講,要觀察到一個變量具備變量值 X,必須有某個線程之前已經真正把變量值 X 寫入了那個變量。
- 「正確同步的」程序的語義應該儘量簡單直觀。這樣,「正確同步的」應該被正式而直觀地定義(這兩種定義應該相互一致)。
- 程序員應該要有信心建立多線程程序。固然,咱們沒有魔法使得編寫併發程序變得很容易,可是咱們的目標是爲了減輕程序員理解內存模型全部細節的負擔。
- 跨大範圍的流行硬件體系結構上的高性能 JVM 實現應該是可能的。現代的處理器在它們的內存模型上有着很大的不一樣;JMM 應該可以適合於實際的儘量多的體系結構,而不會以犧牲性能爲代價。
- 提供一個同步習慣用法(idiom),以容許咱們發佈一個對象而且使得它不用同步就可見。這是一種叫作 初始化安全(initialization safety)的新的安全保證。
- 對現有代碼應該只有最小限度的影響。
值得注意的是,有漏洞的技術(如雙重檢查鎖定)在新的內存模型下仍然有漏洞,而且「修復」雙重檢查鎖定技術並非新內存模型所致力的一個目標。(可是, volatile
的新語義容許一般所提出的其中一個雙重檢查鎖定的可選方法正確地工做,儘管咱們不鼓勵這種技術。)
從 JSR 133 process 變得活躍的三年來,人們發現這些問題比他們認爲重要的任何問題都要微妙得多。這就是做爲一個開拓者的代價!最終正式的語義比原來所預料的要複雜得多,實際上它採用了一種與原先預想的徹底不一樣的形式,但非正式的語義是清晰直觀的,將在本文的第 2 部分概要地說明。
大多數程序員都知道, synchronized
關鍵字強制實施一個互斥鎖(互相排斥),這個互斥鎖防止每次有多個線程進入一個給定監控器所保護的同步語句塊。可是同步還有另外一個方面:正如 JMM 所指定,它強制實施某些內存可見性規則。它確保了當存在一個同步塊時緩存被更新,當輸入一個同步塊時緩存失效。所以,在一個由給定監控器保護的同步塊期間,一個線程所寫入的值對於其他全部的執行由同一監控器所保護的同步塊的線程來講是可見的。它也確保了編譯器不會把指令從一個同步塊的內部移到外部(雖然在某些狀況下它會把指令從同步塊的外部移到內部)。JMM 在缺少同步的狀況下不會作這種保證 —— 這就是隻要有多個線程訪問相同的變量時必須使用同步(或者它的同胞,易失性)的緣由。
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 或平臺這是不可能的,可是舊的內存模型規範容許這樣作。
另外一個主要領域是與 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 中)的詳細信息。