1. 程式人生 > >Java單例模式中雙重檢查鎖的問題

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 物件。建構函式被宣告為 privategetInstance() 方法只建立一個物件。這個實現適合於單執行緒程式。然而,當引入多執行緒時,就必須通過同步來保護 getInstance() 方法。如果不保護 getInstance() 方法,則可能返回Singleton 物件的兩個不同的例項。假設兩個執行緒併發呼叫 getInstance() 方法並且按以下順序執行呼叫:

  1. 執行緒 1 呼叫 getInstance() 方法並決定 instance 在 //1 處為 null。 

  2. 執行緒 1 進入 if 程式碼塊,但在執行 //2 處的程式碼行時被執行緒 2 預佔。 

  3. 執行緒 2 呼叫 getInstance() 方法並在 //1 處決定 instance 為 null。 

  4. 執行緒 2 進入 if 程式碼塊並建立一個新的 Singleton 物件並在 //2 處將變數 instance 分配給這個新物件。 

  5. 執行緒 2 在 //3 處返回 Singleton 物件引用。

  6. 執行緒 2 被執行緒 1 預佔。 

  7. 執行緒 1 在它停止的地方啟動,並執行 //2 程式碼行,這導致建立另一個 Singleton 物件。 

  8. 執行緒 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. 執行緒 1 進入 getInstance() 方法。 

  2. 由於 instance 為 null,執行緒 1 在 //1 處進入 synchronized 塊。 

  3. 執行緒 1 被執行緒 2 預佔。

  4. 執行緒 2 進入 getInstance() 方法。

  5. 由於 instance 仍舊為 null,執行緒 2 試圖獲取 //1 處的鎖。然而,由於執行緒 1 持有該鎖,執行緒 2 在 //1 處阻塞。

  6. 執行緒 2 被執行緒 1 預佔。

  7. 執行緒 1 執行,由於在 //2 處例項仍舊為 null,執行緒 1 還建立一個 Singleton 物件並將其引用賦值給 instance

  8. 執行緒 1 退出 synchronized 塊並從 getInstance() 方法返回例項。 

  9. 執行緒 1 被執行緒 2 預佔。

  10. 執行緒 2 獲取 //1 處的鎖並檢查 instance 是否為 null。 

  11. 由於 instance 是非 null 的,並沒有建立第二個 Singleton 物件,由執行緒 1 建立的物件被返回。

雙重檢查鎖定背後的理論是完美的。不幸地是,現實完全不同。雙重檢查鎖定的問題是:並不能保證它會在單處理器或多處理器計算機上順利執行。

雙重檢查鎖定失敗的問題並不歸咎於 JVM 中的實現 bug,而是歸咎於 Java 平臺記憶體模型。記憶體模型允許所謂的“無序寫入”,這也是這些習語失敗的一個主要原因。

無序寫入

為解釋該問題,需要重新考察上述清單 4 中的 //3 行。此行程式碼建立了一個 Singleton 物件並初始化變數 instance 來引用此物件。這行程式碼的問題是:在 Singleton 建構函式體執行之前,變數 instance 可能成為非 null 的。

什麼?這一說法可能讓您始料未及,但事實確實如此。在解釋這個現象如何發生前,請先暫時接受這一事實,我們先來考察一下雙重檢查鎖定是如何被破壞的。假設清單 4 中程式碼執行以下事件序列:

  1. 執行緒 1 進入 getInstance() 方法。

  2. 由於 instance 為 null,執行緒 1 在 //1 處進入 synchronized 塊。 

  3. 執行緒 1 前進到 //3 處,但在建構函式執行之前,使例項成為非 null。 

  4. 執行緒 1 被執行緒 2 預佔。

  5. 執行緒 2 檢查例項是否為 null。因為例項不為 null,執行緒 2 將 instance 引用返回給一個構造完整但部分初始化了的 Singleton物件。 

  6. 執行緒 2 被執行緒 1 預佔。

  7. 執行緒 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,程式碼執行到 B9BE 處的程式碼為 Singleton 物件從堆中分配記憶體,並將一個指向該塊記憶體的指標儲存到 eax 中。下一行程式碼,C3,獲取 eax 中的指標並將其儲存回記憶體位置為049388C8 的例項引用。結果是,instance 現在為非 null 並引用一個有效的 Singleton 物件。然而,此物件的建構函式尚未執行,這恰是破壞雙重檢查鎖定的情況。然後,在 C8 行處,instance 指標被解除引用並存儲到 ecxCA 和 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. 執行緒 1 進入 getInstance() 方法。

  2. 由於 instance 為 null,執行緒 1 在 //1 處進入第一個 synchronized 塊。 

  3. 區域性變數 inst 獲取 instance 的值,該值在 //2 處為 null。 

  4. 由於 inst 為 null,執行緒 1 在 //3 處進入第二個 synchronized 塊。 

  5. 執行緒 1 然後開始執行 //4 處的程式碼,同時使 inst 為非 null,但在 Singleton 的建構函式執行前。(這就是我們剛才看到的無序寫入問題。) 

  6. 執行緒 1 被執行緒 2 預佔。

  7. 執行緒 2 進入 getInstance() 方法。

  8. 由於 instance 為 null,執行緒 2 試圖在 //1 處進入第一個 synchronized 塊。由於執行緒 1 目前持有此鎖,執行緒 2 被阻斷。

  9. 執行緒 1 然後完成 //4 處的執行。

  10. 執行緒 1 然後將一個構造完整的 Singleton 物件在 //5 處賦值給變數 instance,並退出這兩個 synchronized 塊。 

  11. 執行緒 1 返回 instance

  12. 然後執行執行緒 2 並在 //2 處將 instance 賦值給 inst

  13. 執行緒 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 曾經是 truenum 一定曾被設定成 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,概述了與多執行緒相關的問題,並提供了常見問題的解決方案。