Then Notes 隨筆

Singleton 設計模式:同步處理、雙重鎖定、靜態初始化

在軟體開發中,常常會遇到需要確保某個類別(Class)只有一個執行個體(instance)存在的情況。這時候,就可以使用「單一執行個體模式」(Singleton Pattern)來實現這個需求。單一執行個體模式保證了在整個應用程式中,只有一個 Class 的執行個體存在,並提供一個全域的存取媒介供外部存取這個唯一的執行個體。在這篇文章中,我們將會使用 Java 作為示範來介紹幾種實作單一執行個體模式的方式,並探討它們的優缺點。

基本 Singleton 模式

這是一個簡單又直觀的 Singleton 範例:

class Singleton {
    private static Singleton instance;

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

如果在程式中不會使用到多執行緒,那以上這樣的寫法並沒有太大的問題。我們透過向外部提供一個可用來取得 instance 的方法 getInstance(),並用 if (instance == null) 判斷存不存在來阻止重複建立 instance 的可能,只有為 null 的時候才會 new Singleton(),否則會直接傳回 instance。

但在多執行緒程式的情況下就不一樣了。當多個執行緒同時進入 getInstance() 且 instance 為 null 時,它們都會進入條件判斷的區塊。

  1. 執行緒 A 在進入條件判斷後,暫停執行,然後執行緒 B 也進入條件判斷。
  2. 執行緒 B 在進入條件判斷後,同樣認為 instance 為 null,因此建立了一個新的 Singleton 物件並將其指派給 instance。
  3. 執行緒 B 結束後,執行緒 A 恢復執行。由於執行緒 A 被暫停時並不知道 instance 已經被執行緒 B 建立,它將建立另一個 Singleton 物件並將其指派給 instance。

結果是,我們最終有兩個 Singleton 物件,這違反了 Singleton 設計模式的目標。

同步處理 Synchronization

為了解決這個問題,可以使用同步處理(synchronization)來確保在同一時間只有一個執行緒可以進入 getInstance()。以下是修改後的程式碼範例:

class Singleton {
    private static Singleton instance;

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

這樣做可以確保當一個執行緒進入 getInstance() 時,其他執行緒必須等待,直到該執行緒離開為止。這樣可以確保只有一個 Singleton 物件被建立。

儘管這種修改可以解決多執行緒環境下的競爭問題,但它會對效能造成一些影響。每次呼叫 getInstance() 時都需要進行同步化,這可能會在高併發的情況下導致效能瓶頸。因此,如果應用程式在高併發環境中執行且 Singleton 的建立成本相對較低,則可以考慮使用「雙重檢查鎖定」(Double-Checked Locking)等技巧。

雙重檢查鎖定 Double-Checked Locking

我們可以不必讓它每次都鎖起來,只要在 instance 還沒被建立的時候再鎖就好了。

class Singleton {
    private volatile static Singleton instance;

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

為什麼內層還要再判斷一次 instance 是不是 null 呢?因為可能發生以下情況:

  1. 有兩個執行緒「同時」呼叫 getInstance() 想要建立 instance。
  2. 既然是同時,他們都可以通過第一層 if 條件的檢查。
  3. 因為我們有上鎖,假設執行緒 A 進入,執行緒 B 在排隊。
  4. 執行緒 A 建立了 instance 後離開,但是執行緒 B 完全不知情所以又建立了一個 instance。

此時如果有第二道 if 條件判斷式,就不會發生執行緒 B 還繼續再建立 instance 的狀況了。

然後還有一點值得注意的是 volatile。如果在 instance 變數宣告時不使用 volatile,則可能發生以下情況:

  1. 當一個執行緒 A 建立 Singleton 物件時,其他執行緒可能無法立即看到 instance 的變更。
  2. 其他執行緒 B 可能會繼續進入 getInstance(),並在 instance 仍然為 null 的情況下誤以為 Singleton 物件尚未建立。
  3. 執行緒 B 可能會取得一個未完全初始化的 Singleton 物件,這可能導致未定義的行為或錯誤。

這種未定義的行為稱為「指令重排」(Instruction Reordering),即在多執行緒環境中,編譯器或處理器可能會重新排序指令以提高效能。這可能導致 instance 的初始化在其他操作之前發生。

靜態初始化

class Singleton {
    private static Singleton instance = new Singleton();

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

因為在 Class 載入時就會立即建立 Singleton 物件並將其指派給 instance。這種方式稱為「餓漢式」(Eager Initialization),它確保在任何執行緒存取 getInstance() 之前,已經有一個唯一的 Singleton 物件存在。由於 instance 是在靜態初始化階段建立的,因此在多執行緒環境中不需要額外的同步化處理。

然而,值得注意的是,這種餓漢式的實作方式可能會在 class 載入時就建立 Singleton 物件而提前佔用系統資源,而不論是否真的需要它。這可能會導致不必要的資源浪費,尤其在 Singleton 物件的建立成本較高時。因此,這種實作方式在某些情況下可能不是最佳選擇。

不過,在大多數情況下,使用餓漢型的 Singleton 模式就夠用了,等到真正有效能考慮時再改寫吧!

完整範例

// Test.java

public class Test {
    public static void main(String[] args) {

        Singleton instance1 = Singleton.getInstance();
        Singleton instance2 = Singleton.getInstance();

        if (instance1 == instance2) {
            System.out.println("兩個執行個體相同");
        } else {
            System.out.println("兩個執行個體不同");
        }
    }
}

class Singleton {
    private static Singleton instance = new Singleton();

    private Singleton() {
        // Write your code here
    }

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

編譯並執行

$ javac Test.java && java Test