天天看點

寂然解讀設計模式 - 單例模式(中)

I walk very slowly, but I never walk backwards            

設計模式 - 單例模式(中)

​ 寂然

大家好~,我是寂然,本節課呢,我們把重點放在單例模式的實作方式 - 雙重檢查機制,他的寫法分析,可能存在的問題和解決方案,同時會對volatile,線程切換相關的知識進行擴充,達到融會貫通,那我們啟程吧

雙重檢查機制

上一節我們聊到,第五種寫法懶漢式同步代碼塊的時候,并沒有保證線程安全,是以在裡面建立執行個體對象的時候,進行同步沒有實際意義,是以實際開發中不能使用上述方式,那我們來看第六種,雙重檢查機制,示例代碼如下,我們先驗證其正确性,然後對該寫法進行解析

//單例 雙重檢查機制
class Singleton{

    private Singleton(){

    }

    //後續還要加volatile關鍵字
    private static Singleton singleton;

    //提供一個靜态的公共方法擷取執行個體,加入雙重檢查
    //解決線程安全問題,同時解決懶加載的問題
    //注意,同步的效率很低
    public static Singleton getInstance(){

        if (singleton == null){

            synchronized (Singleton.class){
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

public class DoubleCheckDemo {

    public static void main(String[] args) {

        Singleton instance = Singleton.getInstance();
        Singleton instance1 = Singleton.getInstance();
        System.out.println(instance == instance1);
    }
}           

寫法分析

Double-Check 概念是多線程開發中常使用到的,如代碼中所示,我們進行了兩次 if (singleton == null){ ... }檢查,同時 ,在實際開發中,也推薦使用這種單例設計模式 ,因為有如下三個優點

一,線程安全

假設現在有A,B兩個線程,同時進入外層 if (singleton == null){ ... }的檢查,裡面我們進行了加鎖處理,假設A線程拿到鎖,執行代碼,建立執行個體對象,結束後B線程拿到鎖進來,此時執行個體已經被建立,是以直接 return 執行個體化對象,加鎖後進行判斷,解決了線程安全問題

二,延遲加載

起到了延遲加載的效果,不會造成記憶體浪費,執行個體需要使用到的時候,調用getInstance()方法才會建立

三,效率較高

同步的效率很低,我們不同步方法,當判斷外層if (singleton == null){ ... }為空時才會加鎖,這樣的話,執行個體化代碼執行一次後,後續直接return,避免同步方法後,同時隻能有一個線程進入方法效率太低的問題

為什麼要雙重檢查?

這裡有的小夥伴要問了,為什麼要雙重檢查?去掉外面的一層,不是同樣可以解決線程安全問題嘛?

是的,可以解決線程安全問題,但是我們外層加上判斷,如果不為空,就不需要加鎖,直接return,可以大幅度提升效率,這樣,執行個體化代碼隻用執行一次

可能出現的問題

上述雙重檢查的寫法,線程安全、符合延遲加載,效率較高,我們說實際開發中推薦使用這種方式, 這是OK的,但是,在new Singleton()的操作中卻可能帶來空指針的異常問題,下面我們着重來聊下

我們認為的 new Singleton() 操作

1)配置設定記憶體位址 M

2)在記憶體 M 上初始化Singleton 對象

3)将M的位址指派給 instance 對象

JVM編譯優化後可能的 new Singleton() 操作

2)将M的位址指派給instance變量

3)在記憶體M上初始化 Singleton 對象

異常發生過程

如下圖,JVM建立new Instance()對象時先指派再初始化

  • 線程A先執行getInstance()方法,當線程A在執行完變量的記憶體位址指派(尚未初始化)時,發生線程切換,線程B獲得CPU的執行權
  • 線程B在執行第一個判斷,發現 instance == null條件不成立,直接傳回instance,但此時instance并沒有初始化,此時通路instance對象的成員變量就可能發生空指針異常
寂然解讀設計模式 - 單例模式(中)

解決方式

上述問題出現的本質原因是(線程切換帶來的原子性問題),JVM在編譯時的指令重排序造成的,是以隻要禁止指令重排序,就可以解決這個問題,是以需要在 Singleton 對象的成員變量 singleton 前加 volatile 關鍵字

private volatile static Singleton singleton;           

擴充 - Volatile

volatile是Java虛拟機提供的輕量級同步機制,輕量級可以了解為低配版,因為沒有保證原子性

volatile有三大特性,保證可見性,不保證原子性,禁止指令重排

保證可見性

例 其中一個線程修改了主記憶體變量值,并寫回主程式,及時通知其他線程

各個線程對主記憶體共享變量的操作都會拷貝到自己的工作記憶體去操作,一個線程同理在自己工作記憶體中修改了共享變量的值,還未寫回主記憶體,另一個線程也要修改同一個變量,但是此時它對上一個線程正在修改不知情,即A線程中共享變量的值對B線程不可見,這種工作記憶體與主記憶體同步延遲現象即可見性問題

不保證原子性

即不可分割性,完整性,即某個線程執行某個具體任務時,中間不可以被加塞或者分割,要整體完整,即整體要麼同時成功,要麼同時失敗

禁止指令重排

volatile能夠實作禁止指令重排,避免在多線程環境下出現亂序執行的情況,和底層記憶體屏障有關

指令重排

計算機在執行程式時為了提高性能,底層編譯器和處理器,記憶體都會對指令進行重排

單線程裡面確定程式最終執行結果,會與程式順序執行結果一緻

底層編譯器的處理器在指令重排時,一定會考慮指令間的資料依賴性

多線程環境中線程交替執行,由于編譯器優化重排的存在,兩個線程中使用的變量能否保持一緻是無法确定的

擴充-線程切換

作業系統允許某個程序執行一小段時間,如50ms,過了50ms作業系統會重新選擇一個程序來執行(任務切換),這個50ms稱為時間片,Java并發是基于多線程的,大多數的并發bug都是由于線程切換造成的

Java的一條語句對應的cpu指令可能是多條,其中任意一條cpu指令在執行完都可能發生線程切換

如:count += 1,對應cpu 指令如下:

1)将變量count從記憶體加載到cpu寄存器

2)寄存器中 +1

3)将結果寫入記憶體(緩存機制寫入的可能是cpu而不是記憶體)

大家可以參考如下示例圖,加深了解

寂然解讀設計模式 - 單例模式(中)

下節預告

OK,由于篇幅的限制,本節内容就先到這裡,下一節,我們接着來聊單例模式的後兩種寫法,包括靜态内部類,枚舉,同時會帶大家閱讀JDK源碼中單例模式的應用,以及對單例模式的注意事項進行總結,最後,希望大家在學習的過程中,能夠感覺到設計模式的有趣之處,高效而愉快的學習,那我們下期見~