1. 程式人生 > >雙重檢查鎖定實現單例模式與延遲載入化

雙重檢查鎖定實現單例模式與延遲載入化

首先我們從單例模式開始:

一、單例模式定義:

單例模式確保某個類只有一個例項,而且自行例項化並向整個系統提供這個例項。在計算機系統中,執行緒池快取日誌物件、對話方塊、印表機、顯示卡的驅動程式物件常被設計成單例。這些應用都或多或少具有資源管理器的功能。每臺計算機可以有若干個印表機,但只能有一個Printer Spooler,以避免兩個列印作業同時輸出到印表機中。每臺計算機可以有若干通訊埠,系統應當集中管理這些通訊埠,以避免一個通訊埠同時被兩個請求同時呼叫。總之,選擇單例模式就是為了避免不一致狀態,避免政出多頭。

二、單例模式特點:
  1、單例類只能有一個例項。
  2、單例類必須自己建立自己的唯一例項。因此需要構造方法私有化

,確保其他類不能例項化該類
  3、單例類必須給所有其他物件提供這一例項。因此需要對外提供一個public方法獲得該類的例項

單例模式保證了全域性物件的唯一性,比如系統啟動讀取配置檔案就需要單例保證配置的一致性。

三、單例模式的實現

1.餓漢式單例(立即載入方式)

/*
 * 餓漢式 類建立的時候就建立例項例項且僅建立一次,可以避免程序的同步。 但是無法實現按需載入
 */
class SingletonA {
	private static SingletonA instance = new SingletonA();

	private SingletonA() {// 建構函式私有化。外部無法建立本類物件
	}

	public static SingletonA getInstance() {
		return instance;
	}

}

餓漢式單例在類載入初始化時就建立好一個靜態的物件供外部使用,除非系統重啟,這個物件不會改變,所以本身就是執行緒安全的。Singleton通過將構造方法限定為private避免了類在外部被例項化,在同一個虛擬機器範圍內,Singleton的唯一例項只能通過getInstance()方法訪問。(事實上,通過Java反射機制是能夠例項化構造方法為private的類的,那基本上會使所有的Java單例實現失效。此問題在此處不做討論,姑且閉著眼就認為反射機制不存在。)

2.懶漢式單例(延遲載入方式)

/*
 * 懶漢式 可以按需載入,但是可能出現程序同步問題
 */
class SingletonB {
	private static SingletonB instance;

	private SingletonB() {

	}

	public static SingletonB getInstance() {
		if (instance == null) {
			instance = new SingletonB();
		}
		return instance;
	}

}

該示例雖然用延遲載入方式實現了懶漢式單例,但在多執行緒環境下會產生多個single物件。

因為餓漢式導致的問題,就產生了本篇文章的主題-雙重檢查鎖定。一看到上述問題的產生,就會有人提出加上鎖不就可以解決問題了嗎,於是


class SingletonC {
	private volatile static SingletonC instance;

	private SingletonC() {

	}

	public synchronized static SingletonC getInstance() {
	
		
				if (instance == null) {
					instance = new SingletonC();
				}
				
		return instance;
	}

}

但是大家都知道synchronized將導致效能開銷(加鎖,解鎖,切換等)。於是又提出了第三個方式

3 雙重檢查鎖定單例(非安全)

// 雙重檢驗鎖
class SingletonC {
	private static SingletonC instance;

	private SingletonC() {

	}

	public static SingletonC getInstance() {
		if (instance == null) {// 該句存在主要是因為被synchronized修飾的方法比一般方法要慢。多次呼叫記憶體消耗較大
			synchronized (SingletonB.class) {// 解決執行緒的同步問題
				if (instance == null) {
					instance = new SingletonC();
				}
			}

		}
		return instance;
	}

}

這樣的解決辦法看起來是兩全其美的。首先當多個執行緒試圖在同一時間建立物件的時候,會通過加鎖來保證只有一個執行緒建立物件,其次在物件建立好後,執行getInstance()方法將不需要獲取鎖,直接返回已經建立好的物件,從而減少系統的效能開銷。但這真的是安全的嗎???

