java多線程之延遲初始化

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

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的雙重檢查鎖定和基於類初始化的方案去創建單例對象,在實際工作中,我一般是使用基於類初始化的方案去實現單例模式。

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