從單例模式窺探類初始化過程中的同步處理機制

        在Java多線程中,有時候需要採用延遲初始化來降低初始化類和創建對象的開銷。雙重檢查鎖定是常見的延遲初始化技術。但它是一個錯誤的用法。本文將分析雙重檢查鎖定的錯誤根源,以及兩種線程安全的延遲初始化方案。

一、 雙重檢查鎖定的由來

下面代碼是單例模式中比較常見的寫法(錯誤的、不安全):

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

}

        在 UnsafeLazyInitialization 類中,假設 A線程 執行代碼 1 的同時,B線程 執行代碼 2 。此時,線程 A 可能會看到 instance 引用的對象沒有完成初始化(具體原因後面會詳細講解)。
        對於 UnsafeLazyInitialization 類,我們可以對 getInstance() 方法做同步處理來實現線程安全的延遲初始化。示例代碼如下(正確的、低效率):

public class UnsafeLazyInitialization {
	
	private static UnsafeLazyInitialization instance;
	
	public synchronized static UnsafeLazyInitialization getInstance(){
		if (instance==null) {	 
			instance=new UnsafeLazyInitialization(); 
		}
		return instance;
	}
	
}

        由於對 getInstance() 方法做了同步處理,synchronized 將導致性能開銷。如果 getInstance() 方法被多個線程頻繁的調用,將會導致程序執行性能的下降。反之,如果 getInstance() 方法不會被多個線程頻繁調用,那麼這個延遲初始化方案將能提供令人滿意的性能。
        對於上述方法,有一個優化技巧(只有在未初始化時才做同步操作):雙重檢查鎖定 (Double-Checked Locking) 。通過雙重檢查鎖定來降低同步的開銷。代碼如下:

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

}

        如上面代碼所示,如果第一次檢查 instance 不爲 null ,那麼就不需要執行下面的加鎖和初始化操作。因此,可以大幅降低synchronized 帶來的性能開銷。上面代碼表面上看起來兩全其美。

  • 多個線程試圖在同一事件創建對象時,會通過加鎖來報正直有一個線程創建對象。
  • 在對象創建好之後,執行 getInstance() 方法不需要獲取鎖,直接返回已創建好的對象。

        雙重檢查鎖定看起來很完美,但這是一個錯誤的優化!在代碼執行到第 4 行,代碼讀取到 instance 不爲 null 時,instance 引用的對象有可能還沒有完成初始化,這個問題引入下面重點知識 對象初始化流程。

二、 問題的根源

        上面的雙重檢查鎖定示例代碼的第 7 行( instance = new DoubleCheckedLocking() ) 創建了一個對象。這一行代碼可以分解爲如下的 3 行僞代碼:

 memory = allocate(); // 1: 分配對象的內存空間
 ctorInstance(memory); // 2: 初始化對象
 instance = memory; // 3: 設置instance 指向 2 分配的內存

上面 3 行僞代碼中的 2 和 3 之間,可能會被重排序。2 和 3 之間重排序會後的執行如下:

 memory = allocate(); // 1: 分配對象的內存空間 
 instance = memory;   // 3: 設置instance 指向 2 分配的內存
                         // 注意,此時對象還沒有被初始化 
 ctorInstance(memory); // 2: 初始化對象

        跟據Java語言規範,所有線程在執行Java程序時必須遵守 intra-thread semantics 。intra-thread semantics 保證重排序不會改變單線程內的程序執行結果。換句話說,intra-thread semantics 允許那些在單線程內,不會改變單線程程序執行結果的重排序。上面 3 行僞代碼的 2 和 3 之間雖然被重排序了,但這個重排序並不會違反 intra-thread semantics 。這個重排序在沒有改變單線程程序執行結果的前提下,可以提高程序的執行性能。
