回字有四種寫法,那你知道單例有五種寫法嗎

RudeCrab發表於2021-01-20

基本介紹

單例模式(Singleton)應該是大家接觸的第一個設計模式,其寫法相較於其他的設計模式來說並不複雜,核心理念也非常簡單:程式從始至終只有同一個該類的例項物件。

舉一個耳熟能詳的例子,比如LOL中的大龍,一場遊戲下來無論如何只有一隻,所以該類只能被例項化一次。再舉一個我們應用程式開發中常見的例子,Spring框架中的Bean作用範圍預設也是單例的。

我相信大家都知道單例的兩種最基本的寫法:餓汗式和懶漢式。但是這兩種寫法都有其弊端所在,除了這兩種寫法外其實還有幾種寫法。此時耳邊彷彿聽到孔乙己的聲音:

“對呀對呀!......回字有四樣寫法,你知道麼?”。

我愈不耐煩了,努著嘴走遠。孔乙己剛用指甲蘸了酒,想在櫃上寫字,見我毫不熱心,便又嘆一口氣,顯出極惋惜的樣子........

大家先彆著急走,回字的四樣寫法沒必要知道,單例的五種寫法還是有必要曉得滴,其他的不說,至少面試的時候還能和麵試官吹下是不,況且這幾種寫法也不是純弔書袋,瞭解過後還是能幫助我們理解其設計思想滴。所以接下來我們們由淺入深,從最容易的寫法開始,一步一步的帶大家掌握單例模式!

寫法介紹

餓漢式

話不多說,先直接上最簡單的寫法,然後我們再慢慢剖析:

public class Signleton01 {
    // 私有建構函式,防止別人例項化
    private Signleton01(){}
	// 靜態屬性,指向一個例項化物件
    private static final Signleton01 INSTANCE = new Signleton01();
	// 公共方法,以便別人獲取到例項化物件屬性
    public static Signleton01 getINSTANCE() {
        return INSTANCE;
    }
}

單例模式三元素

一個單例模式就這樣寫完了,簡直不要太簡單。 類裡面一共就三個元素:

  1. 私有建構函式,防止別人例項化
  2. 靜態屬性,指向一個例項化物件
  3. 公共方法,以便別人獲取到例項化物件屬性

這三個元素就是單例模式的核心,單例無論哪種寫法,都離不開這三個元素

這三個元素也很好理解,別人想要用我這個類的例項物件就只能通過我提供的getINSTANCE(),他想new也new不了第二個物件,自然而然就保證了該類只有唯一物件。我們可以做個試驗,跑100個執行緒同時獲取該類的例項物件,然後列印出物件的hashCode,看看到底是不是獲取的同一個物件:

public static void main(String[] args) {
    for (int i = 0; i < 100; i++) {
        new Thread(() -> {
            System.out.println(Signleton01.getINSTANCE().hashCode());
        }).start();
    }
}

結果如下:

...
834649078
834649078
834649078
834649078
834649078
...

嗯,全部都是同一個物件。

優缺點

優點:寫法簡單,執行緒安全

缺點:消耗資源,即使程式從沒有用到過該類物件,該類也會初始化一個物件出來

所以為了解決餓汗式的這個缺點, 我們就引出了第二種寫法,懶漢式!

懶漢式

基本寫法

public class Singleton02 {
    // 私有建構函式,防止別人例項化
    private Singleton02() {}
	// 靜態屬性,指向一個例項化物件(注意,這裡沒有例項化物件哦)
    private static Singleton02 INSTANCE;
	// 公共方法,以便別人獲取到例項化物件屬性
    public static Singleton02 getINSTANCE() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton02();
        }
        return INSTANCE;
    }

}

懶漢式的和餓汗式最大的區別是什麼呢,就是隻有在呼叫getINSTANCE的時候,才會建立例項,如果你從來沒呼叫過,那麼就不例項化物件。這個就比餓汗式更加節約資源,不過這種寫法並不是懶漢式的完善寫法,它有一個非常大的問題,就是執行緒不同步!我們可以按照之前那種方式建立100個執行緒測試一下結果:

...
1851261656
868907500
988762476
1031371881
593800070
...

