1. 程式人生 > >多執行緒下單例模式:懶載入(延遲載入)和即時載入

多執行緒下單例模式:懶載入(延遲載入)和即時載入

前言

在開發中,如果某個例項的建立需要消耗很多系統資源,那麼我們通常會使用惰性載入機制,也就是說只有當使用到這個例項的時候才會建立這個例項,這個好處在單例模式中得到了廣泛應用。這個機制在single-threaded環境下的實現非常簡單,然而在multi-threaded環境下卻存在隱患。本文重點介紹惰性載入機制以及其在多執行緒環境下的使用方法。(作者numberzero,參考IBM文章《Double-checked locking and the Singleton pattern》,歡迎轉載與討論)

單例模式的惰性載入

通常當我們設計一個單例類的時候,會在類的內部構造這個類(通過建構函式,或者在定義處直接建立),並對外提供一個static getInstance方法提供獲取該單例物件的途徑。例如:

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

    private Singleton() {
        // ...
    }

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

這樣的程式碼缺點是:第一次載入類的時候會連帶著建立Singleton例項,這樣的結果與我們所期望的不同,因為建立例項的時候可能並不是我們需要這個例項的時候。同時如果這個Singleton例項的建立非常消耗系統資源,而應用始終都沒有使用Singleton例項,那麼建立Singleton消耗的系統資源就被白白浪費了。

為了避免這種情況,我們通常使用惰性載入的機制,也就是在使用的時候才去建立。以上程式碼的惰性載入程式碼如下:

public class Singleton {
    private static Singleton instance = null;

    private Singleton() {
        // ...
    }

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

惰性載入在多執行緒中的問題

先將惰性載入的程式碼提取出來:

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

這時如果兩個執行緒A和B同時執行了該方法,然後以如下方式執行:

  1. A進入if判斷,此時foo為null,因此進入if內
  2. B進入if判斷,此時A還沒有建立foo,因此foo也為null,因此B也進入if內
  3. A建立了一個Foo並返回
  4. B也建立了一個Foo並返回

此時問題出現了,我們的單例被建立了兩次,而這並不是我們所期望的。

各種解決方案及其存在的問題

使用Class鎖機制

以上問題最直觀的解決辦法就是給getInstance方法加上一個synchronize字首,這樣每次只允許一個現成呼叫getInstance方法:

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

這種解決辦法的確可以防止錯誤的出現,但是它卻很影響效能:每次呼叫getInstance方法的時候都必須獲得Singleton的鎖,而實際上,當單例例項被建立以後,其後的請求沒有必要再使用互斥機制了

double-checked locking

double-checked locking

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

讓我們來看一下這個程式碼是如何工作的:首先當一個執行緒發出請求後,會先檢查instance是否為null,如果不是則直接返回其內容,這樣避免了進入synchronized塊所需要花費的資源。其次,即使第2節提到的情況發生了,兩個執行緒同時進入了第一個if判斷,那麼他們也必須按照順序執行synchronized塊中的程式碼,第一個進入程式碼塊的執行緒會建立一個新的Singleton例項,而後續的執行緒則因為無法通過if判斷,而不會建立多餘的例項。

上述描述似乎已經解決了我們面臨的所有問題,但實際上,從JVM的角度講,這些程式碼仍然可能發生錯誤。

對於JVM而言,它執行的是一個個Java指令。在Java指令中建立物件和賦值操作是分開進行的,也就是說instance = new Singleton();語句是分兩步執行的。但是JVM並不保證這兩個操作的先後順序,也就是說有可能JVM會為新的Singleton例項分配空間,然後直接賦值給instance成員,然後再去初始化這個Singleton例項。這樣就使出錯成為了可能,我們仍然以A、B兩個執行緒為例:

  1. A、B執行緒同時進入了第一個if判斷
  2. A首先進入synchronized塊,由於instance為null,所以它執行instance = new Singleton();
  3. 由於JVM內部的優化機制,JVM先畫出了一些分配給Singleton例項的空白記憶體,並賦值給instance成員(注意此時JVM沒有開始初始化這個例項),然後A離開了synchronized塊。
  4. B進入synchronized塊,由於instance此時不是null,因此它馬上離開了synchronized塊並將結果返回給呼叫該方法的程式。
  5. 此時B執行緒打算使用Singleton例項,卻發現它沒有被初始化,於是錯誤發生了。

通過內部類實現多執行緒環境中的單例模式

為了實現慢載入,並且不希望每次呼叫getInstance時都必須互斥執行,最好並且最方便的解決辦法如下:

public class Singleton {
    private Singleton() {
        // …
    }

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

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

JVM內部的機制能夠保證當一個類被載入的時候,這個類的載入過程是執行緒互斥的。這樣當我們第一次呼叫getInstance的時候,JVM能夠幫我們保證instance只被建立一次,並且會保證把賦值給instance的記憶體初始化完畢,這樣我們就不用擔心3.2中的問題。此外該方法也只會在第一次呼叫的時候使用互斥機制,這樣就解決了3.1中的低效問題。最後instance是在第一次載入SingletonContainer類時被建立的,而SingletonContainer類則在呼叫getInstance方法的時候才會被載入,因此也實現了惰性載入。