爲了更好地理解 intra-thread semantics ,可以看下圖(假設一個 線程A 在構造對象後,立即訪問這個對象):
線程執行時序圖
如上圖,只要保證 2 排在 4 的前面,即使 2 和 3 之間重排序了,也不會違反 intranet-thread semantics
下面我們看看多線程併發執行的情況,如下圖:
多線程執行時序圖
        由於單線程內要遵守 intra-thread semantice , 從而能保證 A線程 的執行結果不會被改變。但是,當 線程A 和 線程B 按上圖的時序執行時,B線程 將看到一個還沒有被初始化的對象。
        再回到 DoubleCheckedLocking 示例代碼的第 7 行( instance=new DoubleCheckedLocking() ) 如果發生重排序,另一個併發執行的 線程B 就有可能在第 4 行( if (instance==null) ) 判斷 instance 不爲null線程B 接下來將訪問instance 所引用的對象,但此時這個對象可能還沒有被 線程A 初始化!!!
在知曉了問題發生的根源之後,我們可以想出兩個辦法來實現線程安全的延遲初始化:

  1. 不允許 2 和 3重排序。
  2. 允許 2 和 3 重排序,但不允許其他線程“看到”這個重排序。
    下面將對這兩種方案進行講解實現。

三、 基於volatile的解決方案

對於前面的基於雙重檢查鎖定來實現延遲初始化的方案( DoubleCheckedLocking ),只需要做一點小的修改(把 instance 聲明爲volatile 型),就可以實現線程安全的延遲初始化:

public class DoubleCheckedLocking {
	private volatile static DoubleCheckedLocking instance; 
	
	public static DoubleCheckedLocking getInstance(){
		if (instance==null) {
			synchronized (DoubleCheckedLocking.class) {
				if (instance==null) {
					 instance=new DoubleCheckedLocking();
				}
			}
		}
		return instance;
	}

}

當聲明對象的引用爲 volatile 後,上代碼中 2 和 3 之間的重排序在多線程環境中將會禁止

四、 基於類初始化的解決方案

JVM 在類的初始化階段(即在 Class 被加載後,且被線程使用之前),會執行類的初始化。在執行類的初始化期間,JVM會去獲取一個鎖。這個鎖可以同步多個線程對同一個類的初始化
基於這個特性,可以實現另一種線程安全的延遲話初始化方案(Initialization On Demand Holder idiom)。

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

假設兩個線程併發執行 getInstance() 方法,下面是執行的示意圖:
兩個線程併發執行的示意圖
這個方案的實質是:允許 2 和 3 重排序,但不允許非構造線程(這裏指 線程B)“看到”這個重排序
初始化一個類,包括執行這個類的靜態初始化和初始化在這個類中聲明的靜態字段。在首次發生下列任意一種情況時,一個類或接口類型T將被立即初始化:

  1. T 是一個類,而且一個T類型的實例被創建。
  2. T 是一個類,且T中聲明的一個靜態方法被調用。
  3. T 中聲明的一個靜態字段被賦值。
  4. T 中聲明的一個靜態字段被使用,而且這個字段不是一個常量字段。
  5. T 是一個頂級類,而且一個斷言語句嵌套在T內部被執行。

InstanceFactory 示例中,首次執行 getInstaance() 方法的現成將導致InstanceHolder 類被初始化(符合情況4)。

五、類的初始化流程

由於Java語言是多線程的,多個線程可能在同一時間嘗試去初始化同一個類或接口。因此,在Java中初始化一個類或者接口時,需要做細緻的同步處理。
Java語言規範規定,對於每一個類或者接口C,都有一個唯一的初始化鎖LC與之對應。從C到LC的映射,由JVM的具體實現去自由實現。JVM在類初始化期間會獲取這個初始化鎖,並且每個線程至少獲取一次鎖來確保這個類已經被初始化過了
對於類或接口的初始化,Java語言規範制定了精巧而複雜的類初始化處理過程。Java初始化一個類或接口的處理過程如下(這裏對類初始化處理過程的說明,省略了與本無關的部分。同時爲了更好的說明類初始化過程中的同步處理機制,我們人爲的把類初始化的處理過程分爲了5個階段)。

第 1 階段

