Java 大白話講解設計模式之 -- 單例模式

聲明:原創作品,轉載請註明出處https://www.jianshu.com/p/b99e870f4ce0

有的時候,我們需要某個類只能被實例化一次,那麼我們就可以使用這種模式。單例模式是相對來講比較簡單的一種設計模式,雖說簡單,卻是處處暗藏殺機。首先我們來看下一個類如何才能只被實例化一次。我們知道一般實例化對象時,都會調用類的構造方法。如果構造方法爲public,那麼肯定每個人都能實例化這個類,也就無法保證該類對象的唯一性。所以這個類的構造方法必須爲private,不能向外界提供。但是這樣我們就無法調用它的構造方法,也就無法實例化對象了,那麼由誰來實例化呢?想必你也想到了,由類自身調用,因爲這時候也只有它自身能調用構造方法了。我們看下代碼:

public class Singleton {
    
    private Singleton(){}
    
    public Singleton getInsatnce(){
        return new Singleton();
    }
}

我們在類中定義一個getInstance方法來提供一個該類的實例化對象,不過有個問題,我們該如何調用這個方法呢,因爲只有在該類實例化後才能調用這個方法,然而這個方法就是用來實例化對象的。是不是很糾結,有什麼好辦法嗎?很簡單我們只要將這個方法表示成靜態方法就可以:

public class Singleton {

    private Singleton(){}

    public static Singleton getInsatnce(){
        return new Singleton();
    }
}

這樣我們就可以直接調用Singleton.getInstance()來創建對象了。

惡漢式單例模式

可是這也不是唯一的啊,每次調用這個方法都會新建一個實例對象。別擔心,我們只要稍微改變下就好了:


public class Singleton {
    
    private static Singleton singleton = new Singleton();
    
    private Singleton(){}
    
    public static Singleton getInsatnce(){
        return singleton;
    }
}

我們在類中事先就創建好一個實例對象,每次調用getInstance方法時返回這個對象就可以了。這樣我們的單例模式就誕生了。我們可以看到,只要這個類一加載完,就會創建實例對象,顯得很着急,像一個惡漢一樣,所以我們稱之爲惡漢式單例模式。

懶漢式單例模式

上面的惡漢式單例模式會帶來一個問題,假如我們代碼中都沒用到這個類的實例對象,如果這個類簡單還好,要是複雜的話就會造成大量資源浪費。這時你可以用懶加載的方式來創建對象,即當你要使用這個實例對象時再創建。代碼如下:

public class Singleton {

    private static Singleton singleton ;

    private Singleton(){}

    public static Singleton getInsatnce(){
        if (singleton == null){
            singleton = new Singleton();
        }
        return singleton;
    }
}

由於只有當用到時才創建對象,比較懶,我們稱之爲懶漢式單例模式。

線程安全的懶漢式單例模式(雙重檢驗鎖模式)

以上懶漢式單例模式雖然可以延遲創建實例,不過會帶來新的問題,我們先看個簡單的例子:

我們在上述類的構造方法中加入一句打印語句,如果該類被實例化就會打印出日誌。如下:

public class Singleton {

    private static Singleton singleton;

    private Singleton(){
        System.out.print("實例化對象\n");
    }

    public static Singleton getInsatnce(){
        if (singleton == null){
            singleton = new Singleton();
        }
        return singleton;
    }
}

然後新建一個測試類來實例化該類:


public class TestClient {

    public static void main(String[] args){
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                Singleton.getInsatnce();
            }
        });
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                Singleton.getInsatnce();
            }
        });
        thread1.start();
        thread2.start();
    }
}

測試類也很簡單,創建了兩個線程,每個線程都調用Singleton的getInstance方法來獲取對象。好了我們運行該測試類看下:

輸出結果:
-------------------------------------------
實例化對象
-------------------------------------------

