并發(fā)情況下,單例模式之雙重檢驗鎖陷阱

在我前面有寫過一篇關于單例模式的幾種創(chuàng)建的文章,最近在看多線程的時候,發(fā)現如果使用雙重檢驗鎖則可能會發(fā)生問題,接下來看我細細道來

單例模式的幾種創(chuàng)建方式文章地址:http://m.itdecent.cn/p/8ec72e016275

首先看一段代碼

public class SingletonV4 {

    private static SingletonV4 singletonV4;

    private SingletonV4() {
        System.out.println("--初始化--");
    }

    /**
     * 雙重檢驗鎖
     * 能夠保證線程安全,且效率高
     * @return
     */
    public static SingletonV4 getInstance() {
        if (null == singletonV4) {
            synchronized (SingletonV4.class) {
                if (null == singletonV4) {
                    singletonV4 = new SingletonV4();
                }
            }
        }
        return singletonV4;
    }

    public static void main(String[] args) {
        SingletonV4 instance1 = SingletonV4.getInstance();
        SingletonV4 instance2 = SingletonV4.getInstance();
        System.out.println(instance1);
        System.out.println(instance2);
    }
}


--初始化--
com.dream.sunny.SingletonV4@1716361
com.dream.sunny.SingletonV4@1716361

如上是一段單例模式中的懶漢模式雙重檢驗鎖,可能會有所疑惑,為什么需要兩次if判斷才進行初始化對象
第一次if判斷主要是為了減少性能開銷,之所以這么說,如果不加第一個if判斷,每次進入getInstance()方法,synchronized關鍵字會將整個代碼進行鎖住,加鎖操作,在進行判斷是否已經初始化,在進行釋放鎖,加鎖和釋放鎖是有較大的性能開銷,所以在最外層包裹一層if判斷實例是否被初始化,這樣就不會每次加鎖和釋放鎖了

既然synchronized鎖增加了性能開銷,為什么要加鎖呢
當然在單線程情況下,是沒有必要加鎖,而多線程情況下,多個線程同時進行初始化對象操作,這樣就會有線程安全性問題,為了防止這種情況,我們需要使用synchronized,這樣該方式在多線程情況下就是線程安全的

第二次if判斷目的在于有可能其他線程獲取過鎖,已經初始化改變量。第二次檢查還未通過,才會真正初始化變量。
這個方法檢查判定兩次,并使用鎖,所以形象稱為雙重檢查鎖定模式。
這個方案縮小鎖的范圍,減少鎖的開銷,看起來很完美。然而這個方案有一些問題卻很容易被忽略。

問題點:
這個被忽略的問題在于 singletonV4 = new SingletonV4();在java中創(chuàng)建一個對象并非是一個原子操作,可以查看如下字節(jié)碼代碼

#創(chuàng)建一個新對象(創(chuàng)建 SingletonV4 對象實例,分配內存)
19: new           #6                  // class com/dream/sunny/SingletonV4
#復制棧頂部一個字長內容(復制棧頂地址,并再將其壓入棧頂)
22: dup
#根據編譯時類型來調用實例方法(調用構造器方法,初始化 SingletonV4 對象)
23: invokespecial #7                  // Method "<init>":()V
#設置類中靜態(tài)字段的值
26: putstatic     #5                  // Field singletonV4:Lcom/dream/sunny/SingletonV4;
#從局部變量0中裝載引用類型值(存入局部方法變量表)
29: aload_0

從字節(jié)碼中可以看到創(chuàng)建一個對象實例,大致可以分為以下幾步:

1.創(chuàng)建對象并分配內存地址
2.調用構造器方法,執(zhí)行初始化對象
3.將對象的引用地址賦值給變量

在多線程情況下,上面三個步驟可能會發(fā)生指令重排(在一些JIT編譯器中),編譯器或處理器會為了提高代碼性能效率,而改變代碼的執(zhí)行順序。
上面三個步驟2和3之間可能會發(fā)生重排,但是1不會,因為2和3是要依托1指令的執(zhí)行結果,才能繼續(xù)往下走:

1.創(chuàng)建對象并分配內存地址
2.將對象的引用地址賦值給變量
3.調用構造器方法,執(zhí)行初始化對象

Java 語言規(guī)規(guī)定了線程執(zhí)行程序時需要遵守 intra-thread semantics。
不管怎么重排序(編譯器和處理器為了提高并行度),(單線程)程序的執(zhí)行結果不能被改變。
這個重排序在沒有改變單線程程序的執(zhí)行結果的前提下,可以提高程序的執(zhí)行性能。
雖然重排序并不影響單線程內的執(zhí)行結果,但是在多線程的環(huán)境就帶來一些問題。

模擬兩個線程創(chuàng)建單例的場景,如下:

時間 線程A 線程B
t1 創(chuàng)建對象 ~
t2 分配內存地址 ~
t3 ~ 判斷對象是否為空
t4 ~ 對象不為空,訪問該對象
t5 初始化對象 ~
t6 訪問該對象 ~