這就需要從jmm說起了。問題的根源出現在instance=new SingletonC()上。該程式碼功能是是建立例項物件,可以分解為如下虛擬碼步驟: 

memory = allocate() ;    //分配物件的記憶體空間
ctorInstance(memory);   //②初始化物件
instance=memory;        //③設定instance指向剛分配的記憶體地址

其中②和③之間,在某些編譯器編譯時,可能出現重排序(主要是為了程式碼優化),此時的程式碼如下:  

memory = allocate() ;    //分配物件的記憶體空間
instance=memory;        //③設定instance指向剛分配的記憶體地址
ctorInstance(memory);   //②初始化物件

  單執行緒下執行時序圖如下:

 

 

   多執行緒下執行時序圖:

   

   由於單執行緒中遵守intra-thread semantics,從而能保證即使②和③交換順序後其最終結果不變。但是當在多執行緒情況下,執行緒B將看到一個還沒有被初始化的物件,此時將會出現問題。

   解決方案:

    1、不允許②和③進行重排序

    2、允許②和③進行重排序,但排序之後,不允許其他執行緒看到。

 4:雙重檢查鎖定(基於volatile的安全解決方案)

    對前面的雙重鎖實現的延遲初始化方案進行如下修改:   

class SingletonC {
	private volatile static SingletonC instance;

	private SingletonC() {

	}

	public static SingletonC getInstance() {
		if (instance == null) {// 該句存在主要是因為被synchronized修飾的方法比一般方法要慢。多次呼叫記憶體消耗較大
			synchronized (SingletonB.class) {// 解決執行緒的同步問題
				if (instance == null) {
					instance = new SingletonC();
				}
			}

		}
		return instance;
	}

}

 使用volatile修飾instance之後,之前的②和③之間的重排序將在多執行緒環境下被禁止,從而保證了執行緒安全執行

5:靜態內部類(基於類初始化的安全解決方案)

JVM在類的初始化階段(即在Class被載入後,且被執行緒使用之前),會執行類的初始化。在執行類的初始化期間,JVM會去獲取一個鎖。這個鎖可以同步多個執行緒對同一個類的初始化

class SingletonD {
	private static class singleHolders {
		public static SingletonD instance = new SingletonD();
	}

	private SingletonD() {

	}

	public static SingletonD getInstance() {
		return singleHolders.instance;
	}

}

假設兩個執行緒併發執行getInstance(),下面是執行的示意圖:

這個方案的實質是:允許“問題的根源”的三行虛擬碼中的2和3重排序,但不允許非構造執行緒(這裡指執行緒B)“看到”這個重排序。

 

這裡又不得不說一下類的初始化過程(注意和例項化的區別)。

初始化一個類,包括執行這個類的靜態初始化和初始化在這個類中宣告的靜態欄位。根據java語言規範,在首次發生下列任意一種情況時,一個類或介面型別T將被立即初始化:

  • T是一個類,而且一個T型別的例項被建立;
  • T是一個類,且T中宣告的一個靜態方法被呼叫;
  • T中宣告的一個靜態欄位被賦值;
  • T中宣告的一個靜態欄位被使用,而且這個欄位不是一個常量欄位;
  • T是一個頂級類(top level class,見java語言規範的§7.6),而且一個斷言語句巢狀在T內部被執行。

在InstanceFactory示例程式碼中,首次執行getInstance()的執行緒將導致InstanceHolder類被初始化(符合情況4)。

由於java語言是多執行緒的,多個執行緒可能在同一時間嘗試去初始化同一個類或介面(比如這裡多個執行緒可能在同一時刻呼叫getInstance()來初始化InstanceHolder類)。因此在java中初始化一個類或者介面時,需要做細緻的同步處理。

