您當前位置: 南順網絡>> 官方資訊>> 建站知識

雙重檢查鎖定與延遲初始化

分享一(yī)篇13年(nián)曾經收藏學(xué)習的(de)一(yī)篇文章(zhāng),寫的(de)非常好~~

在java程序中,有時候可(kě)能需要推遲一(yī)些高(gāo)開銷的(de)對象初始化操作,并且隻有在使用這些對象時才進行初始化。此時程序員可(kě)能會采用延遲初始化。但要正确實現線程安全的(de)延遲初始化需要一(yī)些技巧,否則很容易出現問題。比如(rú),下面是非線程安全的(de)延遲初始化對象的(de)示例代碼:

public class UnsafeLazyInitialization {
    private static Instance instance;
    public static Instance getInstance() {
        if (instance == null) //1:A線程執行
        instance = new Instance(); //2:B線程執行
        return instance;
    }}

在UnsafeLazyInitialization中,假設A線程執行代碼1的(de)同時,B線程執行代碼2。此時,線程A可(kě)能會看到instance引用的(de)對象還沒有完成初始化(出現這種情況的(de)原因見後文的(de)“問題的(de)根源”)。

對于UnsafeLazyInitialization,我們可(kě)以對getInstance()做(zuò)同步處理(lǐ)來實現線程安全的(de)延遲初始化。示例代碼如(rú)下:

public class SafeLazyInitialization {
    private static Instance instance;

    public synchronized static Instance getInstance() {
        if (instance == null)
            instance = new Instance();
        return instance;
    }}

由于對getInstance()做(zuò)了同步處理(lǐ),synchronized将導緻性能開銷。如(rú)果getInstance()被多個線程頻繁的(de)調用,将會導緻程序執行性能的(de)下降。反之,如(rú)果getInstance()不會被多個線程頻繁的(de)調用,那麽這個延遲初始化方案将能提供令人滿意的(de)性能。

在早期的(de)JVM中,synchronized(甚至是無競争的(de)synchronized)存在這巨大的(de)性能開銷。因此,人們想出了一(yī)個“聰明”的(de)技巧:雙重檢查鎖定(double-checked locking)。人們想通過雙重檢查鎖定來降低(dī)同步的(de)開銷。下面是使用雙重檢查鎖定來實現延遲初始化的(de)示例代碼:

public class DoubleCheckedLocking {                 //1
    private static Instance instance;                    //2

