Java單例模式一文通

在程序開發中我們往往會涉及到設計模式,那麼什麼是設計模式呢?官方正式的定義是一套被反覆使用經過分類編目,且多數人知曉的代碼設計經驗總結。簡單的說設計模式是軟件開發人員在軟件開發過程中面臨問題時所做出的解決方案。常用的設計模式有23中,因爲篇幅有限在本篇文章中我之講解23中設計模式中最經典的模式:單例模式。

零、什麼是單例模式

單例模式是創建型模式的一種,它主要提供了創建類對象的最優方式。在單例模式下每個類負責創建自己的對象,並且要保證每個類只創建一個對象,也就是說每個類只能提供唯一的訪問對象的方式。

單例模式的出現是爲了解決全局類被頻繁的創建和銷燬造成的性能開銷,以及避免對資源的多重佔用。單例模式雖然解決了這些問題但是它存在幾個問題,首先在單例模式下沒有接口無法繼承,其次它還與單一職責原則相互衝突,並且單例模式下的類只關注內部實現邏輯,不關注外部如何實例化。

Java 中實現單例模式的方式有六種,分別是餓漢模式、懶漢模式、加鎖懶漢模式、雙重判定加鎖懶漢模式、內部靜態類實現懶漢模式以及枚舉懶漢模式。下面我分別對這六種實現單例模式的方法進行一一講解。

一、餓漢模式

所謂餓漢模式就是在類被加載時就會實例化該類的一個對象,它就像是一個很飢餓人要迫不及待的喫東西一樣。以餓漢模式編寫的單例模式需要注意如下兩點,首先類的構造函數必須定義爲 private,這是爲防止該類不會被其他類實例化,其次類必須提供了靜態實例,並通過靜態方法返回給調用方。下面我們就根據這兩點來編寫餓漢模式的單例模式。

//餓漢模式
public class HungryMode {
	//1.定義私有構造函數
	private HungryMode() {
	}
	//2.定義靜態實例並返回給調用方
	private static HungryMode hungryMode=new HungryMode();
	public static HungryMode getHungryMode() {
		return hungryMode;
	}
	
}

餓漢模式的代碼很簡單,按照前面所說的兩個注意點進行編寫代碼即可。這種模式可以快速且簡單的創建一個線程安全的單例對象,之所以說它是線程安全的,是因爲它只在類加載時纔會初始化,在類初始化後的生命週期中將不會再次進行創建類的實例。因此這種模式特別適合在多線程情況下使用,因爲它不會多個線程中創建多個實例,避免了多線程同步的問題。但是萬物不是隻有優點沒有缺點的,這種模式最大的缺點就是不管你是否用到這個類,這個類都會在初始化的時候被實例化,這樣會造成性能的輕微損耗。餓漢模式一般用於佔用內存小並且在初始化時就會被用到的時候。

二、懶漢模式

什麼是懶漢模式呢?懶漢模式就是隻在需要對象時纔會生成單例對象,它就像一個很懶的人只有在你叫他的時候他纔會動一動。和餓漢模式一樣,懶漢模式也有兩點需要注意的,首先類的構造函數必須定義爲 private,其次類必須提供靜態實例對象且不進行初始化,並通過靜態方法返回給調用方,在編寫返回實例的靜態方法時我們需要判斷實例對象是否爲空,如果爲空則進行實例化反之則直接放回實例化對象。下面我們就來看以下代碼如何實現懶漢模式。

//懶漢模式
public class LazyMode {
	//1.定義私有構造函數
	private LazyMode() {
	}
	//2.靜態實例對象且不進行初始化
	private static LazyMode lazyMode;
	//3.編寫靜態方法,返回實例對象
	public static LazyMode getLazyMode() {
		if(lazyMode==null)
			lazyMode=new LazyMode();
		return lazyMode;
	}
}