可以看到這執行緒一同時拿,拿的都不是同一個物件,這完全就破壞了單例模式。因為很多執行緒在物件沒有初始化前就進入到了if (INSTANCE == null) 判斷語句塊裡,自然而然就會new出不同的物件了。要解決這個執行緒不安全問題,就得上執行緒鎖!

synchronized寫法

public class Singleton02 {

    private Singleton02() {}

    private static Singleton02 INSTANCE;
    
	// 注意,這裡靜態方法加了synchronized關鍵字
    public synchronized static Singleton02 getINSTANCE() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton02();
        }
        return INSTANCE;
    }
}

當我們在靜態方法加上synchronized關鍵字後,就可以保證這個方法在同一時間只會有一個執行緒能成功呼叫,也就順理成章的解決了執行緒不安全問題。我們還是測試一下:

...
1226880356
1226880356
1226880356
1226880356
1226880356
...

不管多少個執行緒,拿到的都是同一個物件,達到了單例的要求!

優缺點

懶漢式連基本的執行緒安全都不能保證,就不做討論了,我們這裡主要說的事synchronized寫法

優點:寫法簡單,節約資源(只有需要該物件的時候才會例項化)

缺點:耗效能

要知道每一次呼叫getINSTANCE()方法時都會上鎖,這是非常耗效能的。那麼為了解決這個好效能的問題,我們又引申出接下來的一種寫法。

雙重檢測

每一次呼叫getINSTANCE()方法都會上鎖,這是完全沒有必要的嘛,因為只有物件還沒有例項化的時候我才需要上鎖以保證執行緒安全,物件都例項化了,自然也不用擔心後續的呼叫會new出新的物件。 所以我們這個鎖,可以加在if (INSTANCE == null) 判斷語句塊裡面:

public class Singleton03 {
    private Singleton03() {}

    private static Singleton03 INSTANCE;

    public static Singleton03 getINSTANCE() {
        if (INSTANCE == null) {
            // 只有在物件還沒有例項化的時候才上鎖
            synchronized (Singleton03.class) {
                INSTANCE = new Singleton03();
            }
        }
        return INSTANCE;
    }
}

這樣就能節約一些效能,但是這樣並沒有做到執行緒安全哦! 因為很多執行緒進入到if (INSTANCE == null) 判斷語句後,雖說是因為鎖不能同時new物件了,但是如果鎖一旦釋放,那麼其他執行緒依然會執行到INSTANCE = new Singleton03()語句,從而破壞了單例。所以在synchronized程式碼塊內還要加一層判斷:

public class Singleton03 {
    private Singleton03() {}
	
    // 注意,使用雙重檢驗寫法要加上volatile關鍵字,避免指令重排(有個印象就行,這不是本文的重點)
    private static volatile Singleton03 INSTANCE;

    public static Singleton03 getINSTANCE() {
        if (INSTANCE == null) {
            // 只有在物件還沒有例項化的時候才上鎖
            synchronized (Singleton03.class) {
                // 額外加一層判斷
                if (INSTANCE == null) {
                	INSTANCE = new Singleton03();
                }
            }
        }
        return INSTANCE;
    }
}

synchronized程式碼塊外面一層判斷,裡面一層判斷,就是有名的雙重檢測(DCL)了!裡面的這一層判斷加了之後呢,第一個執行緒的鎖一旦釋放也不用擔心了,因為此時物件已經例項化,後續的執行緒也執行不了new語句,從而保證了執行緒安全!

優缺點

優點:節約資源(只有需要該物件的時候才會例項化)

缺點:寫法複雜,耗效能(還是上了鎖,還是耗效能)

雖然雙重校驗比synchronized懶漢式寫法減少了很多鎖效能消耗,但畢竟還是上了鎖,所以為了解決這個鎖效能消耗問題了,又引申出下一種寫法。

內部類

話不多說,直接上程式碼:

public class Singleton04 {
    // 老套路,將建構函式私有化
    private Singleton04() {}
	// 宣告一個內部類,內部類裡持有例項的引用
    private static class Inner {
        public static final Singleton04 INSTANCE = new Singleton04();
    }
	// 公共方法
    public static Singleton04 getINSTANCE() {
        return Inner.INSTANCE;
    }
}