    public static Instance getInstance() {               //3
        if (instance == null) {                          //4:第一(yī)次檢查
            synchronized (DoubleCheckedLocking.class) {  //5:加鎖
                if (instance == null)                    //6:第二次檢查
                    instance = new Instance();           //7:問題的(de)根源出在這裏
            }                                            //8
        }                                                //9
        return instance;                                 //10
    }                                                    //11}                                                        //12

在java程序中,有時候可(kě)能需要推遲一(yī)些高(gāo)開銷的(de)對象初始化操作,并且隻有在使用這些對象時才進行初始化。此時程序員可(kě)能會采用延遲初始化。但要正确實現線程安全的(de)延遲初始化需要一(yī)些技巧,否則很容易出現問題。比如(rú),下面是非線程安全的(de)延遲初始化對象的(de)示例代碼:

public class UnsafeLazyInitialization {
private static Instance instance;
public static Instance getInstance() {
if (instance == null) //1:A線程執行
instance = new Instance(); //2:B線程執行
return instance;
}
}
在UnsafeLazyInitialization中,假設A線程執行代碼1的(de)同時,B線程執行代碼
2。此時,線程A可(kě)能會看到instance引用的(de)對象還沒有完成初始化(出現這種情況的(de)原因見後文的(de)“問題的(de)根源”)。

對于UnsafeLazyInitialization,我們可(kě)以對getInstance()做(zuò)同步處理(lǐ)來實現線程安全的(de)延遲初始化。示例代碼如(rú)下:

public class SafeLazyInitialization {
private static Instance instance;

public synchronized static Instance getInstance() {
    if (instance == null)
        instance = new Instance();
    return instance;
}

}
由于對getInstance()做(zuò)了同步處理(lǐ),synchronized将導緻性能開銷。如(rú)果getInstance()被多個線程頻繁的(de)調用,将會導緻程序執行性能的(de)下降。反之,如(rú)果getInstance()不會被多個線程頻繁的(de)調用,那麽這個延遲初始化方案将能提供令人滿意的(de)性能。

相關廠商內(nèi)容

UCan下午茶 悟有所值——一(yī)站式高(gāo)可(kě)用數據平台設計與實踐
京東物流系統自(zì)動化運維平台技術揭密 阿裏巴巴基礎運維平台實踐經驗談 Uber SRE團隊如(rú)何做(zuò)好守護系統穩定的(de)最後一(yī)道(dào)防線? 騰訊雲多Kubernetes集群高(gāo)可(kě)用運維實踐
相關贊助商

CNUTCon全球運維技術大會,9月10日-9月11日,上海·光大會展中心大酒店,精彩內(nèi)容搶先看

在早期的(de)JVM中,synchronized(甚至是無競争的(de)synchronized)存在這巨大的(de)性能開銷。因此,人們想出了一(yī)個“聰明”的(de)技巧:雙重檢查鎖定(double-checked locking)。人們想通過雙重檢查鎖定來降低(dī)同步的(de)開銷。下面是使用雙重檢查鎖定來實現延遲初始化的(de)示例代碼:

public class DoubleCheckedLocking { //1
private static Instance instance; //2

public static Instance getInstance() {               //3
    if (instance == null) {                          //4:第一(yī)次檢查
        synchronized (DoubleCheckedLocking.class) {  //5:加鎖
            if (instance == null)                    //6:第二次檢查
                instance = new Instance();           //7:問題的(de)根源出在這裏
        }                                            //8
    }                                                //9
    return instance;                                 //10
}                                                    //11

} //12
如(rú)上面代碼所示,如(rú)果第一(yī)次檢查instance不為(wèi)null,那麽就不需要執行下面的(de)加鎖和(hé)初始化操作。因此可(kě)以大幅降低(dī)synchronized帶來的(de)性能開銷。上面代碼表面上看起來,似乎兩全其美:

在多個線程試圖在同一(yī)時間創建對象時,會通過加鎖來保證隻有一(yī)個線程能創建對象。
在對象創建好之後,執行getInstance()将不需要獲取鎖,直接返回已創建好的(de)對象。
雙重檢查鎖定看起來似乎很完美,但這是一(yī)個錯誤的(de)優化!在線程執行到第4行代碼讀取到instance不為(wèi)null時,instance引用的(de)對象有可(kě)能還沒有完成初始化。

問題的(de)根源

前面的(de)雙重檢查鎖定示例代碼的(de)第7行(instance = new Singleton();)創建一(yī)個對象。這一(yī)行代碼可(kě)以分解為(wèi)如(rú)下的(de)三行僞代碼:

memory = allocate();   //1:分配對象的(de)內(nèi)存空間ctorInstance(memory);  //2:初始化對象instance = memory;     //3:設置instance指向剛分配的(de)內(nèi)存地(dì)址

上面三行僞代碼中的(de)2和(hé)3之間,可(kě)能會被重排序(在一(yī)些JIT編譯器上,這種重排序是真實發生的(de),詳情見參考文獻1的(de)“Out-of-order writes”部分)。2和(hé)3之間重排序之後的(de)執行時序如(rú)下:

圖片描述

如(rú)上圖所示,隻要保證2排在4的(de)前面,即使2和(hé)3之間重排序了,也不會違反intra-thread semantics。

下面,再讓我們看看多線程并發執行的(de)時候的(de)情況。請看下面的(de)示意圖:

圖片描述

由于單線程內(nèi)要遵守intra-thread semantics,從而能保證A線程的(de)程序執行結果不會被改變。但是當線程A和(hé)B按上圖的(de)時序執行時,B線程将看到一(yī)個還沒有被初始化的(de)對象。

※注:本文統一(yī)用紅(hóng)色的(de)虛箭線标識錯誤的(de)讀操作,用綠色的(de)虛箭線标識正确的(de)讀操作。

回到本文的(de)主題,DoubleCheckedLocking示例代碼的(de)第7行(instance = new Singleton();)如(rú)果發生重排序,另一(yī)個并發執行的(de)線程B就有可(kě)能在第4行判斷instance不為(wèi)null。線程B接下來将訪問instance所引用的(de)對象,但此時這個對象可(kě)能還沒有被A線程初始化!下面是這個場景的(de)具體執行時序:

圖片描述

這裏A2和(hé)A3雖然重排序了,但java內(nèi)存模型的(de)intra-thread semantics将确保A2一(yī)定會排在A4前面執行。因此線程A的(de)intra-thread semantics沒有改變。但A2和(hé)A3的(de)重排序,将導緻線程B在B1處判斷出instance不為(wèi)空,線程B接下來将訪問instance引用的(de)對象。此時,線程B将會訪問到一(yī)個還未初始化的(de)對象。

在知曉了問題發生的(de)根源之後,我們可(kě)以想出兩個辦法來實現線程安全的(de)延遲初始化:

  • 不允許2和(hé)3重排序;

  • 允許2和(hé)3重排序,但不允許其他線程“看到”這個重排序。

後文介紹的(de)兩個解決方案,分别對應于上面這兩點。

基于volatile的(de)雙重檢查鎖定的(de)解決方案

對于前面的(de)基于雙重檢查鎖定來實現延遲初始化的(de)方案(指DoubleCheckedLocking示例代碼),我們隻需要做(zuò)一(yī)點小的(de)修改(把instance聲明為(wèi)volatile型),就可(kě)以實現線程安全的(de)延遲初始化。請看下面的(de)示例代碼:

public class SafeDoubleCheckedLocking {
    private volatile static Instance instance;

    public static Instance getInstance() {
        if (instance == null) {
            synchronized (SafeDoubleCheckedLocking.class) {
                if (instance == null)
                    instance = new Instance();//instance為(wèi)volatile,現在沒問題了
            }
        }
        return instance;
    }}

注意,這個解決方案需要JDK5或更高(gāo)版本(因為(wèi)從JDK5開始使用新的(de)JSR-133內(nèi)存模型規範,這個規範增強了volatile的(de)語義)。

當聲明對象的(de)引用為(wèi)volatile後,“問題的(de)根源”的(de)三行僞代碼中的(de)2和(hé)3之間的(de)重排序,在多線程環境中将會被禁止。上面示例代碼将按如(rú)下的(de)時序執行:

圖片描述

這個方案本質上是通過禁止上圖中的(de)2和(hé)3之間的(de)重排序,來保證線程安全的(de)延遲初始化。

基于類初始化的(de)解決方案

JVM在類的(de)初始化階段(即在Class被加載後,且被線程使用之前),會執行類的(de)初始化。在執行類的(de)初始化期間,JVM會去(qù)獲取一(yī)個鎖。這個鎖可(kě)以同步多個線程對同一(yī)個類的(de)初始化。

基于這個特性,可(kě)以實現另一(yī)種線程安全的(de)延遲初始化方案(這個方案被稱之為(wèi)Initialization On Demand Holder idiom):

public class InstanceFactory {
    private static class InstanceHolder {
        public static Instance instance = new Instance();
    }

    public static Instance getInstance() {
        return InstanceHolder.instance ;  //這裏将導緻InstanceHolder類被初始化
    }}

假設兩個線程并發執行getInstance(),下面是執行的(de)示意圖:

圖片描述

這個方案的(de)實質是:允許“問題的(de)根源”的(de)三行僞代碼中的(de)2和(hé)3重排序,但不允許非構造線程(這裏指線程B)“看到”這個重排序。

初始化一(yī)個類,包括執行這個類的(de)靜态初始化和(hé)初始化在這個類中聲明的(de)靜态字段。根據java語言規範,在首次發生下列任意一(yī)種情況時,一(yī)個類或接口類型T将被立即初始化:

  • T是一(yī)個類,而且一(yī)個T類型的(de)實例被創建;

  • T是一(yī)個類,且T中聲明的(de)一(yī)個靜态方法被調用;

  • T中聲明的(de)一(yī)個靜态字段被賦值;

  • T中聲明的(de)一(yī)個靜态字段被使用,而且這個字段不是一(yī)個常量字段;