如果線程A獲取到鎖,進入到創(chuàng)建對象實例,這個時候發(fā)生了指令重排,線程A執(zhí)行到t3時刻,此時線程B搶占了CPU執(zhí)行時間片,但是由于此時對象不為空,則直接返回對象出去,然而使用該對象卻發(fā)現該對象未被初始化就會報錯,并且從始至終,線程B無需獲取鎖

指令重排
前面已經分析到,出現錯誤的原因在于“指令重排”,那什么是指令重排呢?它什么在并發(fā)情況下指令重排會直接影響到程序的執(zhí)行結果呢?首先我們看一下“順序一致性內存模型”概念。

順序一致性理論內存模型
順序一致性內存模型是一個被計算機科學家理想化了的理論參考模型,它為程序員提供了極強的內存可見性保證。順序一致性內存模型有兩大特性:

  • 一個線程中的所有操作必須按照程序的順序來執(zhí)行。
  • (不管程序是否同步)所有線程都只能看到一個單一的操作執(zhí)行順序。在順序一致性內存模型中,每個操作都必須原子執(zhí)行且立刻對所有線程可見。

實際JMM模型概念
但是,順序一致性模型只是一個理想化了的模型,在實際的JMM實現中,為了盡量提高程序運行效率,和理想的順序一致性內存模型有以下差異:
在順序一致性模型中,所有操作完全按程序的順序串行執(zhí)行。在JMM中不保證單線程操作會按程序順序執(zhí)行(即指令重排序)。 順序一致性模型保證所有線程只能看到一致的操作執(zhí)行順序,而JMM不保證所有線程能看到一致的操作執(zhí)行順序。 順序一致性模型保證對所有的內存寫操作都具有原子性,而JMM不保證對64位的long型和double型變量的讀/寫操作具有原子性(分為2個32位寫操作進行,本文無關不細闡述)

什么是指令重排序
指令重排序是指編譯器或處理器為了優(yōu)化性能而采取的一種手段,在不存在數據依賴性情況下(如寫后讀,讀后寫,寫后寫),調整代碼執(zhí)行順序。 舉個例子:

int a = 1;
int b = 10;
int c = a * b

這段代碼C依賴于A,B,但A,B沒有依賴關系,所以代碼可能有2種執(zhí)行順序:

  • A->B->C
  • B->A->C 但無論哪種最終結果都一致,這種滿足單線程內無論如何重排序不改變最終結果的語義,被稱作as-if-serial語義,遵守as-if-serial語義的編譯器,runtime 和處理器共同為編寫單線程程序的程序員創(chuàng)建了一個幻覺: 單線程程序是按程序的順序來執(zhí)行的。

雙重檢驗鎖問題解決方案
回頭看下我們出問題的雙重檢查鎖程序,它是滿足as-if-serial語義的嗎?是的,單線程下它沒有任何問題,但是在多線程下,會因為重排序出現問題。
解決方案就是volatile關鍵字,對于volatile我們最深的印象是它保證了”可見性“,它的”可見性“是通過它的內存語義實現的:

  • 寫volatile修飾的變量時,JMM會把本地內存中值刷新到主內存
  • 讀volatile修飾的變量時,JMM會設置本地內存無效

重點:為了實現可見性內存語義,編譯器在生成字節(jié)碼時,會在指令序列中插入內存屏障來防止重排序!

volatile是Java虛擬機提供的輕量級的同步機制。volatile關鍵字有如下兩個作用

  • 保證被volatile修飾的共享變量對所有線程總數可見的,也就是當一個線程修改了一個被volatile修飾共享變量的值,新值總是可以被其他線程立即得知。
  • 禁止指令重排序優(yōu)化。

由于 volatile 禁止對象創(chuàng)建時指令之間重排序,所以其他線程不會訪問到一個未初始化的對象,從而保證安全性。

注意,volatile禁止指令重排序在 JDK 5 之后才被修復

對之前代碼加入volatile關鍵字,即可實現線程安全的單例模式。

public class SingletonV4 {

    private static volatile SingletonV4 singletonV4;

    private SingletonV4() {
        System.out.println("--初始化--");
    }

    /**
     * 雙重檢驗鎖
     * 能夠保證線程安全,且效率高
     * @return
     */
    public static SingletonV4 getInstance() {
        if (null == singletonV4) {
            synchronized (SingletonV4.class) {
                if (null == singletonV4) {
                    singletonV4 = new SingletonV4();
                }
            }
        }
        return singletonV4;
    }

    public static void main(String[] args) {
        SingletonV4 instance1 = SingletonV4.getInstance();
        SingletonV4 instance2 = SingletonV4.getInstance();
        System.out.println(instance1);
        System.out.println(instance2);
    }
}


--初始化--
com.dream.sunny.SingletonV4@1716361
com.dream.sunny.SingletonV4@1716361

總結
對象的創(chuàng)建可能發(fā)生指令的重排序,使用 volatile 可以禁止指令的重排序,保證多線程環(huán)境內的系統(tǒng)安全。

參考博客:https://www.cnblogs.com/lkxsnow/p/12293791.html

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容