Java設計模式--單例模式

davidtim發表於2021-09-09

原文地址

在介紹單例模式之前,我們先了解一下,什麼是設計模式? 設計模式(Design Pattern): 是一套被反覆使用,多數人知曉的,經過分類編目的,程式碼設計經驗的總結。 目的: 使用設計模式是為了可重用性程式碼,讓程式碼更容易被他人理解,保證程式碼可靠性。

本文將會用到的關鍵詞:

  • 單例:Singleton
  • 例項:instance
  • 同步:synchronized
  • 類裝載器:ClassLoader

單例模式:

單例,顧名思義就是隻能有一個、不能再出現第二個。就如同地球上沒有兩片一模一樣的樹葉一樣。

在這裡就是說:一個類只能有一個例項,並且整個專案系統都能訪問該例項。

單例模式共分為兩大類:

  • 懶漢模式:例項在第一次使用時建立
  • 餓漢模式:例項在類裝載時建立

單例模式UML圖

單例模式UML圖

餓漢模式

按照定義我們可以寫出一個基本程式碼:

public class Singleton {

	// 使用private將構造方法私有化,以防外界通過該構造方法建立多個例項
	private Singleton() {
	}

	// 由於不能使用構造方法建立例項,所以需要在類的內部建立該類的唯一例項
	// 使用static修飾singleton 在外界可以通過類名呼叫該例項   類名.成員名
	static Singleton singleton = new Singleton();   // 1
	
	// 如果使用private封裝該例項,則需要新增get方法實現對外界的開放
	private static Singleton instance = new Singleton();    // 2
	// 新增static,將該方法變成類所有   通過類名訪問
	public static Singleton getInstance(){
		return instance;
	}
	
	//1和2選一種即可,推薦2
}
複製程式碼

對於餓漢模式來說,這種寫法已經很‘perfect’了,唯一的缺點就是,由於instance的初始化是在類載入時進行的,類載入是由ClassLoader來實現的,如果初始化太早,就會造成資源浪費。 當然,如果所需的單例佔用的資源很少,並且也不依賴於其他資料,那麼這種實現方式也是很好的。

類裝載的時機:

  • new一個物件時
  • 使用反射建立它的例項時
  • 子類被載入時,如果父類還沒有載入,就先載入父類
  • JVM啟動時執行主類 會先被載入

懶漢模式

懶漢模式的程式碼如下

// 程式碼一
public class Singleton {
    private static Singleton instance = null;
    private Singleton(){
    }
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton(); 
        }        
        return instance; 
   }
}
複製程式碼

每次獲取instance之前先進行判斷,如果instance為空就new一個出來,否則就直接返回已存在的instance。

這種寫法在單執行緒的時候是沒問題的。但是,當有多個執行緒一起工作的時候,如果有兩個執行緒同時執行到 if (instance == null),都判斷為null(第一個執行緒判斷為空之後,並沒有繼續向下執行,當第二個執行緒判斷的時候instance依然為空),最終兩個執行緒就各自會建立一個例項出來。這樣就破環了單例模式 例項的唯一性

要想保證例項的唯一性就需要使用 synchronized ,加上一個同步鎖

// 程式碼二
public class Singleton {
    private static Singleton instance = null;
    private Singleton() {}
	
    public static Singleton getInstance() {
        synchronized(Singleton.class){
			if (instance == null)
				instance = new Singleton();
		}
		return instance;
    }
}
複製程式碼

加上synchronized關鍵字之後,getInstance方法就會鎖上了。如果有兩個執行緒(T1、T2)同時執行到這個方法時,會有其中一個執行緒T1獲得同步鎖,得以繼續執行,而另一個執行緒T2則需要等待,當第T1執行完畢getInstance之後(完成了null判斷、物件建立、獲得返回值之後),T2執行緒才會執行執行。

所以這段程式碼也就避免了 程式碼一 中,可能出現因為多執行緒導致多個例項的情況。但是,這種寫法也有一個問題:給getInstance方法加鎖,雖然避免了可能會出現的多個例項問題,但是會強制除T1之外的所有執行緒等待,實際上會對程式的執行效率造成負面影響。

雙重檢查(Double-Check)

程式碼二 相對於程式碼一 的效率問題,其實是為了解決1%機率的問題,而使用了一個100%出現的防護盾。那有一個優化的思路,就是把100%出現的防護盾,也改為1%的機率出現,使之只出現在可能會導致多個例項出現的地方。 程式碼如下:

// 程式碼三
public class Singleton {
    private static Singleton instance = null;
    private Singleton() {}
	
    public static Singleton getInstance() {
		if (instance == null){
			synchronized(Singleton.class){
				if (instance == null)
					instance = new Singleton();
			}
		}
		return instance;
    }
}
複製程式碼

這段程式碼看起來有點複雜,注意其中有兩次if(instance==null)的判斷,這個叫做『雙重檢查 Double-Check』。

  • 第一個 if(instance==null),其實是為了解決
  • 程式碼二 中的效率問題,只有instance為null的時候,才進入synchronized的程式碼段大大減少了機率。第二個if(instance==null),則是跟 程式碼二一樣,是為了防止可能出現多個例項的情況。

