單例模式怎麼實現?這篇文章給你講透

大家可能經常在面試中被問到單例模式的相關問題,如果面試官考察你對單例模式的理解程度,那麼大概率會被要求手寫單例模式。單例模式看起來簡單,但往深了挖,又能考察到面試者對於併發、序列化、類加載等基礎知識的掌握程度。而且單例模式有很多種寫法,大家可能想知道那種寫法更好,我也總結了幾種寫法,一併呈現給大家。

首先我們需要知道什麼是單例模式?單例模式指的是保證一個類只有一個實例,並且提供一個全局可以訪問的入口。

接下來我們看一下常見的寫法又有哪些?我認爲有這麼 5 種。餓漢式、懶漢式、雙重檢查式、靜態內部類式以及枚舉式,我們按照寫法的難易程度來逐層遞進。首先來看一看相對簡單的餓漢式的寫法具體是什麼樣的?

/**
 * 餓漢式寫法,在類裝載的時候就完成了初始化。
 */
public class Singleton {
    private static Singleton singleton = new Singleton();
    private Singleton() {} // 構造函數私有,外部類不能構造。
    public static Singleton getInstance() {
        return singleton;
    }
}

用 static 修飾我們的實例,並且把構造函數用 private 修飾,這種寫法比較簡單,在類裝載的時候就完成了實例化,避免了線程同步的問題。缺點在於類裝載的時候就完成了實例化,而沒有達到懶加載的效果,所以如果從始至終都沒有使用過這個實例,就可能會造成內存的浪費。

接下來我們來看一下第 2 種寫法,懶漢式這種寫法,在 getInstance 方法被調用的時候纔去實例化,我們的對象起到了懶加載的效果,但是隻能在單線程下使用。如果在多線程環境下一個線程進入了 if(singleton == null) 判斷語句塊還沒來得及往下執行,另一個線程也通過了判斷語句,這時就會多次創建實例,但是這裏需要注意在多線程環境下,不能使用這種方式,這是錯誤的寫法。

/**
 * 懶漢式寫法,延遲加載。
 * 有線程不安全的問題
 */