正如我們所願,雖然我們調用兩次getInstance,但該類只被實例化一次。但不要高興的太早,你多運行幾次後會發現,有的時候會打印兩遍,也就說該類被實例化了兩遍:

輸出結果:
-------------------------------------------
實例化對象
實例化對象
-------------------------------------------

這是爲什麼呢?我們明明已經做了判斷,如果對象爲空就創建,不爲空就直接返回,怎麼還會創建兩遍呢?其實這正是多線程在作怪。

我們知道,程序的運行其實就是CPU在一條條的執行指令,如果是單線程,那麼CPU就會依次執行該線程中的指令,但如果是多線程,比如有兩個線程,線程A和線程B。爲了讓兩個線程同時執行,那麼CPU會執行A線程一段時間然後暫停,去執行B線程的指令,一段時間後再切換到A線程執行。這樣反覆切換直到程序結束,由於CPU切換的速度很快,所以讓人感覺兩個線程是同時執行的。那麼到底CPU什麼時候切換,以及每次切換執行多久呢,其實這都是隨機的,一般來講每個線程被執行到的機率都差不多,不過你可以提高某個線程的優先級來讓CPU多執行你會兒,但什麼時候切換你是無法控制的,只能提高你被執行到的機率。

好了,回到上面的例子,我們來看下這個問題到底是怎麼產生的。爲了便於說明我在Singleton類中標記了兩行代碼,這兩行代碼也正是問題的關鍵:

public class Singleton {

    private static Singleton singleton;

    private Singleton(){
        System.out.print("實例化對象\n");
    }

    public static Singleton getInsatnce(){
        if (singleton == null){      //-----------------------------------1
            singleton = new Singleton();    //--------------------------- 2
        }
        return singleton;
    }
}

假設現在有兩個線程A和B,首先A線程調用getInstance方法,當執行到語句1時會判斷對象是否爲空,由於該類還沒被實例化,所以條件成立,遍進入到花括號中準備執行語句2,正如前面所說線程的切換是隨機,當正準備執行語句2時,線程A突然停在這裏了,CPU切換到線程B去執行。當線程B執行這個方法時,也會判斷語句1的條件是否成立,由於A線程停在了語句1和2之間,實例還未創建,所以條件成立,也會進入到花括號中,注意此時線程B並未停止,而是順利的執行語句2,創建了一個實例,並返回。然後線程B又切換回了線程A,別忘了,這時,線程A還停在語句1和2之間,切換回它的時候就又繼續執行下面的代碼,也就是執行語句2,創建了一個實例,並返回。這樣,兩個對象就被創建出來了,我們的單例模式也就失效了。

好了,找到原因了,那有什麼方法解決嗎?很簡單只要在getInstance方法前加上關鍵字synchronized就可以了。代碼如下:

public class Singleton {

    private static Singleton singleton;

    private Singleton(){
        System.out.print("實例化對象\n");
    }

    public static synchronized Singleton getInsatnce(){
        if (singleton == null){      //-----------------------------------1
            singleton = new Singleton();    //--------------------------- 2
        }
        return singleton;
    }
}

synchronized修飾這個方法,相當於給這個方法加了把鎖,只要有線程進入到這個方法裏面,那麼這個鎖就會被鎖上,這時其他的線程想要執行這個方法時,一看,呦,廁所門關着待會再來上。只有當裏面的線程執行完這個方法後,這個鎖纔會打開,其他線程才能進入。這樣就很好的避免前面重複創建對象的問題。synchronized雖然解決了這個問題,但是synchronized會降低程序執行效率,試想你開車到某地有兩條路,突然其中一條在維修,被封鎖了,那就勢必會造成另一條路的擁堵。看來我們還得再優化下上面的代碼。

由上面的分析我們知道,這個實例的重複創建問題主要是在實例還未被創建的時候,且是在執行語句1,2時產生的,只要實例創建成功了,就沒有必要加鎖了。換句話說,我們沒有必要給整個getInstance方法加鎖,其實只用在實例還未創建時給語句1和語句2加個鎖就可以了,當實例創建成功後會直接返回實例。優化後的代碼如下:


