雙重檢查鎖定及單例模式

轉自 IBMdeveloperWorks,關於單例模式的文檔

雙重檢查鎖定及單例模式

全面理解這一失效的編程習語
Peter Haggar, 高級軟件工程師, IBM


簡介: 所有的編程語言都有一些共用的習語。瞭解和使用一些習語很有用,程序員們花費寶貴的時間來創建、學習和實現這些習語。問題是,稍後經過證明,一些習語並不完全如其所聲稱的那樣,或者僅僅是與描述的功能不符。在 Java 編程語言中,雙重檢查鎖定就是這樣的一個絕不應該使用的習語。在本文中,Peter Haggar 介紹了雙重檢查鎖定習語的淵源,開發它的原因和它失效的原因。

標記本文!


發佈日期: 2004 年 5 月 01 日
級別: 中級
其他語言版本: 英文
訪問情況 1104 次瀏覽
建議:



編輯注:本文在針對 Java 5.0 修訂前參考了 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 編程主題,包括了一整章關於多線程問題和編程技術的內容。

Bill Joy 等人編寫的 The Java Language Specification, Second Edition (Addison-Wesley,2000 年)是 Java 編程語言方面的權威性技術參考。

由 Tim Lindholm 和 Frank Yellin 合寫的 The Java Virtual Machine Specification, Second Edition (Addison-Wesley,1999 年)是關於 Java 編譯器和運行時環境的權威性文檔。

訪問 Bill Pugh 的 Java Memory Model Web 站點,獲取大量關於此主題的信息。

要了解更多關於 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,概述了與多線程相關的問題,並提供了常見問題的解決方案。

Allen Holub 在“如果我是國王:關於解決 Java編程語言線程問題的建議”(developerWorks,2000 年 10 月)中建議對 Java 語言作出重大的改變和添加。

在 developerWorks Java 技術專區 查找其他的 Java 技術資料。


關於作者

Peter Haggar 是 IBM 在北卡羅來納州的 Research Triangle Park 的一名高級軟件工程師,他還是 Practical Java Programming Language Guide (Addison-Wesley 出版)一書的作者。此外,他還發表了很多篇關於 Java 編程的文章。他有着廣泛的編程經驗,曾致力於開發工具、類庫和操作系統相關的工作。Peter 在 IBM 致力於研究新興 Internet 技術,目前主要從事高性能 Web 服務方面的工作。Peter 經常在很多行業會議上作爲技術發言人就 Java 技術發表言論。他已經爲 IBM 工作了 14 年多,並獲得了 Clarkson University 的計算機科學學士學位。您可以通過 [email protected] 與他聯繫。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章