public class Singleton {
    private static Singleton singleton;
    private Singleton() {

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

爲了解決線程不安全的問題,我們需要使用 synchronized 關鍵字來保證線程安全。

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

這種寫法保證線程安全,但是也會創建多個實例。有兩個線程通過了 第一個 if 判斷語句的時候,我們繼續往下面執行。第一個線程初始化了一個實例,釋放鎖。第二個線程還會再次初始化一個實例。我們必須進行 double check(即雙重檢查模式)。

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

去掉第一個 check,效率低下,程序串行執行。
去掉第二個 check,會創建多份實例。
爲什麼要使用 volatile 關鍵字?

singleton = new Singleton(); 這句話至少做了三件事情。

  1. 給 singleton 分配內存空間。
  2. 調用構造函數來初始化 singleton。
  3. 將 singleton 對象指向分配的內存空間。(singleton 不再是 null);
    假如有兩個線程,不使用 volatile 關鍵詞時存在着重排序,我們的執行順序是 1-3-2。
    singleton = new Singleton(); 線程一執行到這個語句時剛好完成步驟 3,然而步驟 2 沒有執行。但是此時線程 2 被調度執行,進入第一個 check 的時候,發現 singleton 不再是 null,所以直接返回一個未初始化的 singleton,這樣我們使用未初始化的 singletn 就會報錯。

下面我們再來看靜態內部類的寫法:

/**
 * 靜態內部類的寫法,也能保證線程安全性,並且延遲加載。
 * 但是和 double check 一樣,不能保證被反序列化生成多個實例。
 */
public class Singleton {
    private Singleton(){}

    private static class SingletonInstance {
        private static final Singleton singleton = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonInstance.singleton;
    }
}

可以看出靜態內部類和雙重檢查這兩種寫法都是不錯的寫法,但是他們都不能防止被反序列化,生成多個實例,那麼有沒有更好的寫法呢?最後我們就來看一看枚舉方式的寫法。藉助 jdk 1.5 中添加的枚舉類來實現單例模式,這不僅能夠避免線程同步的問題,而且還能防止反序列化和反射創建新的對象來破壞單例的情況的出現。

public enum  Singleton {
    INSTANCE;
    public void whateverMethod() {
        System.out.println("執行了單例類的方法,例如返回環境變量信息。");
    }
}
// 測試類
public class Test {
    public static void main(String[] args) {
        Singleton.INSTANCE.whateverMethod();
​
    }
}

前面我們講了餓漢式、懶漢式、雙重檢查式、靜態內部類式和枚舉類這5種寫法,有了這麼多方法可以實現單例,此時你可能會問了,我應該怎麼選擇,用哪種單例去實現?

我認爲最好的方式就是利用枚舉類!Joshua Bloch 在 Effective Java一書中明確表達過一個觀點,就是使用枚舉去實現單例的方法,雖然還沒有被廣泛採用,但是單元素的枚舉類型已經成爲了實現 Singleton 的最佳方法!爲什麼他會更加推崇枚舉模式的單例,這就不得不回到枚舉寫法的優點上來說了,枚舉寫法的優點有這麼幾個,首先就是寫法簡單,枚舉的寫法不需要我們自己去考慮,懶加載、線程安全等問題,同時代碼也比較短小精悍,比其他任何的寫法都更簡潔、更優雅。

第2個優點是線程安全有保障,通過反編譯一個枚舉類,我們可以發現枚舉中的各個枚舉項是通過 static 代碼塊來定義和初始化的,他們會在類被加載時完成初始化,而 Java 類的加載由 JVM 保證線程安全,所以創建一個 Enum 類型的枚舉是線程安全的。
前面幾種實現單例的方式,其實是存在問題的,可能被反序列化破壞,反序列化生成的新的對象,從而產生了多個實例。接下來要說的枚舉類的第3個優點,它恰恰解決了這些問題。而對於序列化這件事情, Java 專門對枚舉的序列化做了規定,在序列化時僅僅是將枚舉對象的 name 屬性輸出到結果中,在反序列化時,就是通過 java.lang.Enum 的valueOf 來根據名字查找對象,而不是新建一個新的對象,所以這就防止了反序列化導致的單例破壞問題的出現。
對於通過反射破壞單例而言,枚舉類同樣有防禦措施。反射在通過newInstance 方法創建對象時,會檢查這個類是否是枚舉類,如果是的話就拋出異常,這樣的異常反射創建對象失敗。可以看出枚舉這種方式能夠防止序列化和反射破壞單例,在這一點上與其他的實現方式相比有很大的優勢。安全問題不容小視,一旦生成了多個實例,單例模式就徹底沒用了。所以結合講到的這三個優點,寫法簡單,線程安全以及防止反序列化和反射破壞單例,枚舉寫法最終勝出。
最後我來總結一下,今天我講解了單例模式是什麼?它的作用、用途以及5種經典寫法,其中包含了餓漢式、懶漢式、雙重檢查式、靜態內部類式和枚舉的方式。最後我們還經過了對比,看到了枚舉方式在寫法上線程安全以及避免序列化反射攻擊上都有優勢。這裏也跟大家強調一下,如果使用線程不安全的錯誤的寫法,在併發的情況下可能會產生多個事例,那麼不僅會影響到性能,更可能會造成數據錯誤等嚴重的後果。
如果是在面試中遇到這個問題,那麼你可以從一開始的餓漢式、懶漢式說起,一步一步的分析每一種的優缺點,並且對寫法進行演進。然後重點要講一下雙重檢查模式,爲什麼需要兩次檢查,以及爲什麼需要 volatile 關鍵字,最後再說枚舉類的寫法的優點和背後的原理,相信這一定會爲你的面試加分。另外在工作中要是遇到了全局信息類、無狀態工具類等場景的時候,推薦使用枚舉的寫法來實現單例模式。希望我的分享可以幫助到你。

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