public class Singleton {

    private static Singleton singleton;

    private Singleton() {
        System.out.print("實例化對象\n");
    }

    public static Singleton getInsatnce() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {   
                    singleton = new Singleton();  
                }
            }
        }
        return singleton;
    }
}

這裏synchronized 的用法和上面的有所不同,上面我們用synchronized 來修飾方法,表示給整個方法上了把鎖,我們稱之爲同步方法。這裏我們只給語句1和語句2加了把鎖,我們稱這種結構爲同步代碼塊,同步代碼塊synchronized 後面的括號中需要一個對象,可以任意,這裏我們用了Singleton的類對象Singleton.class。可以看到我們在方法中進行了兩次對象是否爲空的判斷,一次在同步代碼塊外面,一次在裏面。因此稱之爲雙重檢驗鎖模式(Double Checked Locking Pattern)。爲什麼要判斷兩次呢,當還未實例化的時候,就進行同步創建對象,爲什麼同步代碼塊裏面還要做次判斷呢?我們來分析下如果裏面不做判斷會怎麼樣:

public class Singleton {

    private static Singleton singleton;

    private Singleton() {
        System.out.print("實例化對象\n");
    }

    public static Singleton getInsatnce() {
        if (singleton == null) {//-----------------------------1
            synchronized (Singleton.class) {//-----------2
                singleton = new Singleton();    //----------3
            }
        }
        return singleton;
    }
}

如上,我們在同步代碼塊中去掉了判斷語句,這時有兩個線程A、B調用getInstance方法。假設A先調用,當A調用方法時,會執行語句1進行條件判斷,由於對象尚未創建,所以條件成立,正準備執行語句2來獲取同步鎖。我們上面也分析過了,線程的切換是隨機的,還未執行語句2時,線程A突然停這了,切換到線程B執行。當線程B調用getInstance方法時也會執行語句1進行條件的判斷,由於這時實例還未創建,所以條件成立,注意這時線程B還是沒有停,又繼續執行了語句2和3,即獲取了同步鎖並創建了Singleton對象。這時線程B切換回A,由於A此時還停在語句1和2之間,切回A時,就又繼續執行語句2和3,即獲取同步鎖並創建了Singleton對象,這樣兩個對象就被創建出來了,synchronized 也失去了意義。所以我們需要在同步代碼塊中再做次判斷:

public class Singleton {

    private static Singleton singleton;

    private Singleton() {
        System.out.print("實例化對象\n");
    }

    public static Singleton getInsatnce() {
        if (singleton == null) {//-----------------------------1
            synchronized (Singleton.class) {//------------2
                if (singleton == null) {   
                    singleton = new Singleton();  
                }
            }
        }
        return singleton;
    }
}

這樣當線程A從語句1和2之間醒來,然後獲取到同步鎖後在創建對象前做一個判斷,如果對象爲空就創建,如果不爲空就直接跳出同步代碼塊並返回之前線程B創建的對象。這樣當下次再調用getInstance方法時,由於之前創建過對象,就不會再進入到同步代碼塊中,而是直接返回實例。我們代碼的執行效率也就上去了。好了現在我們的雙重檢驗鎖模式既解決了在多線程中重複創建對象問題,又提高了代碼執行效率,同時還是懶加載模式,是不是已經非常完美了?別高興的太早,其實這還是有問題的。納尼!!搞了這麼久怎麼還有問題,有的朋友可能已經坐不住準備退票了。你先別急,讓我們看看到底哪裏還有問題。其實問題就出在Singleton的創建語句上:

singleton = new Singleton();  

