天天看點

淺學設計模式之單例模式(13/23)附帶volatile修飾的詳解

由于單例模式用的多,對我們來說可能會很簡單,是以這篇會省去很多篇幅。

1. 單例模式的概念

單例模式(Singleton),保證一個類僅有一個執行個體,并提供一個通路它的全局通路點

通常我們可以讓一個全局變量使得一個對象被通路,但它不能防止你執行個體化多個對象。

一個最好的辦法就是,讓類自身負責儲存他的唯一執行個體。這個類可以保證沒有其他執行個體可以被建立,并且它可以提供一個通路該執行個體的方法。 這就是單例模式。

2. UML圖

淺學設計模式之單例模式(13/23)附帶volatile修飾的詳解

對于單例來說,instance和構造方法都是private,提供一個public的getInstance方法來通路這個單例。

3. 代碼示例

單例模式的核心是 私有的構造方法,和靜态公有的getter方法,單例模式的代碼在這個基礎上演化出多種版本。

比如“餓漢式”、“懶漢式”,名字是第一批研究單例模式寫法的人起的,其差別就隻是執行個體在什麼時候被構造出來而已。

還有線程安全版本的DCL單例寫法。

下面分别看看他們的寫法。

3.1 餓漢式

餓漢式突出一個餓,即你要馬上給對方提供一個執行個體。在對方想要的時候立馬return給他,是以寫法是這樣的:

// 單例類
public class Singleton {
    private static Singleton instance = new Singleton();  // 靜态變量在類加載時直接初始化。

    //構造函數是private
    private Singleton() {
    }

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

上述單例類在 靜态變量instance在Singleton類加載時直接初始化。

當用戶端想使用instance時,調用 ​​

​getInstance()​

​就能直接拿到instance執行個體。

這樣做的優點是:

  • 在擷取的時候能馬上拿到instance執行個體,速度快,這就是空間換時間。
  • 線程安全,這是因為程式運作後,每個類隻會加載一次,是以餓漢式的單例隻要被加載,其instance在單個程序的執行個體永遠隻有一個。在不同線程來搶奪時,他們搶的instance永遠是同一個instance,是以不會出現instance會被執行個體化多次

缺點是:

  • 如果不用的話,可能會出現資源的浪費。(雖然完全不用的話,類是不會被加載的)

    舉個例子:你沒有用到getInstance方法,但是你通過了​​

    ​class.forname()​

    ​反射的方式調用了它的某個方法,或者執行了這個單例類其他的靜态方法,就會觸發類被加載。如果之後不用到getInstance(),那麼instance的初始化就是沒有意義的,而且因為instance是靜态變量的關系,如果不手動置null,它會存活很久(在一般情況下,程序沒死掉,GC就不會回收靜态變量)。

為了解決餓漢式的缺點,懶漢式就誕生了。接下來看看懶漢式。

PS:其實我認為餓漢式瑕不掩瑜,我們平時開發的時候如果不用Singleton,我們幹嘛會通過那些方式去讓它加載呢?

3.2 懶漢式

即懶加載,在第一次用到的時候,才去初始化這個instance執行個體。

public class Singleton {
    private static Singleton instance;

    //構造函數是private
    private Singleton() {
    }

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

    public static Singleton getInstance() {
        if(instance == null) {  //在使用的時候去加載
            instance = new Singleton();  
        }
        return instance;
    }
}      

可以看到,在類加載的時候不會去初始化instance。在 ​

​getInstance()​

​的時候,會判斷instance是否會null,如果為null,去調用其構造方法初始化。

從上可以看出懶漢式的優點:

  • 将靜态執行個體的初始化提後,這樣可以保證如果不用到 getInstace()方法,靜态執行個體是不會初始化,是以不會造成資源浪費。避免了餓漢式的缺點。

缺點:

