雙重檢查鎖定的漏洞的分析 The "Double-Checked Locking is Broken" Declaration

本文根據http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html 來翻譯,純粹爲了自己學習做記錄,有生硬不通的地方還請海涵,也歡迎各位朋友指正。

 

在多線程環境下實現延遲加載時 Double-Checked Locking是通常使用的而且效率比較高的方法。不幸的是,如果沒有其他同步機制的話,他也許不能在java平臺可靠的運行。當使用其他語言實現時,比如c++,這取決於處理器的內存模型,編譯器引起的reordering 和編譯器與synchronization 庫之間的相互作用。因爲這些不是針對特定的語言,比如c++,幾乎可以說會在其中一種情況工作。顯示的內存屏障(memory barriers)可以在c++中使用,但是不能用在java中。

 

首先解釋期望的動作,思考下面的代碼:

如果這個代碼用於多線程環境,很多事都會出錯。最明顯的,兩個或者更多Helper對象會被創建(我們後續會講述其他問題)。簡單解決這個問題是對getHelper() 方法使用synchronize 關鍵字。

上面這段代碼每次訪問getHelper()方法都要同步進行,雙重檢查鎖定(double-checked locking )試圖避免在helpser對象創建後的同步化。

不幸的是,這個段代碼將不能按期望工作在優化編譯器或共享內存的多處理器。

 

他不能工作

 

不能工作有很多原因,下面描述的第一個原因很明顯的。理解了這些,你也許會被誘惑着去設計修正雙重檢查鎖定的方法。但是你的解決方案不能工作:有很多微妙的原因。理解這些原因之後想出更好的解決方案,但是這個也不能工作,因爲還有更多微妙的原因。

 

很多聰明的人花了很多時間關注這個,但是除了讓每個線程同步訪問helpser對象外沒有辦法解決這個問題。

 

不能工作的第一個原因

不能工作的最明顯原因是初始化Helper對象和helpser字段的賦值可能按順序進行也可能顛倒。因此一個線程調用getHelper()能得到非空的helper對象引用,但是看到是helper裏的字段是初始值,而不是構造函數裏設置的值。 

 

如果編譯器內聯到構造函數的調用且能保證構造函數不會拋出異常或執行同步。,那麼初始化對象的操作和對helper 裏字段的寫入可以自由重排。

 

即使編譯器沒有重排這些操作,在一個多線程處理器或內存系統可能會重排這些寫操作,當感知到另一個線程在另一個處理器上執行時。

 

Doug Lea 寫了一篇 more detailed description of compiler-based reorderings.

測試用例,說明他不能工作

 

 

Paul Jakubik 發現一個使用雙重檢查鎖定但是不能正確工作的例子. A slightly cleaned up version of that code is available here.

在使用Symantec JIT編譯器的系統上不能工作。特別是Symantec JIT 編譯

如下所示(注意Symantec JIT 使用基於句柄的對象分配系統)

 

就像你看到的,分配給singletons[i]的應用是在Singleton 的構造函數被執行前。這在java的內存模型中是完全合法的,而且在c和c++中也同樣。

一個不能工作的修正方案

 

 

根據上述的解釋,一些人可能會給出下面的代碼:

這段代碼將Helper對象的構造函數放入synchronized 塊內,直觀的想法是在同步塊釋放的點有內存屏障(memory barrier ),這樣能夠避免顛倒初始化Helper和對helper字段賦值的順序。

 

 

不幸的是,這個是完全錯誤的。這個同步規則將不會這麼工作。對於監視器退出規則(比如:釋放同步)是指在監視退出前的操作必須在監視器釋放前執行。然而沒有規則說那些在監視器退出之後的操作不能在監視器釋放前完成。編譯器將變量的分配helper = h放入同步塊是完全合法的,在這種情況下我們又回到了之前的地方。許多處理器提供執行這種單向內存屏障的指令。但是變更語義要求釋放鎖是一個完整的內存屏障將有性能損失。

更多的不能工作的修補程序

  

有些操作可以強制寫進程執行完全的雙向內存屏障。但這是粗劣,低效的,而且一旦java內存模型改變基本上就不能工作。不要使用這些技術。

但是即使在helper對像初始化時,一個完整的內存屏障被線程執行,他仍然無法正常工作。

 問題是在某些系統中,那些看到helper字段非空值的線程也需要執行內存屏障。

 

爲什麼?因爲處理器有他們自己本地的內存副本。某些處理器,如果沒有執行緩存一致性指令(例如,內存屏障),讀線程可能讀到過期的本地緩存副本,即使其他處理器使用內存屏障強制寫進程寫到主內存。這裏專門討論這種情況怎樣發生在Alpha 處理器上 a separate web page 。

 

是否值得這麼麻煩? 

對大多數應用來說,簡單的將getHelper() 方法同步化的成本並不高。只有在你知道這會造成應用程序重大開銷而且可以接受的情況下才考慮這種優化方案。

很多時候,更聰明的方法是使用內建的歸併排序而不是處理交換排序(見 JVM DB基準說明)會更有效。

在靜態單例的情況下工作

 

如果你建立的單例是靜態的(比如,只有一個Helper對象會被創建),相對於另一種對象屬性(比如,每一個Foo對象有一個Helper對象)有一種簡單優雅的解決方法。

只要在一個單獨的類中定義一個靜態字段。Java的語義保證字段不會被初始化直到字段被引用,而且所有使用該字段的線程將看到所有在初始化字段時的寫入結果。

 

 

在32位原始值上工作

雖然雙重檢查鎖定不能用於引用類型對象,但是他可以用於32位原始類型(比如,int或float)。注意他不能工作於long或者double,因爲非同步的64位原始類型的讀寫不保證是原子的。

 

 事實上假設方法computeHashCode()總是返回相同的結果而且沒有副作用(比如,冪等--一個操作不會修改狀態信息,並且每次操作的時候都返回同樣的結果。即:做多次和做一次的效果是一樣 的。)你甚至可以去掉所有的同步。

 

  

使用顯示的內存屏障

 

 如果你有明確的內存屏障指令他可能會讓雙重檢查鎖定模式工作。比如,如果你使用c++,你可以使用Doug Schmidt 等人書中的代碼:

 

 

 

使用ThreadLocal 存儲

Alexander Terekhov 提出了一個聰明的建議:使用thread local存儲來實現雙重檢查鎖定。每個線程保留一個線程本地標誌來確定該線程是否已完成所需的同步。

這種技術的性能相當程度上取決於你使用的JDK,在JDK1.2上很慢,但是在JDK1.3之後的版本就快很多。

 

 

新的java內存模型

JDK5, a new Java Memory Model and Thread specification.

 

是用Volatile關鍵字 

 

 JDK5及以後的版本擴展了volatile的語義,這樣系統將不允許對一個volatile的寫操作與之前的讀寫操作進行重排。而且對volatile的讀操作也不會與後續的讀寫進行重排。詳細請見: this entry in Jeremy Manson's blog

根據這個雙重檢查鎖定可以在helper字段聲明爲volatile時正常工作,但是在JDK4及之前不行。

 

雙重檢查鎖定不變對象 

 

如果Helper是不可變對象,比如Helper所有字段都是final,那麼不是用volatile關鍵字雙重檢查鎖定也能正常工作。這個思路是一個不可變對象(比如,String 或 Integer)在很大程度上就像int或float;讀寫不可變對象是原子的。

 

發佈了75 篇原創文章 · 獲贊 18 · 訪問量 38萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章