這個寫法非常像餓漢式寫法,單例三元素還是那三元素,只不過多加了一個內部類,將例項引用放到內部類裡而已。為啥要這樣寫呢?因為JVM保證了內部類的執行緒安全,即一個內部類在整個程式中不會被重複載入,並且如果你沒有使用到內部類的話,是不會載入這個內部類的。這就非常巧妙的實現了執行緒安全以及節約資源的好處!

優缺點

優點:寫法簡單、節約資源(只有呼叫了getINSTANCE()方法才會載入內部類,才會例項化物件)、執行緒安全(JVM保證了內部類的執行緒安全)

缺點:會被序列化或者反射破壞單例

這個缺點可以說是吹毛求疵,因為之前所有寫法都會被序列化、反射破壞單例。雖然說是吹毛求疵,但我們們搞技術的還是得做到了解全部細節,我來演示一下怎樣破壞這個單例

通過反射破壞單例

public static void main(String[] args) throws Exception {
    // 建立100個執行緒同時訪問例項
    for (int i = 0; i < 100; i++) {
        new Thread(() -> {
            System.out.println(Singleton04.getINSTANCE().hashCode());
        }).start();
    }

    // 反射破壞單例
    Class<Singleton04> clazz = Singleton04.class;
    // 拿到無參建構函式並將其設定為可訪問,無視private
    Constructor<Singleton04> constructor = clazz.getDeclaredConstructor();
    constructor.setAccessible(true);
    // 建立物件
    Singleton04 singleton04 = constructor.newInstance();
    System.out.println("反射:" + singleton04.hashCode());
}

執行結果如下:

...
2115147268
2115147268
反射:1078694789
2115147268
2115147268
...

如果是通過正常的訪問例項方法,是完全可以做到單例的要求,但是如果用反射的形式來建立一個物件,則就破壞了單例,一個程式中就出現了多個不同的例項物件。那麼為了解決這個吹毛求疵的問題,聰明的前輩們想到了一個完美的寫法!

列舉

// 注意,這裡是列舉
public enum Singleton05 {
    // 例項
    INSTANCE;
	// 公共方法
    public static Singleton05 getINSTANCE() {
        return INSTANCE;
    }
}

哎嘿,不是說所有單例都是那三元素嗎,這裡怎麼只有兩個元素呀!這是因為列舉就沒有構造方法,自然而然就做到了私有化建構函式的效果,而且比私有化建構函式效果更好!因為都沒有建構函式了,連序列化和反射都破壞不了這種寫法的單例!!

眼見為實,我們做個試驗:

public static void main(String[] args) throws Exception {
    // 建立100個執行緒同時訪問例項
    for (int i = 0; i < 100; i++) {
        new Thread(() -> {
            System.out.println(Singleton05.getINSTANCE().hashCode());
        }).start();
    }

    // 反射破壞單例
    Class<Singleton05> clazz = Singleton05.class;
    // 拿到無參建構函式並將其設定為可訪問,無視private
    Constructor<Singleton05> constructor = clazz.getDeclaredConstructor();
    constructor.setAccessible(true);
    // 建立物件
    Singleton05 singleton05 = constructor.newInstance();
    System.out.println("反射:" + singleton05.hashCode());
}

執行結果如下:

...
422057313
422057313
422057313
422057313

Exception in thread "main" java.lang.NoSuchMethodException: Singleton05.<init>()
	at java.lang.Class.getConstructor0(Class.java:3082)
	at java.lang.Class.getDeclaredConstructor(Class.java:2178)

當執行到反射那一塊程式碼的時候,程式直接報錯,原因就是我之前所說的一樣,列舉沒有構造方法,你自然就無法通過反射來建立物件了!

優缺點

此方法乃是最完美的方法,真是佩服想出這種寫法的前輩!

總結

五個寫法全部介紹完畢,每個寫法都有其特點,根據自己的需求來寫就好了!每種寫法理解其特點後,寫出來也就非常輕鬆。就像我一開始說的一樣,理解這五種寫法也不是弔書袋,每一種寫法都有其背後的思考,有些寫法思路真的讓人歎服,至少我瞭解到內部類和列舉寫法的時候我心裡就是:我靠!這都能想出來,太牛逼了吧......

好的程式碼就是藝術作品,希望我們都能碼出好的藝術出來!

相關文章