單例設計模式

 上一章,我們學習了設計模式的概念,以及爲什麼要學習設計模式,還有在進行系統設計時應當遵守的六大原則,本章我們就來開始一一的學習GOF當中的二十三鍾設計模式。

            我一直在思考如何去詮釋這麼多設計模式,因爲網上有很多現成的,可供學習的資料,我在想有什麼地方可以讓各位跟着我的節奏去學習,而不是網上的那些資料,優勢在哪裏,思考很久,我覺得唯一的優勢,或者說我能有的優勢,就是簡單通俗易懂。

            遵循着中心思想通俗易懂,我們首先來回顧一下單例模式爲何要出現,又或者說什麼樣的類可以做成單例的。

            在我的工作過程中,我發現所有可以使用單例模式的類都有一個共性,那就是這個類沒有自己的狀態,換句話說,這些類無論你實例化多少個,其實都是一樣的,而且更重要的一點是,這個類如果有兩個或者兩個以上的實例的話,我的程序竟然會產生程序錯誤或者與現實相違背的邏輯錯誤。

            這樣的話,如果我們不將這個類控制成單例的結構,應用中就會存在很多一模一樣的類實例,這會非常浪費系統的內存資源,而且容易導致錯誤甚至一定會產生錯誤,所以我們單例模式所期待的目標或者說使用它的目的,是爲了儘可能的節約內存空間,減少無謂的GC消耗,並且使應用可以正常運作。

            我稍微總結一下,一般一個類能否做成單例,最容易區別的地方就在於,這些類,在應用中如果有兩個或者兩個以上的實例會引起錯誤,又或者我換句話說,就是這些類,在整個應用中,同一時刻,有且只能有一種狀態。

            一般實踐當中,有很多應用級別的資源會被做成單例,比如配置文件信息,邏輯上來講,整個應用有且只能在同在時間有一個,當然如果你有多個,這可能並不會引起程序級別錯誤,這裏指的錯誤特指異常或者ERROR。但是當我們試圖改變配置文件的時候,問題就出來了。

            你有兩種選擇,第一種,將所有的實例全部更新成一模一樣的狀態。第二種,就是等着出現問題。

            然而出現的問題大部分是邏輯層次上的錯誤,個人覺得這是比程序錯誤更加嚴重的錯誤,因爲它不會告訴你空指針,不會告訴你非法參數,很多時候要等到影響到客戶使用時纔會被發現。

            下面,我們就來看一下做成單例的幾種方式。

            第一種方式,我們來看一下最標準也是最原始的單例模式的構造方式。

複製代碼
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已經離你不遠了。

發佈了83 篇原創文章 · 獲贊 26 · 訪問量 10萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章