爲什麼這句會有問題呢,在分析原因之前,我們先來了解下Java虛擬機在執行該對象創建語句時具體做了哪些事情,我們簡單概括爲3步:

  • 1 在棧內存中創建singleton 變量,在堆內存中開闢一個空間用來存放Singleton實例對象,該空間會得到一個隨機地址,假設爲0x0001。
  • 2 對Singleton實例對象初始化。
  • 3 將singleton變量指向該對象,也就是將該對象的地址0x0001賦值給singleton變量,此時singleton就不爲null了。

我們之前說過,程序的運行其實就是CPU在一條條執行指令,有的時候CPU爲了提高程序運行效率會打亂部分指令的執行順序,也就是所謂的指令重排序,當然這種指令重排序並不改變最後的運行結果。我們上面的3步就包含了大量的CPU指令,當CPU執行時,是無法保證一定是按照123的順序執行,也可能由於指令重排序的優化,會以132的順序執行。假設現在有兩個線程A、B,CPU先切換到線程A,當執行上述創建對象語句時,假設是以132的順序執行,當線程A執行完3時(執行完第3步後singleton就不爲null了),突然停住了,CPU切換到了線程B去調用getInstance方法,由於singleton此時不爲null,就直接返回了singleton,但此時步驟2是還沒執行的,返回的對象還是未初始化的,這樣程序也就出問題了。那有什麼方法解決嗎?很簡單隻要用volatile修飾singleton變量就可以了:

 private volatile static Singleton singleton;

那爲什麼volatile 修飾變量就可以了呢,我們上面提到指令的重排序,其實CPU在執行指令時並不是無節操隨意打亂順序,而是有一定的原則可尋的,這個原則也叫先行發生原則(happens-before),只要不符合這個原則,那麼執行順序也是得不到保障的,具體有以下8條原則:

先行發生原則(happens-before):

  • 1 程序次序規則:一個線程內,按照代碼順序,書寫在前面的操作先行發生於書寫在後面的操作
  • 2 鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作
  • 3 volatile變量規則:對一個變量的寫操作先行發生於後面對這個變量的讀操作
  • 4 傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C
  • 5 線程啓動規則:Thread對象的start()方法先行發生於此線程的每個一個動作
  • 6 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生
  • 7 線程終結規則:線程中所有的操作都先行發生於線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行
  • 8 對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始

這些原則你可能不太理解,不過沒關係,這裏我們重點關注第3條:即,對volatile變量的寫操作先行與對這個變量的讀操作。我們知道上面的問題主要是線程B對Singleton 對象讀取時,該對象還未寫入初始化導致的,那麼如果我們用volatile來修飾的話,就不會出現這種情況,我們的虛擬機在讀取該對象時,會保證其一定是寫入好的,也就是不會出現132這種情況。這樣也就不會出現問題啦。我們來看下完整的代碼:

public class Singleton {

    private volatile static Singleton singleton;

    private Singleton() {
        System.out.print("實例化對象\n");
    }

    public static Singleton getInsatnce() {
        if (singleton == null) {//-----------------------------1
            synchronized (Singleton.class) {//------------2
                if (singleton == null) {   
                    singleton = new Singleton();  
                }
            }
        }
        return singleton;
    }
}

好了,這就是完美的雙重檢驗鎖的單例模式啦,放心,現在絕對沒坑了。不過每次寫單例模式都要寫這麼多,也是挺麻煩的,有簡單點的嗎?當然有了,下面介紹兩種比較簡潔的單例模式,即用靜態內部類和枚舉來實現,我們來簡單瞭解下。

靜態內部類實現單例模式

public class Singleton {  
    private static class SingletonHolder {  
        private static final Singleton singleton = new Singleton();  
    }  
    private Singleton (){}  
    public static final Singleton getInstance() {  
        return SingletonHolder.singleton; 
    }  
}

可以看到,我們在Singleton 類內部定義一個SingletonHolder 靜態內部類,裏面有一個對象實例,當然由於Java虛擬機自身的特性,只有調用該靜態內部類時纔會創建該對象,從而實現了單例的延遲加載,同樣由於虛擬機的特性,該模式是線程安全的,並且後續讀取該單例對象時不會進行同步加鎖的操作,提高了性能。

