單例模式——線程同步與線程安全分析

說起單例模式,大家都不會陌生,就拿懶漢式單例模式做介紹,最簡單的寫法如下:

public class Single {

    private static Single single;

    private Single(){

    }

    public static Single getInstance(){
        if(single==null){
            single = new Single();
        }
        return single;
    }

}

儘管單例模式一眼看上去真的很簡單,但是,任何東西與多線程掛上關係,難度就會瞬間提高n個檔次。

所以雖然本篇介紹的是單例模式,但是實際上是掛着羊頭賣狗肉哈。

目錄

一.防止反射打破單例

二.使用synchronized關鍵字同步代碼塊

1.多線程的原子性

2.根據原子性思考該代碼的問題所在

3.synchronized關鍵字添加原子性

4.減少同步代碼塊,優化程序

三.你以爲這樣就完了?

1.指令重排優化對單例模式的影響

2.volatile關鍵字屏蔽指令重排優化


一.防止反射打破單例

在多線程大餐到來之前,我們先來一個開胃小菜。

衆所周知,反射是可以暴力破解private方法的,因此,爲了防止我們辛辛苦苦寫的單例模式被暴力反射,應當修改構造方法,在遇到反射時,拋出異常。

public class Single {

    private static Single single;

    private static int ctrl = 0;//控制反射時使用,如果實例對象被創建,ctrl=1
    private Single(){
        if(single!=null||ctrl==0){
            //如果single不爲空,繼續調用構造方法說明此時是被反射調用了,拋出異常
            //如果single爲空,但是ctrl=0,說明不是getInstance調用的方法,拋出異常
            throw new RuntimeException("親,這邊拒絕使用反射");
        }
    }


    public static Single getInstance(){
        if(single==null){
            ctrl++;//實例被創建時++,說明Single已經有了實例對象了。
            single = new Single();
        }
        return single;
    }

}

 

二.使用synchronized關鍵字同步代碼塊

在喫完了前面的開胃小菜,我們來分析一下這個單例爲什麼不是安全的。

1.多線程的原子性:

所謂原子性,和數據庫的事務也很像,意思就是說,一段代碼,要麼全部執行,要麼全部不執行。

同樣的,就像數據庫事務部分字段天生具有原子性,程序代碼被細分到極致,也具有原子性(64位操作系統一次原子操作是64位,32位操作系統則是32位,因此在32位操作系統上讀取double和long變量並不是原子操作,但是主流的商用虛擬機都會將讀取long和double封裝成原子操作)。

但是一行Java代碼並不代表這一行代碼就具有原子性了,實際上被編譯成字節碼之後,一行代碼可能就需要執行很多步才能達到目的。

更何況字節碼還要被解析,最終執行者是C語言(這裏說的是被C執行,而不是解析成C執行),最後又可能成爲彙編語言和機器碼,這個時候,簡單的一行代碼就可能需要幾十次原子操作才能完成。 

因此,在絕大部分情況下,我們都可以認爲,Java的任意一行代碼,都不具有原子性。

 

2.根據原子性思考該代碼的問題所在

if(single==null){
     ctrl++;
     single = new Single();
}

這是我們創建單例對象的代碼,且不說底層機器碼是否是原子操作,光是Java代碼就有兩行(不包括ctrl++的話),因此,我們創建對象的過程並不具有原子性,那麼在多線程的環境下,該代碼可能被線程A執行到一半,就切換給線程B執行了,最後可能產生2個甚至多個對象。

這顯然不是我們想要的。

 

3.synchronized關鍵字添加原子性

爲了讓我們創建對象的過程具有原子性,也就是說這個過程要麼不執行,如果執行,就要有頭有尾,我們需要使用synchronized給該代碼添加原子性,最簡單的辦法,就是加在方法體上:

    public synchronized static Single getInstance(){
        if(single==null){
            ctrl++;
            single = new Single();
        }
        return single;
    }

通過synchronized關鍵字修飾之後,程序在執行的過程中,同一時間只允許一個線程執行該同步方法,這樣做確實可以保證我們的Single對象是單例的。

但是:因爲同步的是整塊方法,也就是說,即便在Single創建好之後(這個時候我們的代碼僅僅只是返回single對象在堆中的內存地址,可以說是很安全的操作),依舊需要排隊才能獲取single對象,並且本身synchronized關鍵字就是需要映射到操作系統底層的,使用該關鍵字如果沒有起到該有的作用,會浪費大量的時間(準確來說是因爲線程的等待和喚醒需要消耗大量的時間)。

 

4.減少同步代碼塊,優化程序

在知道了synchronized關鍵字會造成性能丟失之後,我們就需要考慮如何解決這個問題。

在上一步的分析中,我們已經知道了性能低下是因爲在獲取對象操作(該操作在本案例中即便不加鎖也是線程安全的)使用同步代碼塊造成的。