  • 時間換空間,(但是我認為空間換時間,時間換空間的做法并無優劣之分,他們都是在特定場合使用的,都是非常好的政策)
  • 多線程不安全

先不考慮多線程的問題。

在單線程的情況下,懶漢式的寫法就是業界通用的、推薦的寫法,單線程下,這種寫法沒有沒有任何缺點。

在來考慮多線程的情況, 為什麼會産生線程不安全的問題呢?

我們把getInstance()分解一下 :

public static Singleton getInstance() {
        if(instance == null) {   // 1 if語句
            // 2 進入if語句
            instance = new Singleton(); // 3 new出執行個體  
        }
        return instance; 
    }      

我把getInstance方法分成了上面的三步。接下來模拟一下 A、B兩個線程調用 getInstance()的場景,Cpu時間片先A後B:

開始:類加載,instance為null

A線程:執行1,發現instance為null,進入if語句内,即到2, 在準備執行3的時候------------Cpu切片,線程切換

B線程:執行1,發現instance為null,進入if語句内,即到2,執行3,成功new出instance -----------Cpu切換,線程切換

A線程:因為JVM程式計數器存放剛剛執行代碼的語句,是以這個時候繼續執行3

這個時候就會問題-------A、B線程都建立了執行個體。這就不符合單例模式的定義。

出現這個問題的本質是 我們是永遠不可能知道Cpu什麼時候切換線程,這是作業系統決定的。

是以為了解決懶漢式在多線程下可能會被執行個體多次的問題,大佬們寫出了線程安全的版本

3.3 線程安全式

在Java中,​

​synchronized​

​​、​

​lock()​

​​/​

​unlock()​

​都可以用來加鎖。因為JDK1.7後,JVM對synchronized優化了很多,比起 lock/unlock開發者自己去找機會開鎖和解鎖。是以更推崇使用 synchronized關鍵字。

是以就有了這麼一個線程安全的寫法:

public synchronized static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
// 等價于:
    public static Singleton getInstance() {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
        return instance;
    }      

直接對方法進行修飾。這樣的做法一定能保證instance隻會被初始化一次。

但是這樣的做法挺暴力的。因為直接給方法加鎖或者方法的所有代碼加鎖,這是因為加鎖是重量級操作。

如果一個instance已經被執行個體化了,那麼别的線程都要通過加鎖才能拿到它,這沒有必要。

對比 ​

​HashTable​

​​和​

​ConcurrentHashMap​

​,我們知道,我們可以在關鍵代碼再加鎖。

這個時候,就有了最經典的優化版本----DCL寫法。

3.4 DCL雙重檢驗鎖

如果instance已經被執行個體化了,那麼我們的目标是不用擷取鎖就能拿到它。如果沒有别執行個體化,我們就要求第一個線程對它進行執行個體化。

它的代碼剛開始看是這樣的:

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

這樣寫确實能保證如果instance被執行個體化,線程進來能直接拿到instance,但是這樣的寫法有個問題,我依舊把代碼拆成上面3個部分,有這麼一個場景:

開始:類被初始化,instance為null

線程A:進入到1,發現instance為null,進入2,在準備執行3的時候------------Cpu切片,線程切換

線程B:進入到1,發現instance為null,進入2,執行3成功,拿到鎖,通過new成功對instance執行個體化---------------Cpu切片,線程切換

線程A:執行3,又執行了一次 instance的執行個體化。

這就出現和懶漢式類似的場景,為了解決這個問題,大佬們研究出了 DCL寫法。

DCL的全稱是​​

​Double-Check Locking(雙重檢驗鎖定)​

​​。雙重檢驗就是檢驗兩次instance是否為null,在上面的基礎上又加了一次​

​if(instance == null)​

​:

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

可以看到,注釋1,線程在拿到鎖喉還要檢查一遍instance是否為null,如果為null才去執行個體化。

這就解決了上面的線程不安全的問題。

DCL寫法是業界推薦的多線程下的單例模式寫法。它在多線程下幾乎沒有任何缺點。

但是如果我們的單線程的操作,就沒有必要用到這種寫法,會消耗很多性能。

3.4 volatile修飾instance執行個體

首先先說下 volatle,它是Java保護線程安全的最輕量的機制。它有兩個作用:

  1. 可以保證修飾變量在操作的時候的原子性,但是必須要滿足下面兩個條件

    ①:這個操作的運算結果不依賴目前的值 (最直覺的就是直接指派,例如,x = 5, user = otherUser)

    ②:修飾的變量不需要與其他的狀态變量共同參與不變限制 (就是變量不能被其他變量影響)

  2. 禁止修飾的變量在JVM指令進行優化重排。

針對于第二點說一下:

真正執行代碼的地方是作業系統,我們的Java代碼會經過 JVM -> 作業系統,在作業系統形成機器碼後一條一條的讀。

JVM有個特别強大的地方,它可以進行 指令優化,這是針對機器級的優化,即Java位元組碼在進入JVM後,先會解釋成一條一條的JVM指令碼,接着對這些指令碼重新排序,排序在不擾亂程式正常運作的前提下,打亂指令的排序,然後給機器碼執行。

看到這裡可能會有覺得很神奇,為什麼打亂了程式的指令,程式還能正常運作?舉個例子,指令​

​int a = 0, int b = 1​

​​,

JVM可能會優化成 ​​

​int b = 0, int a = 0​

​​,JVM會認為這樣執行會快一點或者對存儲好一點。這個已經牽扯到彙編的知識了。

再講的本質一點,指令重排序是指CPU采用了允許将多條指令不按程式規定的順序分開發送給各相應電路單元處理。但并不是說,指令任意排序,Cpu需要能夠正确處理指令依賴情況以保障程式能得出正确的執行結果。

指令重排序會導緻下面兩點:

  • ①:是以對于單線程來說,就算指令重排,單線程下看到的現象,所有的操作也都是有序的。(此處的有序是指語義有序,不是指令順序有序)
  • ②:而對于多線程來說,因為指令重排,B線程看A線程的操作是無序的,根本原因是B線程不知道A線程的語義。

上面兩點展現的是Java線程三個特性:原子性、有序性、可見性中的有序性。

這裡我們先不講volatile怎麼去禁止指令重排序的,我們先看看在不加volatile的情況下,上面的DCL會産生什麼問題:

由于我們已經深入到了JVM執行碼的層級,是以我們就要以這個層級做的事來看待這個問題,就是 ​​

​instance = new Singleton()​

​這句,在JVM執行的時候,它這句要拆成三個部分,它的僞代碼是這樣的:

// 正常順序下
A線程拿到鎖
if(instance == null) {
    malloc(tmp);  //配置設定空間
    init(tmp);    //初始化
    instance = tmp;  //指派
}      

由于可能會發生指令重排,就會出現這麼一個情況:

// 重新排序
A線程拿到鎖
if(instance == null) {
    malloc(tmp);  //配置設定空間
    instance = tmp;  //1 指派 
    init(tmp);    //初始化
}      

我們假定這就是指令優化後的結果(現實也會出現這樣的情況)。

這時候開始走我們的場景,從​​

​getInstance()​

​​方法開始:

A線程:執行​​

​if(instance == null)​

​​ 因為instance為null,是以往if語句走

A線程:執行 ​​

​synchronized(Singleton.class)​

​​,發現可以拿到鎖,拿到鎖後,往鎖裡方法走

A線程:執行 ​​

​if(instance==null)​

​​,第二次檢查,發現instance還是空的, 是以往if語句裡面走

A線程:執行 ​​

​malloc(tmp)​

​​, 執行 ​

​instance=tmp​

​​,當要執行​

​init(tmp)​

​​時----------CPU切片,線程切換

B線程:執行​​

​if(instance == null)​

​​,诶!這個時候發現instance不為null!是以return 這個instance。

B線程:拿到instance執行方法,結果報 ​​

​NullPointExpection​

​,instance為空。

對B線程來說,就已經遭重了… 這就是指令重排的潛在危險。

而volatile為什麼能夠解決這個問題呢?我們不妨不上面的例子,A和B的執行順序再來寫一遍,這次我們寫在一起,變成真正的“機器指令碼”,當然這裡寫的是僞代碼:

機器碼執行順序:
....
MALLOC(tmp);   // A線程執行
instance=tmp;   //A線程執行
if instance==null  // B線程執行 
instance != null //B線程執行
return instance  //B線程執行
NULLPOINTEXPECTION //B線程報錯
init(tmp);    //A線程執行      

這就是事故發生的現場,而volatile如果修飾了instance,它的執行順序就變成這樣了:

機器碼執行順序:
....
----------------------
//這裡是關于A線程對instance的操作
MALLOC(tmp);   // A線程執行
instance=tmp;   //A線程執行
init(tmp);    //A線程執行
lock         // volatile發揮作用
-----------------------
if instance==null  // B線程執行 
return instance  //B線程執行      

我們看到,對于A線程來操作 instance時,最後會立馬加一條 ​

​lock​

​​,lock是一條指令。

lock指令的作用:CPU将修改好的instance資料(在Cache中)寫入到主記憶體,别的CPU的Cache中的這個instance資料直接無效。lock後面的指令不能排序到lock前面,lock就像一個屏障一樣(Memory Barrier)。

是以對B線程來說,發生了lock,就意味着之前所有的操作都已經執行完成,這樣便形成了“指令重排序無法越過記憶體屏障”的效果。

線程B,我們假定認為是它别的Cpu上工作,那麼它去看instance時,因為Cache中的資料已經無效,那麼他就有必要再去主存中拿一次instance的值,這時的instance一定是正确的值。

這也說明了volatile是怎麼保證資料的可見性的。

最後根據:

淺學設計模式之單例模式(13/23)附帶volatile修飾的詳解

特點,我們就能确定了 ​

​instance = new Singleton()​

​是原子性操作。

是以 instance如果是null就一定是null,如果不是null就一定不是null。

最終版的DCL代碼如下:

public class Singleton {
    private volatile static Singleton instance;

    private Singleton() {
    }

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

4. 總結

單例模式保證了一個類在一個程序中的執行個體僅有唯一一個。 單例模式因為Singleton類封裝了它的唯一執行個體,這樣它可以嚴格地控制客戶怎樣通路它及何時通路它,簡單的說就是對唯一執行個體的受控通路。

它在單線程中的寫法有餓漢式和懶漢式:

  • 餓漢式(預加載式)

    空間換時間方案,在類加載的時候初始化執行個體。在擷取時O(1)時間拿到執行個體。并且可以保證線程安全。

    缺點是:如果類被加載了,但是又不使用​​

    ​getInstance()​

    ​,因為instance是靜态執行個體的原因,會産生資源的浪費。
  • 懶漢式(懶加載式)

    時間換空間的方案,在第一次調用​​

    ​getInstance()​

    ​​時才去初始化執行個體。

    優點是避免資源消耗。缺點是線程不安全,多線程競争時,可能會産生多個執行個體。

    單線程下,是最推薦的寫法。

  • 在懶漢式的基礎下,在​

    ​getInstance()​

    ​中檢查兩次執行個體。第一次檢查是在沒拿到鎖的時候,第二次檢查是在拿到鎖的時候。

繼續閱讀