這段程式碼看起來已經完美無瑕了。當然,只是『看起來』,還是有小概率出現問題的。想要充分理解需要先弄清楚以下幾個概念:原子操作、指令重排。

原子操作

簡單來說,原子操作(atomic)就是不可分割的操作,在計算機中,就是指不會因為執行緒排程被打斷的操作。比如,簡單的賦值是一個原子操作:

m = 6; // 這是個原子操作
複製程式碼

假如m原先的值為0,那麼對於這個操作,要麼執行成功m變成了6,要麼是沒執行 m還是0,而不會出現諸如m=3這種中間態——即使是在併發的執行緒中。

但是,宣告並賦值就不是一個原子操作:

int  n=6;//這不是一個原子操作
複製程式碼

對於這個語句,至少有兩個操作:①宣告一個變數n ②給n賦值為6——這樣就會有一箇中間狀態:變數n已經被宣告瞭但是還沒有被賦值的狀態。這樣,在多執行緒中,由於執行緒執行順序的不確定性,如果兩個執行緒都使用m,就可能會導致不穩定的結果出現。

指令重排

簡單來說,就是計算機為了提高執行效率,會做的一些優化,在不影響最終結果的情況下,可能會對一些語句的執行順序進行調整。比如,這一段程式碼:

int a ;   // 語句1 
a = 8 ;   // 語句2
int b = 9 ;     // 語句3
int c = a + b ; // 語句4
複製程式碼

正常來說,對於順序結構,執行的順序是自上到下,也即1234。但是,由於指令重排 的原因,因為不影響最終的結果,所以,實際執行的順序可能會變成3124或者1324。

由於語句3和4沒有原子性的問題,語句3和語句4也可能會拆分成原子操作,再重排。——也就是說,對於非原子性的操作,在不影響最終結果的情況下,其拆分成的原子操作可能會被重新排列執行順序。

OK,瞭解了原子操作和指令重排的概念之後,我們再繼續看程式碼三的問題。

主要在於singleton = new Singleton()這句,這並非是一個原子操作,事實上在 JVM 中這句話大概做了下面 3 件事情。

  1. 給 singleton 分配記憶體
  2. 呼叫 Singleton 的建構函式來初始化成員變數,形成例項
  3. 將singleton物件指向分配的記憶體空間(執行完這步 singleton才是非 null了)

在JVM的即時編譯器中存在指令重排序的優化。    也就是說上面的第二步和第三步的順序是不能保證的,最終的執行順序可能是 1-2-3 也可能是 1-3-2。如果是後者,則在 3 執行完畢、2 未執行之前,被執行緒二搶佔了,這時 instance 已經是非 null 了(但卻沒有初始化),所以執行緒二會直接返回 instance,然後使用,然後順理成章地報錯。    再稍微解釋一下,就是說,由於有一個『instance已經不為null但是仍沒有完成初始化』的中間狀態,而這個時候,如果有其他執行緒剛好執行到第一層if (instance ==null)這裡,這裡讀取到的instance已經不為null了,所以就直接把這個中間狀態的instance拿去用了,就會產生問題。這裡的關鍵在於執行緒T1對instance的寫操作沒有完成,執行緒T2就執行了讀操作。

對於程式碼三出現的問題,解決方案為:給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;
    }
}
複製程式碼

volatile 關鍵字的一個作用是禁止指令重排,把instance宣告為volatile之後,對它的寫操作就會有一個記憶體屏障,這樣,在它的賦值完成之前,就不用會呼叫讀操作。

注意:volatile阻止的不是singleton = new Singleton()這句話內部[1-2-3]的指令重排,而是保證了在一個寫操作([1-2-3])完成之前,不會呼叫讀操作(if (instance == null))。

其它方法

靜態內部類

public class Singleton {
   private static class SingletonHolder {
       private static final Singleton INSTANCE = new Singleton();
   }
   private Singleton (){}
   public static final Singleton getInstance() {
       return SingletonHolder.INSTANCE;
   }
}
複製程式碼

這種寫法的巧妙之處在於:對於內部類SingletonHolder,它是一個餓漢式的單例實現,在SingletonHolder初始化的時候會由ClassLoader來保證同步,使INSTANCE是一個真單例。

同時,由於SingletonHolder是一個內部類,只在外部類的Singleton的getInstance()中被使用,所以它被載入的時機也就是在getInstance()方法第一次被呼叫的時候。    它利用了ClassLoader來保證了同步,同時又能讓開發者控制類載入的時機。從內部看是一個餓漢式的單例,但是從外部看來,又的確是懶漢式的實現

列舉

public enum SingleInstance {
  INSTANCE;
   public void fun1() {
       // do something
   }
}// 使用SingleInstance.INSTANCE.fun1();
複製程式碼

是不是很簡單?而且因為自動序列化機制,保證了執行緒的絕對安全。三個詞概括該方式:簡單、高效、安全

這種寫法在功能上與共有域方法相近,但是它更簡潔,無償地提供了序列化機制,絕對防止對此例項化,即使是在面對複雜的序列化或者反射攻擊的時候。雖然這中方法還沒有廣泛採用,但是單元素的列舉型別已經成為實現Singleton的最佳方法。

相關文章