雙重檢查鎖定與單例模式真的線程安全嗎?

加載,表示啓動程序的文件或信息的載入。在Java中類的字節碼文件加載進內存,就是以io流的形式存入內存。

User user=new User("男"18);

該語句做了幾件事:

  • 因爲new用到了User.class,所以找到User.class文件並加載到內存中
  • 執行該類的static代碼塊,如果有的話,給User.class類進行初始化
  • 在堆內存中開闢空間,分配內存地址。
  • 在堆內存中建立對象的特有屬性,並進行默認初始化。
  • 對屬性進行顯式初始化
  • 對對象進行構造代碼塊初始化
  • 對對象進行對應的構造函數初始化
  • 將內存地址賦給棧內存中的p變量

特別注意,步驟7和步驟8,先後發生順序是隨機的

懶加載

餓漢式單例
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;
    }
}

線程安全問題

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

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

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

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

使用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兩個線程爲例:

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

對於上述的問題,我們可以通過把instance聲明爲volatile型來解決,但是必須在JDK5版本以上使用。

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

通過內部類實現多線程環境中的單例模式

爲了實現慢加載,並且不希望每次調用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的內存初始化完畢,這樣我們就不用擔心雙重檢查鎖定中的問題。此外該方法也只會在第一次調用的時候使用互斥機制,這樣就解決了使用Class鎖機制中的低效問題。

最後instance是在第一次加載SingletonContainer類時被創建的,而SingletonContainer類則在調用getInstance方法的時候纔會被加載,因此也實現了惰性加載。

如果文章的內容對你有幫助,歡迎關注公衆號:優享JAVA(ID:YouXiangJAVA),那裏有更多的技術乾貨,並精心準備了一份程序員書單。期待你的到來!

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