Java單例模式中雙重檢查鎖的問題
單例建立模式是一個通用的程式設計習語。和多執行緒一起使用時,必需使用某種型別的同步。在努力建立更有效的程式碼時,Java 程式設計師們建立了雙重檢查鎖定習語,將其和單例建立模式一起使用,從而限制同步程式碼量。然而,由於一些不太常見的 Java 記憶體模型細節的原因,並不能保證這個雙重檢查鎖定習語有效。
它偶爾會失敗,而不是總失敗。此外,它失敗的原因並不明顯,還包含 Java 記憶體模型的一些隱祕細節。這些事實將導致程式碼失敗,原因是雙重檢查鎖定難於跟蹤。在本文餘下的部分裡,我們將詳細介紹雙重檢查鎖定習語,從而理解它在何處失效。
要理解雙重檢查鎖定習語是從哪裡起源的,就必須理解通用單例建立習語,如清單 1 中的闡釋:
清單 1. 單例建立習語
import java.util.*; class Singleton { private static Singleton instance; private Vector v; private boolean inUse; private Singleton() { v = new Vector(); v.addElement(new Object()); inUse = true; } public static Singleton getInstance() { if (instance == null) //1 instance = new Singleton(); //2 return instance; //3 } }
此類的設計確保只建立一個 Singleton
物件。建構函式被宣告為 private
,getInstance()
方法只建立一個物件。這個實現適合於單執行緒程式。然而,當引入多執行緒時,就必須通過同步來保護 getInstance()
方法。如果不保護 getInstance()
方法,則可能返回Singleton
物件的兩個不同的例項。假設兩個執行緒併發呼叫 getInstance()
方法並且按以下順序執行呼叫:
- 執行緒 1 呼叫
getInstance()
方法並決定instance
在 //1 處為null
。 - 執行緒 1 進入
if
程式碼塊,但在執行 //2 處的程式碼行時被執行緒 2 預佔。 - 執行緒 2 呼叫
getInstance()
方法並在 //1 處決定instance
為null
。 - 執行緒 2 進入
if
程式碼塊並建立一個新的Singleton
物件並在 //2 處將變數instance
分配給這個新物件。 - 執行緒 2 在 //3 處返回
Singleton
物件引用。 - 執行緒 2 被執行緒 1 預佔。
- 執行緒 1 在它停止的地方啟動,並執行 //2 程式碼行,這導致建立另一個
Singleton
物件。 - 執行緒 1 在 //3 處返回這個物件。
結果是 getInstance()
方法建立了兩個 Singleton
物件,而它本該只建立一個物件。通過同步 getInstance()
方法從而在同一時間只允許一個執行緒執行程式碼,這個問題得以改正,如清單 2 所示:
清單 2. 執行緒安全的 getInstance() 方法
public static synchronized Singleton getInstance() { if (instance == null) //1 instance = new Singleton(); //2 return instance; //3 }
清單 2 中的程式碼針對多執行緒訪問 getInstance()
方法執行得很好。然而,當分析這段程式碼時,您會意識到只有在第一次呼叫方法時才需要同步。由於只有第一次呼叫執行了 //2 處的程式碼,而只有此行程式碼需要同步,因此就無需對後續呼叫使用同步。所有其他呼叫用於決定 instance
是非 null
的,並將其返回。多執行緒能夠安全併發地執行除第一次呼叫外的所有呼叫。儘管如此,由於該方法是synchronized
的,需要為該方法的每一次呼叫付出同步的代價,即使只有第一次呼叫需要同步。
為使此方法更為有效,一個被稱為雙重檢查鎖定的習語就應運而生了。這個想法是為了避免對除第一次呼叫外的所有呼叫都實行同步的昂貴代價。同步的代價在不同的 JVM 間是不同的。在早期,代價相當高。隨著更高階的 JVM 的出現,同步的代價降低了,但出入synchronized
方法或塊仍然有效能損失。不考慮 JVM 技術的進步,程式設計師們絕不想不必要地浪費處理時間。
因為只有清單 2 中的 //2 行需要同步,我們可以只將其包裝到一個同步塊中,如清單 3 所示:
清單 3. getInstance() 方法
public static Singleton getInstance() { if (instance == null) { synchronized(Singleton.class) { instance = new Singleton(); } } return instance; }
清單 3 中的程式碼展示了用多執行緒加以說明的和清單 1 相同的問題。當 instance
為 null
時,兩個執行緒可以併發地進入 if
語句內部。然後,一個執行緒進入 synchronized
塊來初始化 instance
,而另一個執行緒則被阻斷。當第一個執行緒退出 synchronized
塊時,等待著的執行緒進入並建立另一個 Singleton
物件。注意:當第二個執行緒進入 synchronized
塊時,它並沒有檢查 instance
是否非 null
。
雙重檢查鎖定
為處理清單 3 中的問題,我們需要對 instance
進行第二次檢查。這就是“雙重檢查鎖定”名稱的由來。將雙重檢查鎖定習語應用到清單 3 的結果就是清單 4 。
清單 4. 雙重檢查鎖定示例
public static Singleton getInstance() { if (instance == null) { synchronized(Singleton.class) { //1 if (instance == null) //2 instance = new Singleton(); //3 } } return instance; }
雙重檢查鎖定背後的理論是:在 //2 處的第二次檢查使(如清單 3 中那樣)建立兩個不同的 Singleton
物件成為不可能。假設有下列事件序列:
- 執行緒 1 進入
getInstance()
方法。 - 由於
instance
為null
,執行緒 1 在 //1 處進入synchronized
塊。 - 執行緒 1 被執行緒 2 預佔。
- 執行緒 2 進入
getInstance()
方法。 - 由於
instance
仍舊為null
,執行緒 2 試圖獲取 //1 處的鎖。然而,由於執行緒 1 持有該鎖,執行緒 2 在 //1 處阻塞。 - 執行緒 2 被執行緒 1 預佔。
- 執行緒 1 執行,由於在 //2 處例項仍舊為
null
,執行緒 1 還建立一個Singleton
物件並將其引用賦值給instance
。 - 執行緒 1 退出
synchronized
塊並從getInstance()
方法返回例項。 - 執行緒 1 被執行緒 2 預佔。
- 執行緒 2 獲取 //1 處的鎖並檢查
instance
是否為null
。 - 由於
instance
是非null
的,並沒有建立第二個Singleton
物件,由執行緒 1 建立的物件被返回。
雙重檢查鎖定背後的理論是完美的。不幸地是,現實完全不同。雙重檢查鎖定的問題是:並不能保證它會在單處理器或多處理器計算機上順利執行。
雙重檢查鎖定失敗的問題並不歸咎於 JVM 中的實現 bug,而是歸咎於 Java 平臺記憶體模型。記憶體模型允許所謂的“無序寫入”,這也是這些習語失敗的一個主要原因。
無序寫入
為解釋該問題,需要重新考察上述清單 4 中的 //3 行。此行程式碼建立了一個 Singleton
物件並初始化變數 instance
來引用此物件。這行程式碼的問題是:在 Singleton
建構函式體執行之前,變數 instance
可能成為非 null
的。
什麼?這一說法可能讓您始料未及,但事實確實如此。在解釋這個現象如何發生前,請先暫時接受這一事實,我們先來考察一下雙重檢查鎖定是如何被破壞的。假設清單 4 中程式碼執行以下事件序列:
- 執行緒 1 進入
getInstance()
方法。 - 由於
instance
為null
,執行緒 1 在 //1 處進入synchronized
塊。 - 執行緒 1 前進到 //3 處,但在建構函式執行之前,使例項成為非
null
。 - 執行緒 1 被執行緒 2 預佔。
- 執行緒 2 檢查例項是否為
null
。因為例項不為 null,執行緒 2 將instance
引用返回給一個構造完整但部分初始化了的Singleton
物件。 - 執行緒 2 被執行緒 1 預佔。
- 執行緒 1 通過執行
Singleton
物件的建構函式並將引用返回給它,來完成對該物件的初始化。
此事件序列發生線上程 2 返回一個尚未執行建構函式的物件的時候。
為展示此事件的發生情況,假設為程式碼行 instance =new Singleton();
執行了下列虛擬碼: instance =new Singleton();
mem = allocate(); //Allocate memory for Singleton object. instance = mem; //Note that instance is now non-null, but //has not been initialized. ctorSingleton(instance); //Invoke constructor for Singleton passing //instance.
這段虛擬碼不僅是可能的,而且是一些 JIT 編譯器上真實發生的。執行的順序是顛倒的,但鑑於當前的記憶體模型,這也是允許發生的。JIT 編譯器的這一行為使雙重檢查鎖定的問題只不過是一次學術實踐而已。
為說明這一情況,假設有清單 5 中的程式碼。它包含一個剝離版的 getInstance()
方法。我已經刪除了“雙重檢查性”以簡化我們對生成的彙編程式碼(清單 6)的回顧。我們只關心 JIT 編譯器如何編譯 instance=new Singleton();
程式碼。此外,我提供了一個簡單的建構函式來明確說明彙編程式碼中該建構函式的執行情況。
清單 5. 用於演示無序寫入的單例類
class Singleton { private static Singleton instance; private boolean inUse; private int val; private Singleton() { inUse = true; val = 5; } public static Singleton getInstance() { if (instance == null) instance = new Singleton(); return instance; } }
清單 6 包含由 Sun JDK 1.2.1 JIT 編譯器為清單 5 中的 getInstance()
方法體生成的彙編程式碼。
清單 6. 由清單 5 中的程式碼生成的彙編程式碼
;asm code generated for getInstance 054D20B0 mov eax,[049388C8] ;load instance ref 054D20B5 test eax,eax ;test for null 054D20B7 jne 054D20D7 054D20B9 mov eax,14C0988h 054D20BE call 503EF8F0 ;allocate memory 054D20C3 mov [049388C8],eax ;store pointer in ;instance ref. instance ;non-null and ctor ;has not run 054D20C8 mov ecx,dword ptr [eax] 054D20CA mov dword ptr [ecx],1 ;inline ctor - inUse=true; 054D20D0 mov dword ptr [ecx+4],5 ;inline ctor - val=5; 054D20D7 mov ebx,dword ptr ds:[49388C8h] 054D20DD jmp 054D20B0
注: 為引用下列說明中的彙編程式碼行,我將引用指令地址的最後兩個值,因為它們都以 054D20
開頭。例如,B5
代表 test eax,eax
。
彙編程式碼是通過執行一個在無限迴圈中呼叫 getInstance()
方法的測試程式來生成的。程式執行時,請執行 Microsoft Visual C++ 偵錯程式並將其附到表示測試程式的 Java 程序中。然後,中斷執行並找到表示該無限迴圈的彙編程式碼。
B0
和 B5
處的前兩行彙編程式碼將 instance
引用從記憶體位置 049388C8
載入至 eax
中,並進行 null
檢查。這跟清單 5 中的getInstance()
方法的第一行程式碼相對應。第一次呼叫此方法時,instance
為 null
,程式碼執行到 B9
。BE
處的程式碼為 Singleton
物件從堆中分配記憶體,並將一個指向該塊記憶體的指標儲存到 eax
中。下一行程式碼,C3
,獲取 eax
中的指標並將其儲存回記憶體位置為049388C8
的例項引用。結果是,instance
現在為非 null
並引用一個有效的 Singleton
物件。然而,此物件的建構函式尚未執行,這恰是破壞雙重檢查鎖定的情況。然後,在 C8
行處,instance
指標被解除引用並存儲到 ecx
。CA
和 D0
行表示內聯的建構函式,該建構函式將值 true
和 5
儲存到 Singleton
物件。如果此程式碼在執行 C3
行後且在完成該建構函式前被另一個執行緒中斷,則雙重檢查鎖定就會失敗。
不是所有的 JIT 編譯器都生成如上程式碼。一些生成了程式碼,從而只在建構函式執行後使 instance
成為非 null
。針對 Java 技術的 IBM SDK 1.3 版和 Sun JDK 1.3 都生成這樣的程式碼。然而,這並不意味著應該在這些例項中使用雙重檢查鎖定。該習語失敗還有一些其他原因。此外,您並不總能知道程式碼會在哪些 JVM 上執行,而 JIT 編譯器總是會發生變化,從而生成破壞此習語的程式碼。
雙重檢查鎖定:獲取兩個
考慮到當前的雙重檢查鎖定不起作用,我加入了另一個版本的程式碼,如清單 7 所示,從而防止您剛才看到的無序寫入問題。
清單 7. 解決無序寫入問題的嘗試
public static Singleton getInstance() { if (instance == null) { synchronized(Singleton.class) { //1 Singleton inst = instance; //2 if (inst == null) { synchronized(Singleton.class) { //3 inst = new Singleton(); //4 } instance = inst; //5 } } } return instance; }
看著清單 7 中的程式碼,您應該意識到事情變得有點荒謬。請記住,建立雙重檢查鎖定是為了避免對簡單的三行 getInstance()
方法實現同步。清單 7 中的程式碼變得難於控制。另外,該程式碼沒有解決問題。仔細檢查可獲悉原因。
此程式碼試圖避免無序寫入問題。它試圖通過引入區域性變數 inst
和第二個 synchronized
塊來解決這一問題。該理論實現如下:
- 執行緒 1 進入
getInstance()
方法。 - 由於
instance
為null
,執行緒 1 在 //1 處進入第一個synchronized
塊。 - 區域性變數
inst
獲取instance
的值,該值在 //2 處為null
。 - 由於
inst
為null
,執行緒 1 在 //3 處進入第二個synchronized
塊。 - 執行緒 1 然後開始執行 //4 處的程式碼,同時使
inst
為非null
,但在Singleton
的建構函式執行前。(這就是我們剛才看到的無序寫入問題。) - 執行緒 1 被執行緒 2 預佔。
- 執行緒 2 進入
getInstance()
方法。 - 由於
instance
為null
,執行緒 2 試圖在 //1 處進入第一個synchronized
塊。由於執行緒 1 目前持有此鎖,執行緒 2 被阻斷。 - 執行緒 1 然後完成 //4 處的執行。
- 執行緒 1 然後將一個構造完整的
Singleton
物件在 //5 處賦值給變數instance
,並退出這兩個synchronized
塊。 - 執行緒 1 返回
instance
。 - 然後執行執行緒 2 並在 //2 處將
instance
賦值給inst
。 - 執行緒 2 發現
instance
為非null
,將其返回。
這裡的關鍵行是 //5。此行應該確保 instance
只為 null
或引用一個構造完整的 Singleton
物件。該問題發生在理論和實際彼此背道而馳的情況下。
由於當前記憶體模型的定義,清單 7 中的程式碼無效。Java 語言規範(Java Language Specification,JLS)要求不能將 synchronized
塊中的程式碼移出來。但是,並沒有說不能將 synchronized
塊外面的程式碼移入 synchronized
塊中。
JIT 編譯器會在這裡看到一個優化的機會。此優化會刪除 //4 和 //5 處的程式碼,組合並且生成清單 8 中所示的程式碼。
清單 8. 從清單 7 中優化來的程式碼。
public static Singleton getInstance() { if (instance == null) { synchronized(Singleton.class) { //1 Singleton inst = instance; //2 if (inst == null) { synchronized(Singleton.class) { //3 //inst = new Singleton(); //4 instance = new Singleton(); } //instance = inst; //5 } } } return instance; }
如果進行此項優化,您將同樣遇到我們之前討論過的無序寫入問題。
用 volatile 宣告每一個變數怎麼樣?
另一個想法是針對變數 inst
以及 instance
使用關鍵字 volatile
。根據 JLS(參見 參考資料),宣告成 volatile
的變數被認為是順序一致的,即,不是重新排序的。但是試圖使用 volatile
來修正雙重檢查鎖定的問題,會產生以下兩個問題:
- 這裡的問題不是有關順序一致性的,而是程式碼被移動了,不是重新排序。
- 即使考慮了順序一致性,大多數的 JVM 也沒有正確地實現
volatile
。
第二點值得展開討論。假設有清單 9 中的程式碼:
清單 9. 使用了 volatile 的順序一致性
class test { private volatile boolean stop = false; private volatile int num = 0; public void foo() { num = 100; //This can happen second stop = true; //This can happen first //... } public void bar() { if (stop) num += num; //num can == 0! } //... }
根據 JLS,由於 stop
和 num
被宣告為 volatile
,它們應該順序一致。這意味著如果 stop
曾經是 true
,num
一定曾被設定成 100
。儘管如此,因為許多 JVM 沒有實現 volatile
的順序一致性功能,您就不能依賴此行為。因此,如果執行緒 1 呼叫 foo
並且執行緒
2 併發地呼叫 bar
,則執行緒 1 可能在 num
被設定成為 100
之前將 stop
設定成 true
。這將導致執行緒見到 stop
是 true
,而 num
仍被設定成 0
。使用 volatile
和 64 位變數的原子數還有另外一些問題,但這已超出了本文的討論範圍。有關此主題的更多資訊,請參閱 參考資料。
解決方案
底線就是:無論以何種形式,都不應使用雙重檢查鎖定,因為您不能保證它在任何 JVM 實現上都能順利執行。JSR-133 是有關記憶體模型定址問題的,儘管如此,新的記憶體模型也不會支援雙重檢查鎖定。因此,您有兩種選擇:
- 接受如清單 2 中所示的
getInstance()
方法的同步。 - 放棄同步,而使用一個
static
欄位。
選擇項 2 如清單 10 中所示
清單 10. 使用 static 欄位的單例實現
class Singleton { private Vector v; private boolean inUse; private static Singleton instance = new Singleton(); private Singleton() { v = new Vector(); inUse = true; //... } public static Singleton getInstance() { return instance; } }
清單 10 的程式碼沒有使用同步,並且確保呼叫 static getInstance()
方法時才建立 Singleton
。如果您的目標是消除同步,則這將是一個很好的選擇。
String 不是不變的
鑑於無序寫入和引用在建構函式執行前變成非 null
的問題,您可能會考慮 String
類。假設有下列程式碼:
private String str; //... str = new String("hello");
String
類應該是不變的。儘管如此,鑑於我們之前討論的無序寫入問題,那會在這裡導致問題嗎?答案是肯定的。考慮兩個執行緒訪問String str
。一個執行緒能看見 str
引用一個 String
物件,在該物件中建構函式尚未執行。事實上,清單 11 包含展示這種情況發生的程式碼。注意,這個程式碼僅在我測試用的舊版 JVM 上會失敗。IBM 1.3 和 Sun 1.3 JVM 都會如期生成不變的 String
。
清單 11. 可變 String 的例子
class StringCreator extends Thread { MutableString ms; public StringCreator(MutableString muts) { ms = muts; } public void run() { while(true) ms.str = new String("hello"); //1 } } class StringReader extends Thread { MutableString ms; public StringReader(MutableString muts) { ms = muts; } public void run() { while(true) { if (!(ms.str.equals("hello"))) //2 { System.out.println("String is not immutable!"); break; } } } } class MutableString { public String str; //3 public static void main(String args[]) { MutableString ms = new MutableString(); //4 new StringCreator(ms).start(); //5 new StringReader(ms).start(); //6 } }
此程式碼在 //4 處建立一個 MutableString
類,它包含了一個 String
引用,此引用由 //3 處的兩個執行緒共享。在行 //5 和 //6 處,在兩個分開的執行緒上建立了兩個物件 StringCreator
和 StringReader
。傳入一個 MutableString
物件的引用。StringCreator
類進入到一個無限迴圈中並且使用值“hello”在
//1 處建立 String
物件。StringReader
也進入到一個無限迴圈中,並且在 //2 處檢查當前的 String
物件的值是不是 “hello”。如果不行,StringReader
執行緒打印出一條訊息並停止。如果 String
類是不變的,則從此程式應當看不到任何輸出。如果發生了無序寫入問題,則使 StringReader
看到 str
引用的惟一方法絕不是值為“hello”的 String
物件。
在舊版的 JVM 如 Sun JDK 1.2.1 上執行此程式碼會導致無序寫入問題。並因此導致一個非不變的 String
。
結束語
為避免單例中代價高昂的同步,程式設計師非常聰明地發明了雙重檢查鎖定習語。不幸的是,鑑於當前的記憶體模型的原因,該習語尚未得到廣泛使用,就明顯成為了一種不安全的程式設計結構。重定義脆弱的記憶體模型這一領域的工作正在進行中。儘管如此,即使是在新提議的記憶體模型中,雙重檢查鎖定也是無效的。對此問題最佳的解決方案是接受同步或者使用一個 static field
。
參考資料
- 您可以參閱本文在 developerWorks 全球網站上的 英文原文。
- 在 Peter Haggar 的書 Practical Java Programming Language Guide (Addison-Wesley,2000
年)中,他介紹了多個 Java 程式設計主題,包括了一整章關於多執行緒問題和程式設計技術的內容。
- 由 Tim Lindholm 和 Frank Yellin 合寫的 The Java Virtual Machine
Specification, Second Edition (Addison-Wesley,1999 年)是關於 Java 編譯器和執行時環境的權威性文件。
- 要了解更多關於
volatile
和 64 位變數的資訊,請參閱 Peter Haggar 的文章“Does Java Guarantee Thread Safety?”,發表在 2002 年 6 月那期的 Dr. Dobb's Journal 之上。 - JSR-133 處理對 Java 平臺的記憶體模型和執行緒規範的修訂。
- Java 軟體顧問 Brian Goetz 在“輕鬆使用執行緒:同步不是敵人”(developerWorks,2001 年 7 月)中介紹了何時使用同步。
- 在“輕鬆使用執行緒:不共享有時是最好的”(developerWorks,2001 年 10 月)中,Brian Goetz 介紹了
ThreadLocal
,並提供了一些發掘它的能力的小提示。 - 在“輕鬆使用執行緒:同步不是敵人”(developerWorks,2001 年 2 月)中,Alex Roetter 引入 Java Thread API,概述了與多執行緒相關的問題,並提供了常見問題的解決方案。
- 您可以參閱本文在 developerWorks 全球網站上的 英文原文。