引言
在開發中,如果某個實例的創建需要消耗很多系統資源,那么我們通常會使用惰性加載機制(或懶加載、延時加載),也就是說只有當使用到這個實例的時候才會創建這個實例,這個好處在單例模式中得到了廣泛應用。這個機制在單線程環境下的實現非常簡單,然而在多線程環境下卻存在隱患。
1、單例模式的惰性加載
通常當我們設計一個單例類的時候,會在類的內部構造這個類(通過構造函數,或者在定義處直接創建),並對外提供一個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;
}
}
2、惰性加載在多線程中的問題
先將惰性加載的代碼提取出來:
public static Singleton getInstance(){
if (instance == null)
instance = new Singleton();
return instance;
}
這是如果兩個線程 A 和 B 同時執行了該方法,然后以如下方式執行:
- A 進入 if 判斷,此時 foo 為 null,因此進入 if 內
- B 進入 if 判斷,此時 A 還沒有創建 foo,因此 foo 也為 null,因此 B 也進入 if 內
- A 創建了一個 Foo 並返回
- B 也創建了一個 Foo 並返回
此時問題出現了,我們的單例被創建了兩次,而這並不是我們所期望的。
3. 各種解決方案及其存在的問題
3.1 使用 Class 鎖機制
以上問題最直觀的解決辦法就是給 getInstance 方法加上一個 synchronize 前綴,這樣每次只允許一個現成調用 getInstance 方法:
public static synchronized Singleton getInstance(){
if (instance == null)
instance = new Singleton();
return instance;
}
這種解決辦法的確可以防止錯誤的出現,但是它卻很影響性能:每次調用 getInstance 方法的時候都必須獲得 Singleton 的鎖,而實際上,當單例實例被創建以后,其后的請求沒有必要再使用互斥機制了
3.2 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兩個線程為例:
- A、B線程同時進入了第一個if判斷
- A首先進入synchronized塊,由於instance為null,所以它執行instance = new Singleton();
- 由於JVM內部的優化機制,JVM先畫出了一些分配給Singleton實例的空白內存,並賦值給instance成員(注意此時JVM沒有開始初始化這個實例),然后A離開了synchronized塊。
- B進入synchronized塊,由於instance此時不是null,因此它馬上離開了synchronized塊並將結果返回給調用該方法的程序。
- 此時B線程打算使用Singleton實例,卻發現它沒有被初始化,於是錯誤發生了。
4. 通過內部類實現多線程環境中的單例模式
為了實現慢加載,並且不希望每次調用 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 方法的時候才會被加載,因此也實現了惰性加載。