懶漢模式規避了餓漢模式的缺點,只有在我們需要用到類的時候纔會去實例化它,並且通過餓漢模式類中的靜態方法(本例中的getLazyMode),基本上規避了重複創建類對象的問題。到這裏就需要注意了我所說的是基本上規避,而不是完全規避,我爲什麼這麼說呢?這是因爲懶漢模式並沒有考慮在多線程下當類的實例對象沒有被生成的時候很有可能存在多個線程同時進入 getLazyMode 方法,並同時生成實例對象的問題。因此我們說在懶漢模式下實現的單例模式是線程不安全的。那麼這個問題怎麼解決呢?這時我們就可以使用加鎖懶漢模式,我們來看一下代碼如何實現。

//加鎖懶漢模式
public class LockSluggerMode {
	//1.定義私有構造函數
	private LockSluggerMode() {
	}
	//2.靜態實例對象且不進行初始化
	private static LockSluggerMode lockSluggerMode;
	//3.編寫靜態方法,返回實例對象
	public static LockSluggerMode getLazyMode() {
		synchronized(LockSluggerMode.class) {
			if(lockSluggerMode==null) {
				lockSluggerMode=new LockSluggerMode();
			}
		}
		return lockSluggerMode;
	}
}

在上面的代碼中我們增加了同步鎖,這樣就避免了前面所說的問題。加鎖懶漢模式和懶漢模式的相同點都是在第一次需要時,類的實例纔會被創建,再次調用將不會重新創建新的實例對象,而是直接返回之前創建的實例對象。這兩種模式都適用於單例類的使用次數少,但消耗資源較多的時候。但是加鎖懶漢模式因爲涉及到了鎖,因此與懶漢模式相比多了一些額外的資源消耗。

三、雙重判定加鎖懶漢模式

雙重判定加鎖懶漢模式在 Java 面試中會被經常問到,但是很少有人能夠正確的寫出雙重判定加鎖懶漢模式的代碼,甚至很少有人會說出來這種模式的問題,以及在 JDK1.5版本中是如何修正這個問題的。針對這幾個問題我在這一小節中進行一一講解。

雙重判定加鎖懶漢模式的實現其實是創建線程安全單例模式的老方法,當單例的實例被創建時它會用單個鎖進行性能優化,但是因爲這個方法實現起來很複雜,因此在 JDK1.4 中實現總是失敗。在 JDK1.5 沒有修正這個問題前,爲什麼還需要這個模式呢?這時因爲在加鎖懶漢模式中雖然解決了線程併發的問題,又實現了延遲加載,但是它存在性能問題。這是因爲使用 synchronized 的同步方法執行速度會比普通方法慢得多,如果多次調用獲取實例的方法時積累的性能損耗就會很大,因此就出現了雙重判定加鎖懶漢模式。我們先來看一下具體的代碼實現。

public class DoubleJudgementLockSluggerMode {
	private static DoubleJudgementLockSluggerMode doubleJudgementLockSluggerMode;
	private DoubleJudgementLockSluggerMode() {
	}
	
	public static DoubleJudgementLockSluggerMode getInstance() {
		if(doubleJudgementLockSluggerMode==null) {
			synchronized (DoubleJudgementLockSluggerMode.class) {
				if(doubleJudgementLockSluggerMode==null) {
					doubleJudgementLockSluggerMode=new DoubleJudgementLockSluggerMode();
				}
			}
		}
		return doubleJudgementLockSluggerMode;
	}
}

在上述代碼中我們在同步代碼塊外層多加了一個 doubleJudgementLockSluggerMode 是否爲空的判斷,因此在大部分情況下調用 getInstance 方法都不會執行同步代碼塊,而是直接返回已經實例化的對象,進而提高了代碼的性能。下面我們考慮一個問題,如果程序中存在線程一和線程二,當線程一執行了外層的判斷語句它發現實例對象沒有創建,然而這個時候線程二也執行到了外層判斷語句,它同樣發現實例對象沒有創建,然後這兩個線程依次執行同步代碼塊中的內容,分別創建了連個實例對象,對於單例模式來說這種情況我們必須避免,因此我麼們在同步代碼塊中增加了 if(doubleJudgementLockSluggerMode==null) 判斷語句來解決這個問題。