Java語言規範規定,對於每一個類或介面C,都有一個唯一的初始化鎖LC與之對應。從C到LC的對映,由JVM的具體實現去自由實現。JVM在類初始化期間會獲取這個初始化鎖,並且每個執行緒至少獲取一次鎖來確保這個類已經被初始化過了(事實上,java語言規範允許JVM的具體實現在這裡做一些優化,見後文的說明)。

對於類或介面的初始化,java語言規範制定了精巧而複雜的類初始化處理過程。java初始化一個類或介面的處理過程如下(這裡對類初始化處理過程的說明,省略了與本文無關的部分;同時為了更好的說明類初始化過程中的同步處理機制,筆者人為的把類初始化的處理過程分為了五個階段):

第一階段:通過在Class物件上同步(即獲取Class物件的初始化鎖),來控制類或介面的初始化。這個獲取鎖的執行緒會一直等待,直到當前執行緒能夠獲取到這個初始化鎖。

假設Class物件當前還沒有被初始化(初始化狀態state此時被標記為state = noInitialization),且有兩個執行緒A和B試圖同時初始化這個Class物件。下面是對應的示意圖:

下面是這個示意圖的說明:

時間 執行緒A 執行緒B
t1 A1:嘗試獲取Class物件的初始化鎖。這裡假設執行緒A獲取到了初始化鎖 B1:嘗試獲取Class物件的初始化鎖,由於執行緒A獲取到了鎖,執行緒B將一直等待獲取初始化鎖
t2 A2:執行緒A看到執行緒還未被初始化(因為讀取到state == noInitialization),執行緒設定state = initializing  
t3 A3:執行緒A釋放初始化鎖  

第二階段:執行緒A執行類的初始化,同時執行緒B在初始化鎖對應的condition上等待:

下面是這個示意圖的說明:

時間 執行緒A 執行緒B
t1 A1:執行類的靜態初始化和初始化類中宣告的靜態欄位 B1:獲取到初始化鎖
t2   B2:讀取到state == initializing
t3   B3:釋放初始化鎖
t4   B4:在初始化鎖的condition中等待

第三階段:執行緒A設定state = initialized,然後喚醒在condition中等待的所有執行緒:

下面是這個示意圖的說明:

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

第四階段:執行緒B結束類的初始化處理:

下面是這個示意圖的說明:

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

執行緒A在第二階段的A1執行類的初始化,並在第三階段的A4釋放初始化鎖;執行緒B在第四階段的B1獲取同一個初始化鎖,並在第四階段的B4之後才開始訪問這個類。根據java記憶體模型規範的鎖規則,這裡將存在如下的happens-before關係:

這個happens-before關係將保證:執行緒A執行類的初始化時的寫入操作(執行類的靜態初始化和初始化類中宣告的靜態欄位),執行緒B一定能看到。

第五階段:執行緒C執行類的初始化的處理:

下面是這個示意圖的說明:

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

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

執行緒A在第二階段的A1執行類的初始化,並在第三階段的A4釋放鎖;執行緒C在第五階段的C1獲取同一個鎖,並在在第五階段的C4之後才開始訪問這個類。根據java記憶體模型規範的鎖規則,這裡將存在如下的happens-before關係:

這個happens-before關係將保證:執行緒A執行類的初始化時的寫入操作,執行緒C一定能看到。

※注1:這裡的condition和state標記是本文虛構出來的。Java語言規範並沒有硬性規定一定要使用condition和state標記。JVM的具體實現只要實現類似功能即可。

※注2:Java語言規範允許Java的具體實現,優化類的初始化處理過程(對這裡的第五階段做優化),具體細節參見java語言規範的12.4.2章。

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

總結

延遲初始化降低了初始化類或建立例項的開銷,但增加了訪問被延遲初始化的欄位的開銷。在大多數時候,正常的初始化要優於延遲初始化。如果確實需要對例項欄位使用執行緒安全的延遲初始化,請使用上面介紹的基於volatile的延遲初始化的方案;如果確實需要對靜態欄位使用執行緒安全的延遲初始化,請使用上面介紹的基於類初始化的方案。