單例創建模式是一個通用的編程習語。和多線程一起使用時,必需使用某種類型的同步。在努力創建更有效的代碼時,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() 方法從而在同一時間只允許一個線程執行代碼,這個問題得以改正,如清單 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 平臺內存模型。內存模型允許所謂的“無序寫入”,這也是這些習語失敗的一個主要原因:
初始化 Singleton 和將對象地址寫到instance字段的順序是不確定的。在某個線程new Singleton()時,在構造方法被調用之前,就爲該對象分配了內存空間並將對象的字段設置爲默認值。此時就可以將分配的內存地址賦值給instance字段了,然而該對象可能還沒有初始化;此時若另外一個線程來調用getInstance,取到的就是狀態不正確的對象。
鑑於以上原因,有人可能提出下列解決方案:
public class Singleton {
private static Singleton instance = null;
private Singleton(){}
public static Singleton getInstance() {
if(instance == null) {
Singleton temp;
synchronized(Singleton.class) {
temp = instance;
if(temp == null) {
synchronized(Singleton.class) {
temp = new Singleton();
}
instance = temp;
}
}
}
return instance;
}
}
該方案將Singleton對象的構造置於最裏面的同步塊,這種思想是在退出該同步塊時設置一個內存屏障,以阻止初始化Singleton 和將對象地址寫到instance字段的重新排序。
不幸的是,這種想法也是錯誤的,同步的規則不是這樣的。退出監視器(退出同步)的規則是:所有在退出監視器前面的動作都必須在釋放監視器之前完成。然而,並沒有規定說退出監視器之後的動作不能放到退出監視器之前完成。也就是說同步塊裏的代碼必須在退出同步時完成,而同步塊後面的代碼則可以被編譯器或運行時環境移到同步塊中執行。
編譯器可以合法地,也是合理地,將instance = temp移動到最裏層的同步塊內,這樣就出現了上個版本同樣的問題。
在JDK1.5及其後續版本中,擴充了volatile語義,系統將不允許對寫入一個volatile變量的操作與其之前的任何讀寫操作重新排序,也不允許將讀取一個volatile變量的操作與其之後的任何讀寫操作重新排序。
在jdk1.5及其後的版本中,可以將instance 設置成volatile以讓雙重檢查鎖定生效,如下:
public class Singleton {
private static volatile Singleton instance = null;
private Singleton(){}
public static Singleton getInstance() {
if(instance == null) {
synchronized(Singleton.class) {
if(instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
需要注意的是:在JDK1.4以及之前的版本中,該方式仍然有問題。