因此我們的解決方案就是,只有當Single創建的時候,才加鎖。但是鎖加在哪裏,就是一門學問了。

錯誤寫法示例:

    public static Single getInstance() {
        //在此處添加鎖,程序代碼依舊需要先獲取鎖才能夠得到single對象,性能依舊低下。
        synchronized (Single.class) {
            if (single == null) {
                ctrl++;
                single = new Single();
            }
        }
        return single;
    }
    public static Single getInstance() {
        
        //此處有判斷條件,因此如果對象已經創建,則不需要進入同步代碼塊獲取鎖
        if (single == null) {

            //但是如果線程A在創建對象的過程中,線程B執行到此處,
            //線程B因爲沒有獲取鎖,會等待線程A釋放鎖
            //當線程A創建好single對象並且釋放鎖之後,線程B則會獲取鎖並且進入,
            //此時,線程B又會繼續new一個新對象
            //線程A:MMP
            synchronized (Single.class) {
                ctrl++;
                single = new Single();
            }
        }
        return single;
    }

正確寫法:

    public static Single getInstance() {
        if (single == null) {
            synchronized (Single.class) {
                //在錯誤示範2的基礎上,再加一次判斷。
                //即便阻塞之後,線程B執行同步代碼塊時,sing已經不爲null,因此
                //此時線程B不會創建新對象
                if (single == null) {
                    ctrl++;
                    single = new Single();
                }
            }
        }
        return single;
    }

 

三.你以爲這樣就完了?

在經過了二的摧殘之後,雖然在代碼層面,我們的單例模式已經很完美了。

但是

指令重排優化了解一下?

Single單例:MMP

什麼是指令重排優化呢?

一般情況下,JVM和CPU爲了加快執行效率,會允許在不影響程序結果的情況下,對程序的執行順序進行重新排序。

例如:

int a = 10;
int c = 20;
a = 50;
c = 80;

上述四句代碼在執行時,可能真正執行的順序是這樣

int c = 20;
c = 80;
int a = 10;
a = 50;

當然,真正的JVM和CPU執行過程不是這個樣子,他們可能會進行各種優化,但是這並不妨礙我們通過這個例子來了解指令重排優化。

上面四句代碼不論是否指令重排,雖然重排後程序的運行順序發生了很多變化,但是得出的結果都是a=50,c=80,在單線程中,即便指令重排,也不會影響結果。

可如果放到多線程,那就不一定了。

多線程:我有一句MMP不知當講不當講。

 

1.指令重排優化對單例模式的影響

我們已經明白了什麼是指令重排優化,那麼這個玩意,對我們的單例模式有什麼影響呢?

在上面我們說過,我們可以認爲任何一句Java代碼被編譯執行之後,都是不具備原子性的。

就拿創建對象而言:

Single single = new Single();

這一行代碼被編譯後就是這樣:(僞代碼)

//1.分配內存地址
addr = new addr();

//2.實例化對象
new Single();

//3.將實例對象的地址傳值給single
single = addr;

而經過指令重排優化後,可能執行的順序就是這樣(僞代碼):

//1.分配內存地址
addr = new addr();

//3.將實例對象的地址傳值給A
single = addr;

//2.實例化對象
new Single();

也就是說,原本實例化的過程被放到最後執行,而優先將內存地址分配給了single,當分配內存之後,single!=null,但是此時的A實例化還沒有完成!!!

如果在線程A實例化single的過程中(此時因爲指令重排優化,導致single雖然沒有實例化完成,但是已經是個非空對象。)線程B執行getInstance方法,因爲single!=null ,所以線程B會直接得到single的地址,但是此時的single還沒有實例化完成。

 

2.volatile關鍵字屏蔽指令重排優化

volatile關鍵字修飾的變量被使用時,會爲其前後的代碼塊添加屏蔽字段,該字段被識別後,CPU不會使用指令重排優化來優化處於該字段中的指令,改用正常的順序執行。

所以,我們的單例模式最終版就是這樣子了:

public class Single {
    
    //加上volatile關鍵字屏蔽指令重排優化
    private volatile static Single single = null;
    private static int ctrl = 0;

    private Single() {
        if (single != null || ctrl == 0) {
            throw new RuntimeException("親,這邊拒絕使用反射");
        }
    }


    public static Single getInstance() {
        if (single == null) {
            synchronized (Single.class) {
                if (single == null) {
                    ctrl++;
                    single = new Single();
                }
            }
        }
        return single;
    }

}

volatile關鍵字除了可以屏蔽指令重排優化,還能夠保證被修飾的字段的可見性。

也就是不管在哪個線程修改了volatile關鍵字修飾的字段,其他的線程都會感知到,但是在本單例模式中,volatile僅僅用來屏蔽指令重排優化,並沒有起到可見性的作用,因此不做介紹。(這個知識點和線程棧有關係,本人很懶,寫不動了)。

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