  • T是一(yī)個頂級類(top level class,見java語言規範的(de)§7.6),而且一(yī)個斷言語句嵌套在T內(nèi)部被執行。

在InstanceFactory示例代碼中,首次執行getInstance()的(de)線程将導緻InstanceHolder類被初始化(符合情況4)。

由于java語言是多線程的(de),多個線程可(kě)能在同一(yī)時間嘗試去(qù)初始化同一(yī)個類或接口(比如(rú)這裏多個線程可(kě)能在同一(yī)時刻調用getInstance()來初始化InstanceHolder類)。因此在java中初始化一(yī)個類或者接口時,需要做(zuò)細緻的(de)同步處理(lǐ)。

Java語言規範規定,對于每一(yī)個類或接口C,都有一(yī)個唯一(yī)的(de)初始化鎖LC與之對應。從C到LC的(de)映射,由JVM的(de)具體實現去(qù)自(zì)由實現。JVM在類初始化期間會獲取這個初始化鎖,并且每個線程至少獲取一(yī)次鎖來确保這個類已經被初始化過了(事實上,java語言規範允許JVM的(de)具體實現在這裏做(zuò)一(yī)些優化,見後文的(de)說明)。

對于類或接口的(de)初始化,java語言規範制定了精巧而複雜的(de)類初始化處理(lǐ)過程。java初始化一(yī)個類或接口的(de)處理(lǐ)過程如(rú)下(這裏對類初始化處理(lǐ)過程的(de)說明,省略了與本文無關的(de)部分;同時為(wèi)了更好的(de)說明類初始化過程中的(de)同步處理(lǐ)機制,筆(bǐ)者人為(wèi)的(de)把類初始化的(de)處理(lǐ)過程分為(wèi)了五個階段):

第一(yī)階段:通過在Class對象上同步(即獲取Class對象的(de)初始化鎖),來控制類或接口的(de)初始化。這個獲取鎖的(de)線程會一(yī)直等待,直到當前線程能夠獲取到這個初始化鎖。

假設Class對象當前還沒有被初始化(初始化狀态state此時被标記為(wèi)state = noInitialization),且有兩個線程A和(hé)B試圖同時初始化這個Class對象。下面是對應的(de)示意圖:

圖片描述

下面是這個示意圖的(de)說明:

圖片描述

第二階段:線程A執行類的(de)初始化,同時線程B在初始化鎖對應的(de)condition上等待:

圖片描述

下面是這個示意圖的(de)說明:

圖片描述

第三階段:線程A設置state = initialized,然後喚醒在condition中等待的(de)所有線程:

圖片描述

下面是這個示意圖的(de)說明:

圖片描述

第四階段:線程B結束類的(de)初始化處理(lǐ):

圖片描述

下面是這個示意圖的(de)說明:

圖片描述

線程A在第二階段的(de)A1執行類的(de)初始化,并在第三階段的(de)A4釋放初始化鎖;線程B在第四階段的(de)B1獲取同一(yī)個初始化鎖,并在第四階段的(de)B4之後才開始訪問這個類。根據java內(nèi)存模型規範的(de)鎖規則,這裏将存在如(rú)下的(de)happens-before關系:

圖片描述

這個happens-before關系将保證:線程A執行類的(de)初始化時的(de)寫入操作(執行類的(de)靜态初始化和(hé)初始化類中聲明的(de)靜态字段),線程B一(yī)定能看到。

第五階段:線程C執行類的(de)初始化的(de)處理(lǐ):

圖片描述

下面是這個示意圖的(de)說明:

圖片描述

在第三階段之後,類已經完成了初始化。因此線程C在第五階段的(de)類初始化處理(lǐ)過程相對簡單一(yī)些(前面的(de)線程A和(hé)B的(de)類初始化處理(lǐ)過程都經曆了兩次鎖獲取-鎖釋放,而線程C的(de)類初始化處理(lǐ)隻需要經曆一(yī)次鎖獲取-鎖釋放)。

線程A在第二階段的(de)A1執行類的(de)初始化,并在第三階段的(de)A4釋放鎖;線程C在第五階段的(de)C1獲取同一(yī)個鎖,并在在第五階段的(de)C4之後才開始訪問這個類。根據java內(nèi)存模型規範的(de)鎖規則,這裏将存在如(rú)下的(de)happens-before關系:

圖片描述

這個happens-before關系将保證:線程A執行類的(de)初始化時的(de)寫入操作,線程C一(yī)定能看到。

※注1:這裏的(de)condition和(hé)state标記是本文虛構出來的(de)。Java語言規範并沒有硬性規定一(yī)定要使用condition和(hé)state标記。JVM的(de)具體實現隻要實現類似功能即可(kě)。

※注2:Java語言規範允許Java的(de)具體實現,優化類的(de)初始化處理(lǐ)過程(對這裏的(de)第五階段做(zuò)優化),具體細節參見java語言規範的(de)12.4.2章(zhāng)。

通過對比基于volatile的(de)雙重檢查鎖定的(de)方案和(hé)基于類初始化的(de)方案,我們會發現基于類初始化的(de)方案的(de)實現代碼更簡潔。但基于volatile的(de)雙重檢查鎖定的(de)方案有一(yī)個額外的(de)優勢:除了可(kě)以對靜态字段實現延遲初始化外,還可(kě)以對實例字段實現延遲初始化。

總結

延遲初始化降低(dī)了初始化類或創建實例的(de)開銷,但增加了訪問被延遲初始化的(de)字段的(de)開銷。在大多數時候,正常的(de)初始化要優于延遲初始化。如(rú)果确實需要對實例字段使用線程安全的(de)延遲初始化,請使用上面介紹的(de)基于volatile的(de)延遲初始化的(de)方案;如(rú)果确實需要對靜态字段使用線程安全的(de)延遲初始化,請使用上面介紹的(de)基于類初始化的(de)方案。


編輯:--ns868