枚舉實現單例模式

接下來,我們看下如何用枚舉來實現單例。可能有的朋友對枚舉還不是很熟悉,其實枚舉和我們的普通類沒有太大區別,比如我們下面舉個簡單的例子:

public class Person {
    
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

上面,我們定義了一個簡單的Person類,類中定義了一個屬性name,非常簡單,接下來,如果你想要操作這個類,比如創建一個person對象,並寫入對象的name然後再獲取name,也是非常簡單:

    Person person1 = new Person();
    person1.setName("張三");
    System.out.print(person1.getName());

這裏,想必你只要接觸過Java語言都能看懂上面的代碼,我們創建了一個Person對象,並給對象設置了一個名字,然後打印出名字。

接下來我們看下用枚舉如何完成上面的操作,我們把上面的Person類稍加修改:

public enum Person {
    person1;
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

你可能發現了不同的地方,我們把Person類名前的class改爲了enum,表示這個Person類是個枚舉類,然後你會發現,這個類中比之前的類多了一行代碼

    person1;

這個person1是什麼,你發現前面我們實例化Person的時候也出現過person1,是的你沒猜錯,這裏的person1就是我們這個枚舉類的一個對象實例,也就是說,如果你要獲取一個Person對象,不用再像上面那樣調用new Person()來創建對象,直接獲取這個枚舉類中的person1就可以了,這個person1就是一個Person對象實例,你可能不信,沒關係我們來試驗下:

 Person.person1.setName("張三");
 System.out.print(Person.person1.getName());

運行後你會發現,成功打印出來名字,是不是很方便。可能你會說,那我想再創建一個Person對象比如叫做person2,怎麼辦呢,很簡單,只要在person1後面再加上person2就可以了,如下:

   person1,person2;

注意:實例person1和實例person2之間要用逗號隔開,用分號結束,且實例要定義在類中的第一行。

好了,瞭解的枚舉的簡單實用,問題來了如何將上述的枚舉Person類改爲單例呢,很簡單,我們只要在類中中定義一個實例就可以了,比如去掉person2,只保留person1,如下:

public enum Person {
    person1;
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

這樣你就只能獲取到一個Person實例對象了。可能有的人有疑惑了,不對啊,難道我就不能再new一個嗎?這個是不能的,因爲枚舉類的構造方法是私有掉的,你是無法調用到的並且你也無法通過反射來創建該實例,這也是枚舉的獨特之處。可能有人會問了,如果這個Person的name需要在對象創建時就初始化好,那該怎麼辦呢?很簡單,就和普通類一樣只要在裏面定義一個構造方法,傳入name參數就可以了。如下:

public enum Person {
    person1("張三");
    private String name;

    Person(String name){
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

可以看到就和普通類一樣,我們在枚舉類中定義了一個入參爲name的構造方法,注意這構造方法前面雖然沒有加權限修飾的,但並不表示它的權限是默認的,前面提到枚舉類中的構造方法是私有的,即使你強行給它加個public,編輯器也會報錯。好了,定義好了構造方法,就可以調用。調用也會簡單,直接在實例person1後面加個括號傳入一個名字就可以了,這樣Person中的name字段就有值了,你可以直接調用Person.person1.getName()來獲取這個名字,是不是很方便。

另外枚舉類實例的創建也是線程安全的,所以使用枚舉來實現單例也是一種比較推薦的方法,但是如果你是做Android開發的,你需要注意了,因爲使用枚舉所使用的內存是靜態變量的兩倍之上,如果你的應用中使用了大量的枚舉,這將會影響到你應用的性能,不過一般用的不多的話還是沒什麼關係的。

好了,單例模式到這裏也介紹的差不多,你可以根據自己的喜好以及具體的業務選擇其中一種。

設計模式持續更新中...

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章