Java單例模式中雙重檢查鎖

在這轉發一篇有level的博客
轉至:http://www.javashuo.com/article/p-naisdsey-a.html
清單 1. 單例建立習語java

複製代碼程序員

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 預佔。 app

線程 2 調用 getInstance() 方法並在 //1 處決定 instance 爲 null。 編程語言

線程 2 進入 if 代碼塊並建立一個新的 Singleton 對象並在 //2 處將變量 instance 分配給這個新對象。 ide

線程 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。(這邊解釋一下:這篇長篇大論想闡述的觀點就是:若是在使用單例設計模式的時候要保證線程的安全不是使用雙重檢測鎖,而是使用單例設計模式中的餓漢子式單例,但其實清單2也能夠實現只是形成了性能消耗)

參考資料

您能夠參閱本文在 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 技術資料。

相關文章
相關標籤/搜索