單例模式的 Java 實現與思考

AI-George發表於2019-07-23

本文對單例模式在 Java 裡的不同實現方式進行了分析,對比了不同方案的優缺點並給出使用結論。

單例模式的要義是控制某一個類只有一個唯一的例項,並提供一個統一的訪問點。它主要用在某些不希望有多個例項的場景,比如執行緒池。

1.基礎實現

首先來看一個單例模式的基礎實現:

public class Singleton {

    private static final Singleton instance = new Singleton();

    private Singleton() {
        System.out.println("Singleton()");
    }

    public static Singleton getInstance() {
        return instance;
    }
}

這個實現宣告瞭一個靜態變數 instance,利用了 Java 類初始化的規則(詳見下方參考資料裡的 JSL 12.4.1):

  • 一個類只有在被例項化,呼叫靜態方法和訪問靜態變數之前才會被初始化。
  • 類的初始化會執行靜態初始化塊和靜態變數的初始化語句。
  • 類的初始化過程會加鎖,可以保證只會執行一次。

因此這種方式保證了 instance 只會在類初始化的時候被建立一次,之後每次透過 getInstance 獲取到的都是同一個例項。

2.懶載入實現

有時不想要例項過早初始化,而是在真正使用到的時候才初始化,這種策略被稱為懶載入。

2.1 基礎實現
public class LazySingletonNaive {

    private static LazySingletonNaive instance;

    private LazySingletonNaive() {
        System.out.println("LazySingletonNaive()");
    }

    public static LazySingletonNaive getInstance() {
        if (instance == null) {
            instance = new LazySingletonNaive();
        }
        return instance;
    }
}

注意這裡的 getInstance 方法存在競態條件,有可能兩個執行緒分別檢查到 instance == null,然後各自執行了一次例項化操作。

而且由於指令重排的原因,還有可能造成部分初始化問題,這一點在後面 DCL 時會說到。

2.2 執行緒安全的懶載入
  • 方法1:直接對 getInstance 方法加 synchronized 鎖
public static synchronized LazySingletonNaive getInstance() {

這種方式最簡單,但會帶來同步效能開銷,僅在應用可以接受這種效能開銷時使用。

  • 方法2:Double Check Lock
public class LazySingletonDCL {

    private static volatile LazySingletonDCL instance; // 這裡必須使用 volatile 關鍵字,是為了避免部分初始化問題,下文詳述。

    private LazySingletonDCL() {
        System.out.println("LazySingletonDCL()");
    }

    public static LazySingletonDCL getInstance() {
        if (instance == null) {
            synchronized (LazySingletonNaive.class) {
                if (instance == null) {
                    instance = new LazySingletonDCL();
                }
            }
        }
        return instance;
    }

}

getInstance 方法的邏輯是:

  1. 首先判斷 instance 是否為空,如果不為空,則說明已經初始化過了,可以直接使用,這種情況下可以不用進入同步塊,避免了絕大多數情況下的效能開銷;
  2. 如果為空,則說明需要進行初始化,進入同步塊,拿到鎖之後需要再檢查一次 instance 是否為空(Double Check 名稱的來源),如果 instance 為空,則執行初始化,否則說明其他執行緒已經初始化過了,直接返回即可。

instance 例項必須要使用 volatile 修飾,是為了避免部分初始化問題。

  • 方法3:Holder 方式
public class LazySingletonHolder {

    private static class Holder {
        static final LazySingletonHolder instance = new LazySingletonHolder();
    }

    private LazySingletonHolder() {
        System.out.println("LazySingletonHolder()");
    }

    public static LazySingletonHolder getInstance() {
        return Holder.instance;
    }

}

這裡新建立了一個靜態私有內部類 Holder,它的唯一目的就是儲存一個靜態的 instance 例項。然後這個類只有在 getInstance 方法呼叫時才會初始化,從而把 instance 例項初始化,實現了懶載入。

這種方式也比較簡單,同時避免了同步的效能開銷,推薦使用。

問:同樣是利用類的靜態變數,為什麼說 Singleton 和 LazySingletonHolder 一個沒有懶載入,一個實現了懶載入呢?

答:如果按照上面貼出來的程式碼,Singleton 類的初始化時機只有一個,那就是 getInstance 方法呼叫時,那它其實就是懶載入。但是實際中,這個類裡可能不止 getInstance 一個公開方法,如果還暴露了其他的公開方法或者變數,在訪問那些方法或變數時,也會觸發類的初始化,就會造成還沒呼叫 getInstance 就初始化了例項的情況,所以說它沒有實現懶載入。 而 LazySingletonHolder 類裡的 Holder 由於被限制為了私有,且靜態變數的唯一訪問時機就是 getInstance 方法,因此可以實現懶載入。 所以這兩個類的核心區別在於 instance 的初始化時機,Singleton 沒有做嚴格管控,有可能會被提前初始化,LazySingletonHolder 做了嚴格管控,從而實現了懶載入。

結論:

基礎實現方式足夠簡單,易於理解,也沒有併發問題,如果沒有特殊的需求,建議90%的場景直接使用。\
如果應用場景確實需要懶載入,建議使用 Holder 方式。\
DCL 方式有點 tricky,可讀性不夠好,而且相比 Hold 方式沒有優勢,因此不建議使用。

那是不是 DCL 方式就沒有用處了呢?也不是,如果想要對一個例項變數做懶載入,基於靜態變數的 Holder 方式就行不通了,此時就必須使用 DCL 才行了。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章