作者:zuoxiaolong8810(左瀟龍),轉載請註明出處。
上一章,我們學習了設計模式的概念,以及為什麼要學習設計模式,還有在進行系統設計時應當遵守的六大原則,本章我們就來開始一一的學習GOF當中的二十三鐘設計模式。
我一直在思考如何去詮釋這麼多設計模式,因為網上有很多現成的,可供學習的資料,我在想有什麼地方可以讓各位跟著我的節奏去學習,而不是網上的那些資料,優勢在哪裡,思考很久,我覺得唯一的優勢,或者說我能有的優勢,就是簡單通俗易懂。
遵循著中心思想通俗易懂,我們首先來回顧一下單例模式為何要出現,又或者說什麼樣的類可以做成單例的。
在我的工作過程中,我發現所有可以使用單例模式的類都有一個共性,那就是這個類沒有自己的狀態,換句話說,這些類無論你執行個體化多少個,其實都是一樣的,而且更重要的一點是,這個類如果有兩個或者兩個以上的執行個體的話,我的程式竟然會產生程式錯誤或者與現實相違背的邏輯錯誤。
這樣的話,如果我們不將這個類控製成單例的結構,應用中就會存在很多一模一樣的類執行個體,這會非常浪費系統的記憶體資源,而且容易導致錯誤甚至一定會產生錯誤,所以我們單例模式所期待的目標或者說使用它的目的,是為了儘可能的節約記憶體空間,減少無謂的GC消耗,並且使應用可以正常運作。
我稍微總結一下,一般一個類能否做成單例,最容易區別的地方就在於,這些類,在應用中如果有兩個或者兩個以上的執行個體會引起錯誤,又或者我換句話說,就是這些類,在整個應用中,同一時刻,有且只能有一種狀態。
一般實踐當中,有很多應用層級的資源會被做成單例,比如設定檔資訊,邏輯上來講,整個應用有且只能在同在時間有一個,當然如果你有多個,這可能並不會引起程式層級錯誤,這裡指的錯誤特指異常或者ERROR。但是當我們試圖改變更配置置檔案的時候,問題就出來了。
你有兩種選擇,第一種,將所有的執行個體全部更新成一模一樣的狀態。第二種,就是等著出現問題。
然而出現的問題大部分是邏輯層次上的錯誤,個人覺得這是比程式錯誤更加嚴重的錯誤,因為它不會告訴你null 指標,不會告訴你非法參數,很多時候要等到影響到客戶使用時才會被發現。
下面,我們就來看一下做成單例的幾種方式。
第一種方式,我們來看一下最標準也是最原始的單例模式的構造方式。
public class Singleton {//一個靜態執行個體private static Singleton singleton;//私人化建構函式private Singleton(){}//給出一個公用的靜態方法返回一個單一執行個體public static Singleton getInstance(){if (singleton == null) {singleton = new Singleton();}return singleton;}}
這是在不考慮並發訪問的情況下標準的單例模式的構造方式,這種方式通過幾個地方來限制了我們取到的執行個體是唯一的。
1.靜態執行個體,帶有static關鍵字的屬性在每一個類中都是唯一的。
2.限制用戶端隨意創造執行個體,即私人化構造方法,此為保證單例的最重要的一步。
3.給一個公用的擷取執行個體的靜態方法,注意,是靜態方法,因為這個方法是在我們未擷取到執行個體的時候就要提供給用戶端調用的,所以如果是非靜態話,那就變成一個矛盾體了,因為非靜態方法必須要擁有執行個體才可以調用。
4.判斷只有持有的靜態執行個體為null時才調用構造方法創造一個執行個體,否則就直接返回。
假如你去面試一家公司,給了你一道題,讓你寫出一個單例模式的例子,那麼如果你是剛出大學校門的學生,你能寫出上面這種樣本,假設我是面試官的話,滿分100的話,我會給90分,剩下的那10分算是給更優秀的人一個更高的台階。但如果你是一個有過兩三年工作經驗的人,如果你寫出上面的樣本,我估計我最多給你30分,甚至心情要是萬一不好的話可能會一分不給。
為什麼同樣的樣本放到不同的人身上差別會這麼大,就是因為前面我提到的那個情況,在不考慮並發訪問的情況下,上述樣本是沒有問題的。
至於為什麼在並發情況下上述的例子是不安全的呢,我在這裡給各位製造了一個並發的例子,用來說明,上述情況的單例模式,是有可能造出來多個執行個體的,我自己測試了約莫100次左右,最多的一次,竟然造出了3個執行個體。下面給出代碼,大約運行10次(並發是具有機率性的,10次只是保守估計,也可能一次,也可能100次)就會發現我們創造了不只一個執行個體。
import java.util.Collections;import java.util.HashSet;import java.util.Set;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;public class TestSingleton {boolean lock ;public boolean isLock() {return lock;}public void setLock(boolean lock) {this.lock = lock;}public static void main(String[] args) throws InterruptedException {final Set<String> instanceSet = Collections.synchronizedSet(new HashSet<String>());final TestSingleton lock = new TestSingleton();lock.setLock(true);ExecutorService executorService = Executors.newCachedThreadPool();for (int i = 0; i < 100; i++) {executorService.execute(new Runnable() {public void run() {while (true) {if (!lock.isLock()) {Singleton singleton = Singleton.getInstance();instanceSet.add(singleton.toString());break;}}}});}Thread.sleep(5000);lock.setLock(false);Thread.sleep(5000);System.out.println("------並發情況下我們取到的執行個體------");for (String instance : instanceSet) {System.out.println(instance);}executorService.shutdown();}}
我在程式中同時開啟了100個線程,去訪問getInstance方法,並且把獲得執行個體的toString方法獲得的執行個體字串裝入一個同步的set集合,set集合會自動去重,所以看結果如果輸出了兩個或者兩個以上的執行個體字串,就說明我們在並發訪問的過程中產生了多個執行個體。
程式當中讓main線程睡眠了兩次,第一次是為了給足夠的時間讓100個線程全部開啟,第二個是將鎖開啟以後,保證所有的線程都已經調用了getInstance方法。
好了,這下我們用事實說明了,上述的單例寫法,我們是可以創造出多個執行個體的,至於為什麼在這裡要稍微解釋一下,雖說我一直都喜歡用事實說話,包括看書的時候,我也不喜歡作者跟我解釋為什麼,而是希望給我一個例子,讓我自己去印證。
造成這種情況的原因是因為,當並發訪問的時候,第一個調用getInstance方法的線程A,在判斷完singleton是null的時候,線程A就進入了if塊準備創造執行個體,但是同時另外一個線程B線上程A還未創造出執行個體之前,就又進行了singleton是否為null的判斷,這時singleton依然為null,所以線程B也會進入if塊去創造執行個體,這時問題就出來了,有兩個線程都進入了if塊去創造執行個體,結果就造成單例模式並非單例。
為了避免這種情況,我們就要考慮並發的情況了,我們最容易想到的方式應該是下面這樣的方式,直接將整個方法同步。
public class BadSynchronizedSingleton {//一個靜態執行個體private static BadSynchronizedSingleton synchronizedSingleton;//私人化建構函式private BadSynchronizedSingleton(){}//給出一個公用的靜態方法返回一個單一執行個體public synchronized static BadSynchronizedSingleton getInstance(){if (synchronizedSingleton == null) {synchronizedSingleton = new BadSynchronizedSingleton();}return synchronizedSingleton;}}
上面的做法很簡單,就是將整個擷取執行個體的方法同步,這樣在一個線程訪問這個方法時,其它所有的線程都要處於掛起等待狀態,倒是避免了剛才同步訪問創造出多個執行個體的危險,但是我只想說,這樣的設計實在是糟糕透了,這樣會造成很多無謂的等待,所以為了表示我的憤怒,我在類名上加入Bad。
其實我們同步的地方只是需要發生在單例的執行個體還未建立的時候,在執行個體建立以後,擷取執行個體的方法就沒必要再進行同步控制了,所以我們將上面的樣本改為很多教科書中標準的單例模式版本,也稱為雙重加鎖。
public class SynchronizedSingleton {//一個靜態執行個體private static SynchronizedSingleton synchronizedSingleton;//私人化建構函式private SynchronizedSingleton(){}//給出一個公用的靜態方法返回一個單一執行個體public static SynchronizedSingleton getInstance(){if (synchronizedSingleton == null) {synchronized (SynchronizedSingleton.class) {if (synchronizedSingleton == null) {synchronizedSingleton = new SynchronizedSingleton();}}}return synchronizedSingleton;}}
這種做法與上面那種最無腦的同步做法相比就要好很多了,因為我們只是在當前執行個體為null,也就是執行個體還未建立時才進行同步,否則就直接返回,這樣就節省了很多無謂的線程等待時間,值得注意的是在同步塊中,我們再次判斷了synchronizedSingleton是否為null,解釋下為什麼要這樣做。
假設我們去掉同步塊中的是否為null的判斷,有這樣一種情況,假設A線程和B線程都在同步塊外面判斷了synchronizedSingleton為null,結果A線程首先獲得了線程鎖,進入了同步塊,然後A線程會創造一個執行個體,此時synchronizedSingleton已經被賦予了執行個體,A線程退出同步塊,直接返回了第一個創造的執行個體,此時B線程獲得線程鎖,也進入同步塊,此時A線程其實已經創造好了執行個體,B線程正常情況應該直接返回的,但是因為同步塊裡沒有判斷是否為null,直接就是一條建立執行個體的語句,所以B線程也會創造一個執行個體返回,此時就造成創造了多個執行個體的情況。
經過剛才的分析,貌似上述雙重加鎖的樣本看起來是沒有問題了,但如果再進一步深入考慮的話,其實仍然是有問題的。
如果我們深入到JVM中去探索上面這段代碼,它就有可能(注意,只是有可能)是有問題的。
因為虛擬機器在執行建立執行個體的這一步操作的時候,其實是分了好幾步去進行的,也就是說建立一個新的對象並非是原子性操作。在有些JVM中上述做法是沒有問題的,但是有些情況下是會造成莫名的錯誤。
首先要明白在JVM建立新的對象時,主要要經過三步。
1.分配記憶體
2.初始化構造器
3.將對象指向分配的記憶體的地址
這種順序在上述雙重加鎖的方式是沒有問題的,因為這種情況下JVM是完成了整個對象的構造才將記憶體的地址交給了對象。但是如果2和3步驟是相反的(2和3可能是相反的是因為JVM會針對位元組碼進行調優,而其中的一項調優便是調整指令的執行順序),就會出現問題了。
因為這時將會先將記憶體位址賦給對象,針對上述的雙重加鎖,就是說先將分配好的記憶體位址指給synchronizedSingleton,然後再進行初始化構造器,這時候後面的線程去請求getInstance方法時,會認為synchronizedSingleton對象已經執行個體化了,直接返回一個引用。如果在初始化構造器之前,這個線程使用了synchronizedSingleton,就會產生莫名的錯誤。
所以我們在語言層級無法完全避免錯誤的發生,我們只有將該任務交給JVM,所以有一種比較標準的單例模式。如下所示。
package com.oneinstance;public class InnerClassSingleton {public static Singleton getInstance(){return Singleton.singleton;}private static class Singleton{protected static Singleton singleton = new Singleton();}}
首先來說一下,這種方式為何會避免了上面莫名的錯誤,主要是因為一個類的靜態屬性只會在第一次載入類時初始化,這是JVM幫我們保證的,所以我們無需擔心並發訪問的問題。所以在初始化進行一半的時候,別的線程是無法使用的,因為JVM會幫我們強行同步這個過程。另外由於靜態變數只初始化一次,所以singleton仍然是單例的。
上面這種寫法是我們使用靜態內部類作為單例,這樣不太符合我們的習慣。所以我們改為以下形式。
public class Singleton {private Singleton(){}public static Singleton getInstance(){return SingletonInstance.instance;}private static class SingletonInstance{static Singleton instance = new Singleton();}}
好了,進行到這裡,單例模式算是已經完成了。最終的產物就是如上述的形式。上述形式保證了以下幾點。
1.Singleton最多隻有一個執行個體,在不考慮反射強行突破訪問限制的情況下。
2.保證了並發訪問的情況下,不會發生由於並發而產生多個執行個體。
3.保證了並發訪問的情況下,不會由於初始化動作未完全完成而造成使用了尚未正確初始化的執行個體。
以下為不太常用的方式,這裡給出來只是給各位參考,不建議使用下述方式。
第一種,就是俗稱的餓漢式載入。
public class Singleton {private static Singleton singleton = new Singleton();private Singleton(){}public static Singleton getInstance(){return singleton;}}
上述方式與我們最後一種給出的方式類似,只不過沒有經過內部類處理,這種方式最主要的缺點就是一旦我訪問了Singleton的任何其他的靜態域,就會造成執行個體的初始化,而事實是可能我們從始至終就沒有使用這個執行個體,造成記憶體的浪費。
不過在有些時候,直接初始化單例的執行個體也無傷大雅,對項目幾乎沒什麼影響,比如我們在應用啟動時就需要載入的設定檔等,就可以採取這種方式去保證單例。
第二種我就不貼了,與雙重鎖定一模一樣,只是給靜態執行個體屬性加上關鍵字volatile,標識這個屬性是不需要最佳化的。
這樣也不會出現執行個體化發生一半的情況,因為加入了volatile關鍵字,就等于禁止了JVM自動的指令重排序最佳化,並且強行保證線程中對變數所做的任何寫入操作對其他線程都是即時可見的。這裡沒有篇幅去介紹volatile以及JVM中變數訪問時所做的具體動作,總之volatile會強行將對該變數的所有讀和取操作綁定成一個不可拆分的動作。如果讀者有興趣的話,可以自行去找一些資料看一下相關內容。
不過值得注意的是,volatile關鍵字是在JDK1.5以及1.5之後才被給予了意義,所以這種方式要在JDK1.5以及1.5之後才可以使用,但仍然還是不推薦這種方式,一是因為代碼相對複雜,二是因為由於JDK版本的限制有時候會有諸多不便。
好了,以上基本上就是常見的所有單例模式的構造方式,如果下次再有面試讓你去寫一個單例模式,有時間的話就把上面所有的全部寫給面試官並一一將優劣講給他聽吧,這樣的話估計offer已經離你不遠了。
本次單例模式的分享就到此結束了,感謝各位的收看。
下期預告,代理模式。