java多執行緒之延遲初始化

飛翔的兔兔發表於2017-06-30

有時候我們可能推遲一些高開銷的物件的初始化操作,並且只有在使用這些物件時才進行初始化,開發者可以採用延遲初始化來實現該需求。但是要正確實現執行緒安全的延遲初始化還是需要一些技巧的,否則很容易出現問題。下面是一個非執行緒安全的延遲初始化的例子:

public class UnsafeLazyInit {
	
	private static Instance instance;
	
	public static Instance getInstance() {
		if(instance == null) {           //1:A執行緒執行
			instance = new Instance();   //2:B執行緒執行
		}
		return instance;
	}

}

在UnsafeLazyInit類中,假設A執行緒執行程式碼1的同時,B執行緒執行程式碼2,此時執行緒A可能會看到instance物件還沒有完成初始化。
對於UnsafeLazyInit類,我們可以對getInstance方法做同步處理來實現執行緒安全的延遲初始化,程式碼如下:

public class UnsafeLazyInit {
	
	private static Instance instance;
	
	public synchronized static Instance getInstance() {
		if(instance == null) {            //1:A執行緒執行
			instance = new Instance();    //2:B執行緒執行
		}
		return instance;
	}

}

由於對getInstance方法做了同步處理,將導致效能開銷,如果getInstance方法被多個執行緒頻繁呼叫的話,將會導致程式執行效能的下降,而如果getInstance不會被多個執行緒頻繁呼叫,那麼這個方案將會提供令人滿意的效能。
對於synchronized方法可能帶來的程式執行效能的下降,我們可以使用一種“聰明”的技巧:雙重檢查鎖定(Double-Checked Locking)來降低同步的開銷。下面是使用雙重檢查鎖來實現延遲初始化的示例程式碼:

public class DoubleCheckedLocking {

	private static Instance instance;
	
	public static Instance getInstance() {
		if(instance == null) {                              //1:第一次檢查
			synchronized(DoubleCheckedLocking.class) {      //2:加鎖
				if(instance == null) {                      //3:第二次檢查
					instance = new Instance();              //4:問題的根源處在這裡
				}
			}
		}
		return instance;
	}

}

按照上面的程式碼,如果第一次檢查instance不為null,則不需要執行下面的加鎖和二次檢查與初始化操作,因此可以大大降低synchronized帶來的效能開銷,似乎是兩全其美的實現方式。
雙重檢查鎖定看起來似乎很完美,但這是一個錯誤的優化!線上程執行1:第一次檢查時,程式碼讀取到instance不為null,其實instance有肯能還沒有完成初始化,該問題的根源就在於:重排序。
在建立instance例項時,instance = new Instance()這行程式碼可以分解為如下3行虛擬碼:

memory = allocate();    //1:分配物件的記憶體空間
ctorInstance(memory);   //2: 初始化物件
instance = memory;      //3: 設定instance指向剛分配的記憶體

上述虛擬碼中的2和3之間,可能會發生重排序,重排序後的執行順序如下:

memory = allocate();   //1:分配物件的記憶體空間
instance = memory;     //3: 設定instance指向剛分配的記憶體  注意:此時物件還沒有被初始化!
ctorInstance(memory);  //2: 初始化物件

在上邊的java程式碼中,如果instance = new Instance()發生了重排序,另一個併發執行緒B就有可能在第一次檢查時instance不為null,執行緒B接下來將訪問instance所引用的物件,但此時該物件可能還沒有被A執行緒初始化,也就是會訪問一個未被初始化的物件。
知道了這個問題根源以後,可以有兩個辦法來實現執行緒安全的延遲初始化:
1.不允許2和3重排序。
2.允許2和3重排序,但不允許其它執行緒“看到”這個重排序。

1.不允許2和3重排序,只需對雙重檢查鎖定做小小的修改即可,我們把instance宣告為volatile型,就可以實現執行緒安全的延遲初始化,示例程式碼如下:

public class DoubleCheckedLocking {

	private volatile static Instance instance;
	
	public static Instance getInstance() {
		if(instance == null) {
			synchronized(DoubleCheckedLocking.class) {
				if(instance == null) {
					instance = new Instance();   //instance為volatile,現在沒有問題了。
				}
			}
		}
		return instance;
	}

}

當物件宣告為volatile後,虛擬碼中的2和3的重排序,在多執行緒環境中將被禁止。

2.允許2和3重排序,但不允許其它執行緒“看到”這個重排序。
JVM在類的初始化階段(即在Class被載入後,且被執行緒使用之前),會執行類的初始化。在執行類的初始化期間,JVM會去獲取一個鎖,這個鎖可以同步多個執行緒對一個類的初始化。基於這個特性,我們可以在允許2和3重排序的情況下,實現執行緒安全的延遲初始化。

public class InstanceFactory {
	
	private static class InstanceHolder {
		public static Instance instance = new Instance();
	}
	
	public static Instance getInstance() {
		return InstanceHolder.instance;    //這裡將導致InstanceHolder類被初始化
	}
	
}

這個方案的實質是:允許2和3重排序,但是不允許非構造執行緒(如執行緒B)“看到”這個重排序。
在InstanceFactory中,首次執行getInstance方法的執行緒(如執行緒A)將導致InstanceHolder類被初始化,但是如果多個執行緒同時呼叫getInstance方法,將會怎樣呢?
Java語言規範規定,對於每一個類或介面C,都有一個唯一的初始化鎖LC與之對應,從C到LC的對映,由JVM的具體實現去自由實現。JVM在初始化期間會獲取這個初始化鎖,並且每個執行緒至少獲取一次鎖來確保這個類被初始化了。
這個過程比較冗長,這裡不做過多描述,總之就是JVM通過初始化鎖同步了多個執行緒同時初始化一個物件的操作,保證類不會被多次初始化。

通過對比基於volatile的雙重檢查鎖定的方案和基於類初始化的方案,我們發現基於類初始化的方案更加簡潔。但基於volatile的雙重檢查鎖定方案有一個額外優勢:除了可以對靜態欄位實現延遲初始化外,還可以對例項欄位實現延遲初始化。

在設計模式中,有一個單例模式(Singleton),該模式比較常用,我們可以使用基於volatile的雙重檢查鎖定和基於類初始化的方案去建立單例物件,在實際工作中,我一般是使用基於類初始化的方案去實現單例模式。


相關文章