通過在 Class對象上同步(即獲取 Class對象的初始化鎖),來控制類或接口的初始化。這個獲取鎖的線程會一直等待,知道當前線程能夠獲取到這個初始化鎖。
假設 Class對象當前還沒有被初始化(初始化狀態state,此時被標記爲 state = noInitialization ),且有兩個線程A 和 B 試圖同時初始化這個 Class對象。如圖:
類初始化——第一階段
第 1 階段的執行時序表:

時間 線程 A 線程B
t1 A1: 嘗試獲取Class對象的初始化鎖。這裏假設線程A獲取到了初始化鎖 B1: 嘗試獲取Class對象的初始化鎖,由於線程A獲取到了鎖,線程B將一直等待獲取初始化鎖
t2 A2: 線程A看到對象還未被初始化(因爲讀取到state = noInitialization),線程A設置state = initializing
t3 A3: 線程A釋放初始化鎖
第 2 階段

線程A 執行類的初始化,同時線程B 在初始化鎖對應的condition 上等待。
類初始化——第2階段
第 2 階段的執行時序表:

時間 線程 A 線程B
t1 A1: 執行類的靜態初始化和初始化類中聲明的靜態字段 B1: 獲取到初始化鎖
t2 B2: 讀取到 state = initializing
t3 B3: 釋放初始化鎖
t4 B4: 在初始化鎖的condition等待
第 3 階段

線程A 設置 state = initialized ,然後喚醒在 condition 中等待的所有線程。
類初始化——第3階段
第 3 階段的執行時序表:

時間 線程 A
t1 A1: 獲取初始化鎖
t2 A2: 設置 state = initialized
t3 A3: 喚醒在condition中等待的所有線程
t4 A4: 釋放初始化鎖
t5 A5: 線程A的初始化處理過程完成
第 4 階段

線程B 結束類的初始化處理
類初始化——第4階段
第 4 階段的執行時序表:

時間 線程 B
t1 B1: 獲取初始化鎖
t2 B2: 讀取到 state = initialized
t3 B3: 釋放初始化鎖
t4 B4: 線程B的類初始化處理過程完成

多線程執行的時序圖
線程A 在第 2 階段的A1執行類的初始化,並在第 3 階段的A4釋放初始化鎖;線程B在第 4 階段的B1獲取同一個初始化鎖,並在第 4 階段的B4之後纔開始訪問這個類。
根據Java內存模型規範的鎖規則,這裏將存在如下的happens-before關係:線程A 執行類的初始化時的寫入操作(執行類的靜態初始化和初始化類中的聲明的靜態字段),線程B一定能看到。

第 5 階段

線程C執行類的初始化的處理
類初始化——第5階段
第 5 階段的執行時序表:

時間 線程 C
t1 C1: 獲取初始化鎖
t2 C2: 讀取到 state = initialized
t3 C3: 釋放初始化鎖
t4 C4: 線程B的類初始化處理過程完成

在第 3 階段之後,類已經完成了初始化。因此線程C 在第 5 階段的類初始化處理過程相對簡單一些(前面的線程A 和 B 的初始化處理過程都經歷了兩次獲取-釋放鎖,而線程C 的類初始化處理只需要經歷一次鎖獲取-釋放)。

注意: 這裏的 conditionstate 標記是本文虛構的,Java語言規範並沒有硬性規定一定要使用 conditionstate 標記。JVM的具體實現只要實現類似的功能即可。

六、總結

通過對比基於 volatile 的雙重檢查鎖定的方案和基於類初始化的方案,我們會發現基於類初始化的方案的實現代碼更簡潔。但基於 volatile 的雙重檢查鎖定的方案有一個優勢:除了可以對靜態字段實現延遲初始化外,還可以對實例字段實現延遲初始化。
字段延遲初始化降低了初始化類或創建實例的開銷,但增加了訪問被延遲化字段的開銷。在大多數的時候,正常的初始化要優於延遲初始化如果確實需要對實例字段使用線程安全的延遲初始化,請使用上面介紹的基於volatile的延遲初始化方案如果確實需要對靜態字段使用線程安全的延遲初始化,請使用上面介紹的基於類初始化方案

本文選自《Java併發編程的藝術》

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