Java 設計模式(四):單例模式
對於一個軟體系統的某些類而言,我們無須建立多個例項。舉個大家都熟知的例子——Windows工作管理員,如圖所示:
通常情況下,無論我們啟動任務管理多少次,Windows系統始終只能彈出一個工作管理員視窗。為什麼要這樣設計呢?我們可以從以下兩個方面來分析:其一,如果能彈出多個視窗,且這些視窗的內容完全一致,全部是重複物件,這勢必會浪費系統資源,工作管理員需要獲取系統執行時的諸多資訊,這些資訊的獲取需要消耗一定的系統資源,包括CPU資源及記憶體資源等;其二,如果彈出的多個視窗內容不一致,這就意味著在某一瞬間系統資源使用情況和程序、服務等資訊存在多個狀態,例如工作管理員視窗A顯示“CPU使用率”為10%,視窗B顯示“CPU使用率”為15%,到底哪個才是真實的呢?
1. 模式概述
定義:確保某一個類只有一個例項,而且自行例項化並向整個系統提供這個例項,這個類稱為單例類,它提供全域性訪問的方法。單例模式是一種物件建立型模式。
下面我們來模擬實現Windows工作管理員,假設工作管理員的類名為TaskManager在TaskManager類中包含了大量的成員方法,例如建構函式TaskManager(),顯示程序的方法displayProcesses(),顯示服務的方法displayServices()等,該類的示意程式碼如下:
public class TaskManager {
//初始化視窗
public TaskManager () { /*……*/ }
//顯示程序
public void displayProcesses() { /*……*/ }
//顯示服務
public void displayServices() { /*……*/ }
//其他方法
//……
}
為了實現Windows工作管理員的唯一性,我們通過如下三步來對該類進行重構:
(1) 由於每次使用new關鍵字來例項化TaskManager類時都將產生一個新物件,為了確保TaskManager例項的唯一性,我們需要禁止類的外部直接使用new來建立物件,因此需要將TaskManager的建構函式的可見性改為private,程式碼如下所示:
private TaskManager() { /*……*/ }
(2) 將建構函式改為private修飾後該如何建立物件呢?我們可以在TaskManager內部建立並儲存唯一例項,提供給類外部呼叫。為了讓外界可以訪問這個唯一例項,需要在TaskManager中定義一個靜態的TaskManager型別的私有成員變數,程式碼如下所示:
private static TaskManager tm = null;
(3) 為了保證成員變數的封裝性,我們將TaskManager型別的tm物件的可見性設定為private,但外界該如何使用該成員變數並何時例項化該成員變數呢?答案是增加一個公有的靜態方法,程式碼如下所示:
public static TaskManager getInstance() {
if (tm == null) {
tm = new TaskManager();
}
return tm;
}
在getInstance()方法中首先判斷tm物件是否存在,如果不存在(即tm == null),則使用new關鍵字建立一個新的TaskManager型別的tm物件,再返回新建立的tm物件;否則直接返回已有的tm物件。
需要注意的是getInstance()方法的修飾符,首先它應該是一個public方法,以便供外界其他物件使用,其次它使用了static關鍵字,即它是一個靜態方法,在類外可以直接通過類名來訪問,而無須建立TaskManager物件,事實上在類外也無法建立TaskManager物件,因為建構函式是私有的。
通過以上三個步驟,我們完成了一個最簡單的單例類的設計,其完整程式碼如下:
public class TaskManager {
//初始化視窗
//public TaskManager() { /*……*/ }
private TaskManager() { /*……*/ }
//顯示程序
public void displayProcesses() { /*……*/ }
//顯示服務
public void displayServices() { /*……*/ }
//其他方法
//……
private static TaskManager tm = null;
public static TaskManager getInstance() {
if (tm == null) {
tm = new TaskManager();
}
return tm;
}
}
在類外我們無法直接建立新的TaskManager物件,但可以通過程式碼TaskManager.getInstance()來訪問例項物件,第一次呼叫getInstance()方法時將建立唯一例項,再次呼叫時將返回第一次建立的例項,從而確保例項物件的唯一性。
單例模式有三個要點:一是某個類只能有一個例項;二是它必須自行建立這個例項;三是它必須自行向整個系統提供這個例項。
單例模式是結構最簡單的設計模式一,在它的核心結構中只包含一個被稱為單例類的特殊類。單例模式結構如圖所示:
在單例模式結構圖中只包含一個角色:
- Singleton(單例):在單例類的內部實現只生成一個例項,同時提供一個靜態的getInstance()方法,讓客戶可以訪問它的唯一例項;為了防止在外部對其例項化,將其建構函式設計為私有;在單例類內部定義了一個Singleton型別的靜態物件,作為外部共享的唯一例項。
2. 單例模式的多種實現
2.1 餓漢式
餓漢式單例類是實現起來最簡單的單例類,餓漢式單例類結構圖如圖所示:
從圖中可以看出,由於在定義靜態變數的時候例項化單例類,因此在類載入的時候就已經建立了單例物件,程式碼如下所示:
public class SingletonEhs {
private static SingletonEhs instance = new SingletonEhs();
private SingletonEhs() {
}
public static SingletonEhs getInstance() {
return instance;
}
public void showMessage() {
System.out.println("餓漢式單例");
}
}
當類被載入時,靜態變數instance會被初始化,此時類的私有建構函式會被呼叫,單例類的唯一例項將被建立。
2.2 懶漢式
除了餓漢式單例,還有一種經典的懶漢式單例,也就是第一章節中TaskManager實現的方式,懶漢式單例類結構圖如圖所示:
從圖中可以看出,懶漢式單例在第一次呼叫getInstance()方法時例項化,在類載入時並不自行例項化,這種技術又稱為延遲載入(Lazy Loading)技術,即需要的時候再載入例項,程式碼如下所示:
public class SingletonLhsNoSyn {
private static SingletonLhsNoSyn instance;
private SingletonLhsNoSyn() {
}
public static SingletonLhsNoSyn getInstance() {
if (instance == null) {
instance = new SingletonLhsNoSyn();
}
return instance;
}
public void showMessage() {
System.out.println("懶漢式,執行緒不安全單例");
}
}
這種方式實現起來同樣很容易,但是存在一個問題,它不能保證在多執行緒的情況下保證只生成一個例項。為了避免多個執行緒同時呼叫getInstance()方法,我們可以使用關鍵字synchronized,程式碼如下所示:
public class SingletonLhsSyn {
private static SingletonLhsSyn instance;
private SingletonLhsSyn() {
}
public static synchronized SingletonLhsSyn getInstance() {
if (instance == null) {
instance = new SingletonLhsSyn();
}
return instance;
}
public void showMessage() {
System.out.println("懶漢式,執行緒安全單例");
}
}
該懶漢式單例類在getInstance()方法前面增加了關鍵字synchronized進行執行緒鎖,以處理多個執行緒同時訪問的問題。但是,上述程式碼雖然解決了執行緒安全問題,但是每次呼叫getInstance()時都需要進行執行緒鎖定判斷,在多執行緒高併發訪問環境中,將會導致系統性能大大降低。如何既解決執行緒安全問題又不影響系統性能呢?接下來介紹單例的第三種實現方式–雙檢鎖。
2.3 雙檢鎖/雙重校驗鎖
執行緒安全懶漢式解決了執行緒安全問題但可能會影響系統性能,我們需要對其進行改進。事實上,我們無須對整個getInstance()方法進行鎖定,只需對其中的程式碼“instance = new SingletonLhsSyn();”進行鎖定即可。因此getInstance()方法可以進行如下改進:
public class SingletonDCL {
private static SingletonDCL instance;
private SingletonDCL() {
}
public static SingletonDCL getInstance() {
//先檢查例項是否存在,如果不存在才進入下面的同步塊
if (instance == null) {
//同步塊,執行緒安全的建立例項
synchronized (SingletonDCL.class) {
instance = new SingletonDCL();
}
}
return instance;
}
public void showMessage() {
System.out.println("雙檢鎖/雙重校驗鎖單例");
}
}
問題貌似得以解決,事實並非如此。如果使用以上程式碼來實現單例,還是會存在單例物件不唯一。原因如下:假如在某一瞬間執行緒A和執行緒B都在呼叫getInstance()方法,此時instance物件為null值,均能通過instance == null的判斷。由於實現了synchronized加鎖機制,執行緒A進入synchronized鎖定的程式碼中執行例項建立程式碼,執行緒B處於排隊等待狀態,必須等待執行緒A執行完畢後才可以進入synchronized鎖定程式碼。但當A執行完畢時,執行緒B並不知道例項已經建立,將繼續建立新的例項,導致產生多個單例物件,因此需要進行進一步改進,在synchronized中再進行一次(instance == null)判斷,所以這種方式被稱為雙重檢查鎖定(Double-Check Locking)。使用雙重檢查鎖定實現的懶漢式單例類完整程式碼如下所示:
public class SingletonDCL {
//對儲存例項的變數新增volitile的修飾
private volatile static SingletonDCL instance;
private SingletonDCL() {
}
public static SingletonDCL getInstance() {
//先檢查例項是否存在,如果不存在才進入下面的同步塊
if (instance == null) {
//同步塊,執行緒安全的建立例項
synchronized (SingletonDCL.class) {
//再次檢查例項是否存在,如果不存在才真正的建立例項
if (instance == null) {
instance = new SingletonDCL();
}
}
}
return instance;
}
public void showMessage() {
System.out.println("雙檢鎖/雙重校驗鎖單例");
}
}
需要注意的是,如果使用雙重檢查鎖定來實現懶漢式單例類,需要在靜態成員變數instance之前增加修飾符volatile,被volatile修飾的成員變數可以確保多個執行緒都能夠正確處理(這裡要加volatile的原因以及更詳細的介紹可以參考我的另一篇部落格Java 併發:volatile 記憶體可見性和指令重排),且該程式碼只能在JDK 1.5及以上版本中才能正確執行。由於volatile關鍵字會遮蔽Java虛擬機器所做的一些程式碼優化,可能會導致系統執行效率降低,因此即使使用雙重檢查鎖定來實現單例模式也不是一種完美的實現方式。
2.4 靜態內部類
餓漢式單例類不能實現延遲載入,不管將來用不用始終佔據記憶體;懶漢式單例類執行緒安全需要控制瑣,而且效能受影響。這裡介紹一種更好的單例實現方式–靜態內部類。
我們在單例類中增加一個靜態內部類,在該內部類中建立單例物件,再將該單例物件通過getInstance()方法返回給外部使用,實現程式碼如下所示:
public class SingletonSIC {
//類級的內部類,也就是靜態類的成員式內部類,該內部類的例項與外部類的例項沒有繫結關係,而且只有被呼叫時才會裝載,從而實現了延遲載入
private static class SingletonHolder {
//靜態初始化器,由JVM來保證執行緒安全
private static final SingletonSIC INSTANCE = new SingletonSIC();
}
private SingletonSIC() {
}
public static final SingletonSIC getInstance() {
return SingletonHolder.INSTANCE;
}
public void showMessage() {
System.out.println("靜態內部類單例");
}
}
由於靜態單例物件沒有作為Singleton的成員變數直接例項化,因此類載入時不會例項化Singleton,第一次呼叫getInstance()時將載入內部類SingletonHolder,在該內部類中定義了一個static型別的變數instance,此時會首先初始化這個成員變數,由Java虛擬機器來保證其執行緒安全性,確保該成員變數只能初始化一次。由於getInstance()方法沒有任何執行緒鎖定,因此其效能不會造成任何影響。
這種技術被稱之為Initialization Demand Holder (IoDH)的技術,通過IoDH,我們既可以實現延遲載入,又可以保證執行緒安全,不影響系統性能,不失為一種最好的Java語言單例模式實現方式(其缺點是與程式語言本身的特性相關,很多面向物件語言不支援IoDH)。
2.5 列舉類
在Effective Java中介紹到另外一種實現單例的方式–列舉單例。相比於其它方式實現的單例,列舉單例能夠保證單例不被反射破壞。列舉單例實現如下:
public enum SingletonEnum {
INSTANCE;
SingletonEnum() {
}
public void showMessage() {
System.out.println("列舉單例");
}
}
這裡有個概念–“反射破壞單例”,在上面介紹的方法中,我們通過將建構函式私有化保證外部不能通過new的方式建立物件例項,一般情況下確實可以保證類例項的唯一性。但是我們知道,反射可以幫助我們獲取類的方法,建構函式,甚至訪問私有屬性。通過反射建立不同物件例項的程式碼如下所示:
public class SingleReflection {
@SuppressWarnings("unchecked")
public static void main(String[] args) throws Exception {
SingletonEhs s1 = SingletonEhs.getInstance();
Class clazz = SingletonEhs.class;
Constructor constructor = clazz.getDeclaredConstructor();
constructor.setAccessible(true);
SingletonEhs s2 = (SingletonEhs) constructor.newInstance();
SingletonEhs s3 = (SingletonEhs) constructor.newInstance();
System.out.println(s1 == s2); // false
System.out.println(s1.equals(s2)); // false
System.out.println(s2 == s3); // false
System.out.println(s2.equals(s3)); // false
//以下輸出三個不同的hashcode值
System.out.println(s1.hashCode());
System.out.println(s2.hashCode());
System.out.println(s3.hashCode());
}
}
而當我們以同樣的方式獲取列舉單例類的建構函式時會報錯(即使我們在列舉類中定義建構函式):
Exception in thread "main" java.lang.NoSuchMethodException: cn.qianlq.singleton.object.SingletonEnum.<init>()
at java.lang.Class.getConstructor0(Class.java:3082)
at java.lang.Class.getDeclaredConstructor(Class.java:2178)
at cn.qianlq.singleton.reflection.SingleReflection.main(SingleReflection.java:40)
列舉類的實質:
3. 模式總結
單例模式作為一種目標明確、結構簡單、理解容易的設計模式,在軟體開發中使用頻率相當高,在很多應用軟體和框架中都得以廣泛應用。
- 主要優點
① 單例模式提供了對唯一例項的受控訪問。因為單例類封裝了它的唯一例項,所以它可以嚴格控制客戶怎樣以及何時訪問它。
② 由於在系統記憶體中只存在一個物件,因此可以節約系統資源,對於一些需要頻繁建立和銷燬的物件單例模式無疑可以提高系統的效能。
③ 允許可變數目的例項。基於單例模式我們可以進行擴充套件,使用與單例控制相似的方法來獲得指定個數的物件例項,既節省系統資源,又解決了單例單例物件共享過多有損效能的問題。 - 主要缺點
① 由於單例模式中沒有抽象層,因此單例類的擴充套件有很大的困難。
② 單例類的職責過重,在一定程度上違背了“單一職責原則”。因為單例類既充當了工廠角色,提供了工廠方法,同時又充當了產品角色,包含一些業務方法,將產品的建立和產品的本身的功能融合到一起。
③ 現在很多面向物件語言(如Java、C#)的執行環境都提供了自動垃圾回收的技術,因此,如果例項化的共享物件長時間不被利用,系統會認為它是垃圾,會自動銷燬並回收資源,下次利用時又將重新例項化,這將導致共享的單例物件狀態的丟失。 - 適用場景
① 系統只需要一個例項物件,如系統要求提供一個唯一的序列號生成器或資源管理器,或者需要考慮資源消耗太大而只允許建立一個物件。
② 客戶呼叫類的單個例項只允許使用一個公共訪問點,除了該公共訪問點,不能通過其他途徑訪問該例項。
4. 思考
如何對單例模式進行改造,使得系統中某個類的物件可以存在有限多個,例如兩例或三例?【注:改造之後的類可稱之為多例類。】