在 水木上看到一篇分析DCL(雙檢測鎖定-Double Checked Lock)安全性的文章。記得以前也討論過這個問題,但是為什么DCL也存在隱患,至今沒弄明白,這篇分析做了比較詳盡的解釋,概括起來原因有兩點:一是 因為編譯器或者處理器并不是嚴格按照程序順序進行指令調度。二是java中同步機制的存在。
原文來自:
發信人: wyxzellux (I still believe...), 信區: Java
標 題: Singleton模式與雙檢測鎖定(DCL)
發信站: 水木社區 (Mon Apr 7 23:42:14 2008), 站內
看OOP教材時,提到了一個雙檢測鎖定(Double-Checked Lock, DCL)的問題,但是書上沒有多介紹,只是說這是一個和底層內存機制有關的漏洞。查閱了下相關資料,對這個問題大致有了點了解。
從頭開始說吧。
在多線程的情況下Singleton模式會遇到不少問題,一個簡單的例子
1: class Singleton {
2: private static Singleton instance = null;
3:
4: public static Singleton instance() {
5: if (instance == null) {
6: instance = new Singleton();
7: }
8: return instance;
9: }
10: }
假
設這樣一個場景,有兩個線程調用Singleton.instance(),首先線程一判斷instance是否等于null,判斷完后一瞬間虛擬機把線
程二調度為運行線程,線程二再次判斷instance是否為null,然后創建一個Singleton實例,線程二的時間片用完后,線程一被喚醒,接下來
它執行的代碼依然是instance = new Singleton();
兩次調用返回了不同的對象,出現問題了。
最簡單的方法自然是在類被載入時就初始化這個對象:private static Singleton instance = new Singleton();
JLS(Java Language Specification)中規定了一個類只會被初始化一次,所以這樣做肯定是沒問題的。
但是如果要實現延遲初始化(Lazy initialization),比如這個實例初始化時的參數要在運行期才能確定,應該怎么做呢?
依然有最簡單的方法:使用synchronized關鍵字修飾初始化方法:
public synchronized static Singleton instance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
這里有一個性能問題:多個線程同時訪問這個方法時,會因為同步而導致每次只有一個線程運行,影響程序性能。而事實上初始化完畢后只需要簡單的返回instance的引用就行了。
DCL是一個“看似”有效的解決方法,先把對應代碼放上來吧:
1 : class Singleton {
2 : private static Singleton instance = null ;
3 :
4 : public static Singleton instance() {
5 : if (instance == null ) {
6 : synchronized (this) {
7 : if (instance == null)
8 : instance = new Singleton();
9 : }
10 : }
11 : return instance;
12 : }
13 : }
用JavaWorld上對應文章的標題來評論這種做法就是smart, but broken。來看原因:
Java
編譯器為了提高程序性能會進行指令調度,CPU在執行指令時同樣出于性能會亂序執行(至少現在用的大多數通用處理器都是out-of-order的),另
外cache的存在也會改變數據回寫內存時的順序[2]。JMM(Java Memory Model,
見[1])指出所有的這些優化都是允許的,只要運行結果和嚴格按順序執行所得的結果一樣即可。
Java假設每個線程都跑在自己的處理器
上,享有自己的內存,和共享的主存交互。注意即使在單核上這種模型也是有意義的,考慮到cache和寄存器會保存部分臨時變量。理論上每個線程修改自己的
內存后,必須立即更新對應的主存內容。但是Java設計師們認為這種約束會影響程序性能,他們試著創造了一套讓程序跑得更快、但又保證線程之間的交互與預
期一致的內存模型。
synchronized關鍵字便是其中一把利器。事實上,synchronized塊的實現和Linux中的信號量
(semaphore)還是有區別的,前者過程中鎖的獲得和釋放都會都會引發一次Memory
Barrier來強制線程本地內存和主存之間的同步。通過這個機制,Java中的同步機制保證了synchronized塊中指令的原子性
(atomic)。
好了,回過頭來看DCL問題。看起來訪問一個未同步的instance字段不會產生什么問題,我們再次來假設一個場景:
線程一進入同步塊,執行instance = new Singleton(); 線程二剛開始執行getResource();
按照順序的話,接下來應該執行的步驟是 1) 分配新的Singleton對象的內存 2) 調用Singleton的構造器,初始化成員字段 3) instance被賦為指向新的對象的引用。
前
面說過,編譯器或處理器都為了提高性能都有可能進行指令的亂序執行,線程一的真正執行步驟可能是1) 分配內存 2) instance指向新對象
3)
初始化新實例。如果線程二在2完成后3執行前被喚醒,它看到了一個不為null的instance,跳出方法體走了,帶著一個還沒初始化的
Singleton對象。
錯誤發生的一種情形就是這樣,關于更詳細的編譯器指令調度導致的問題,可以參看這個網頁 [4]。
[3] 中提供了一個編譯器指令調度的證據
instance = new Singleton(); 這條命令在Symantec JIT中被編譯成
0206106A mov eax,0F97E78h
0206106F call 01F6B210 ; 分配空間
02061074 mov dword ptr [ebp],eax ; EBP中保存了instance的地址
02061077 mov ecx,dword ptr [eax] ; 解引用,獲得新的指針地址
02061079 mov dword ptr [ecx],100h ; 接下來四行是inline后的構造器
0206107F mov dword ptr [ecx+4],200h
02061086 mov dword ptr [ecx+8],400h
0206108D mov dword ptr [ecx+0Ch],0F84030h
可以看到,賦值完成在初始化之前,而這是JLS允許的。
另
一種情形是,假設線程一安穩地完成Singleton對象的初始化,退出了同步塊,并同步了和本地內存和主存。線程二來了,看到一個非空的引用,拿走。注
意線程二沒有執行一個Read Barrier,因為它根本就沒進后面的同步塊。所以很有可能此時它看到的數據是陳舊的。
還有很多人根據已知的幾種提出了一個又一個fix的方法,但最終還是出現了更多的問題。可以參閱[3]中的介紹。
[5]中還說明了即使把instance字段聲明為volatile還是無法避免錯誤的原因。
由此可見,安全的Singleton的構造一般只有兩種方法,一是在類載入時就創建該實例,二是使用性能較差的synchronized方法。
(by ZelluX http://www.tkk7.com/zellux )
參考資料:
[1] Java Language Specification, Second Edition, 第17章介紹了Java中線程和內存交互關系的具體細節。
[2] out-of-order與cache的介紹可以參閱Computer System, A Programmer's Perspective的第四、五章。
[3] The "Double-Checked Locking is Broken" Declaration, http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
[4] Synchronization and the Java Memory Model, http://gee.cs.oswego.edu/dl/cpj/jmm.html
[5] Double-checked locking: Clever, but broken, http://www.javaworld.com/javaworld/jw-02-2001/jw-0209-double.html?page=1
[6] Holub on Patterns, Learning Design Patterns by Looking at Code