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

聲明:原創作品,轉載請注明出處http://www.lxweimin.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開發的,你需要注意了,因為使用枚舉所使用的內存是靜態變量的兩倍之上,如果你的應用中使用了大量的枚舉,這將會影響到你應用的性能,不過一般用的不多的話還是沒什么關系的。

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

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,936評論 6 535
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,744評論 3 421
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,879評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,181評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,935評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,325評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,384評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,534評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,084評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,892評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,067評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,623評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,322評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,735評論 0 27
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,990評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,800評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,084評論 2 375