到目前爲止雖然實現了延遲加載和線程併發問題,同時也解決了執行效率問題但是在 JDK1.5 之前還存一些問題。首先在 Java 中存在指令重排優化,這個功能會在不改變原有語義的情況下調整指令順序來讓程序運行的更快。但是在 JVM 中並沒有規定優化哪些內容,所以 JVM 可以隨意的進行指令重排優化。這樣就引出了一個問題,因爲指令重排優化的存在會導致初始化 DoubleJudgementLockSluggerMode 和將對象地址付給 doubleJudgementLockSluggerMode 的順序發生改變。如果在創建單例對象時,在構造函數被調用之前就已經給當前對象分配了內存並且還將對象的字段賦予了默認值,那麼此時如果將分配的內存地址賦予 doubleJudgementLockSluggerMode 字段,並有一個線程來調用 getInstance 方法,由於該對象有可能尚未初始化,因此程序就會報錯。但是在 JDK1.5 中修正了這個問題,我們只需要利用 volatile 關鍵字來禁止指令重排優化來避免上述問題。增加 volatile 關鍵字後,代碼如下:

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

四、內部靜態類懶漢模式

雙重判定加鎖懶漢模式實現起來不僅複雜,在 JDK1.4 及其以下版本上還存在指令重排優化的問題,那麼有沒有既解決了線程安全的問題又可以實現懶加載的單例模式的實現方法呢?答案是有的,我們可以利用靜態內部類來實現。我們先來看一下代碼如何實現。

// 內部靜態類懶漢模式
public class StaticInnerClass {
	private StaticInnerClass() {}
	//定義內部靜態來
	private static class StaticInnerClassHolder{
		//在內部靜態類中實例化 StaticInnerClass
		public static StaticInnerClass staticInnerClass =new StaticInnerClass();
	}
	public static StaticInnerClass getStaticInnerClass() {
		return StaticInnerClassHolder.staticInnerClass;
	}
}

這種方式和餓漢模式一樣都是利用了類加載機制,因此不存在對線程併發的問題,同時只要不適用內部類 JVM 就不會去創建單例對象,進而實現了與懶漢模式一樣的延遲加載。但是這種方式會導致最終生成的 class 文件變大,程序體積變大。

五、枚舉懶漢模式

枚舉懶漢模式在開發中並不常用,一般來說如果你編寫的類既要支持序列化和反射,又要支持單例模式的話可以使用枚舉懶漢模式,但是因爲使用了枚舉因此會造成內存佔用過大的問題。下面我們來看以下代碼,然後根據代碼來詳細講解枚舉懶漢模式。

class EnumMode {
	//more code
}

public enum ModeEnum{
	INSTAMCE;
	private EnumMode enumMode;
	private ModeEnum() {
		enumMode=new EnumMode();
	}
	public EnumMode getEnumMode() {
		return enumMode;
	}
}

在上述代碼中如果要獲取 EnumMode 實例對象我們必須這樣調用 ModeEnum.INSTAMCE.getEnumMode()。那麼枚舉懶漢模式實現的原理是什麼呢?首先在枚舉中明確了構造方法並設置爲私有,當我們訪問枚舉實例的時候會執行構造方法,同時每個枚舉實例是 static final 類型,因此只能被實例化一次。只有在構造方法被調用時單例纔會被實例化。

六、總結

這篇文章講解了 Java 中單例模式的實現方式,這些實現方式中常用的是餓漢模式和雙重判定加鎖懶漢模式這兩種,其他方式我們也需要掌握。最後我總結一下實現單例模式各種方式的線程安全問題。

實現方式 是否線程安全
餓漢模式
懶漢模式
雙重判定加鎖懶漢模式
內部靜態類懶漢模式
枚舉懶漢模式
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章