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

在Java多線程程序中,有時候需要采用延遲初始化來降低初始化類和創(chuàng)建對象的開銷。雙重檢查鎖定是常見的延遲初始化技術(shù),但它是一個錯誤的用法。本文將分析雙重檢查鎖定的錯誤根源,以及兩種線程安全的延遲初始化方案。

雙重檢查鎖定的由來

在Java程序中,有時候可能需要推遲一些高開銷的對象初始化操作,并且只有在使用這些對象時才進行初始化。此時,程序員可能會采用延遲初始化。但要正確實現(xiàn)線程安全的延遲初始化需要一些技巧,否則很容易出現(xiàn)問題。比如,下面是非線程安全的延遲初始化對象的示例代碼。

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

在UnsafeLazyInitialization類中,假設(shè)A線程執(zhí)行代碼1的同時,B線程執(zhí)行代碼2。此時,線程A可能會看到instance引用的對象還沒有完成初始化。

對于UnsafeLazyInitialization類,我們可以對getInstance()方法做同步處理來實現(xiàn)線程安全的延遲初始化。示例代碼如下。

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

由于對getInstance()方法做了同步處理,synchronized將導(dǎo)致性能開銷。如果getInstance()方法被多個線程頻繁的調(diào)用,將會導(dǎo)致程序執(zhí)行性能的下降。反之,如果getInstance()方法不會被多個線程頻繁的調(diào)用,那么這個延遲初始化方案將能提供令人滿意的性能。

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

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

如上面代碼所示,如果第一次檢查instance不為null,那么就不需要執(zhí)行下面的加鎖和初始化操作。因此,可以大幅降低synchronized帶來的性能開銷。上面代碼表面上看起來,似乎兩全其美。

  • 多個線程試圖在同一時間創(chuàng)建對象時,會通過加鎖來保證只有一個線程能創(chuàng)建對象。
  • 在對象創(chuàng)建好之后,執(zhí)行g(shù)etInstance()方法將不需要獲取鎖,直接返回已創(chuàng)建好的對象。

雙重檢查鎖定看起來似乎很完美,但這是一個錯誤的優(yōu)化!在線程執(zhí)行到第一次檢查時,代碼讀取到instance不為null時,instance引用的對象有可能還沒有完成初始化。

問題的根源

前面的雙重檢查鎖定示例代碼的第7行(instance = new Singleton();)創(chuàng)建了一個對象。這一行代碼可以分解為如下的3行偽代碼。

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

上面3行偽代碼中的2和3之間,可能會被重排序(在一些JIT編譯器上,這種重排序是真實發(fā)生的)。2和3之間重排序之后的執(zhí)行時序如下。

memory = allocate(); //1:分配對象的內(nèi)存空間
instance = memory;   //3:設(shè)置instance指向剛分配的內(nèi)存地址 //注意,此時對象還沒有被初始化!
ctorInstance(memory); //2:初始化對象

根據(jù)《The Java Language Specif ication, Java SE 7 Edition》(簡稱為Java語言規(guī)范),所有線程在執(zhí)行Java程序時必須要遵守intra-thread semantics。intra-thread semantics保證重排序不會改變單線程內(nèi)的程序執(zhí)行結(jié)果。換句話說,intra-thread semantics允許那些在單線程內(nèi),不會改變單線程程序執(zhí)行結(jié)果的重排序。上面3行偽代碼的2和3之間雖然被重排序了,但這個重排序并不會違反intra-thread semantics。這個重排序在沒有改變單線程程序執(zhí)行結(jié)果的前提下,可以提高程序的執(zhí)行性能。

線程執(zhí)行時序
1: 分配對象的內(nèi)存空間
3: 設(shè)置instance指向內(nèi)存空間
2: 初始化對象
3: 初次訪問對象

雖然這里2和3重排序了,但是只要保證2排在4的前面執(zhí)行,單線程內(nèi)的執(zhí)行結(jié)果就不會被改變

多線程執(zhí)行時序如下:

線程A 線程B
1: 分配對象的內(nèi)存空間
3:設(shè)置instance指向內(nèi)存空間
- 判斷instance是否為null
- B線程初次訪問對象
2:初始化對象
4:A線程初次訪問對象

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

回到本文的主題,DoubleCheckedLocking示例代碼的第7行(instance=newSingleton();)如果發(fā)生重排序,另一個并發(fā)執(zhí)行的線程B就有可能在第4行判斷instance不為null。線程B接下來將訪問instance所引用的對象,但此時這個對象可能還沒有被A線程初始化!下表是這個場景的具體執(zhí)行時序。

多線程執(zhí)行時序表:

時間 線程A 線程B
t1 A1: 分配對象的內(nèi)存空間 -
t2 A3: 設(shè)置instance指向內(nèi)存空間 -
t3 - B1: 判斷instance是否為空
t4 - B2: 由于instance不為null,線程B將訪問instance引用的對象
t5 A2: 初始化對象 -
t6 A4: 訪問instance引用的對象 -

這里A2和A3雖然重排序了,但Java內(nèi)存模型的intra-thread semantics將確保A2一定會排在A4前面執(zhí)行。因此,線程A的intra-thread semantics沒有改變,但A2和A3的重排序,將導(dǎo)致線程B在B1處判斷出instance不為空,線程B接下來將訪問instance引用的對象。此時,線程B將會訪問到一個還未初始化的對象。

在知曉了問題發(fā)生的根源之后,我們可以想出兩個辦法來實現(xiàn)線程安全的延遲初始化。

  1. 不允許2和3重排序。
  2. 允許2和3重排序,但不允許其他線程“看到”這個重排序。
    后文介紹的兩個解決方案,分別對應(yīng)于上面這兩點。

基于volatile的解決方案

將實例定義為volatile類型,實現(xiàn)線程安全的延遲初始化。

public class SafeDoubleCheckLocking {
    private volatile static Instance instance;
    public static Instance getInstance() {
        if (instance == null) {
            synchronized (SafeDoubleCheckLocking.class) {
                if (instance == null) {
                    instance = new Instance();  //instance為volatile,現(xiàn)在沒問題了
                }
            }
        }
        return instance;
    }
}

這個解決方案需要JDK 5或更高版本(因為從JDK 5開始使用新的JSR-133內(nèi)存模型規(guī)范,這個規(guī)范增強了volatile的語義)。

基于類初始化的解決方案

JVM在類的初始化階段(即在Class被加載后,且被線程使用之前),會執(zhí)行類的初始化。在執(zhí)行類的初始化期間,JVM會去獲取一個鎖。這個鎖可以同步多個線程對同一個類的初始化。
基于這個特性,可以實現(xiàn)另一種線程安全的延遲初始化方案(這個方案被稱之為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;     //這里導(dǎo)致InstanceHolder初始化
    }
}

這個方案的實質(zhì)是:允許臨界區(qū)代碼重排序,但不允許非構(gòu)造線程(這里指線程B)“看到”這個重排序。

初始化一個類,包括執(zhí)行這個類的靜態(tài)初始化和初始化在這個類中聲明的靜態(tài)字段。根據(jù)Java語言規(guī)范,在首次發(fā)生下列任意一種情況時,一個類或接口類型T將被立即初始化。

  1. T是一個類,而且一個T類型的實例被創(chuàng)建。
  2. T是一個類,且T中聲明的一個靜態(tài)方法被調(diào)用。
  3. T中聲明的一個靜態(tài)字段被賦值。
  4. T中聲明的一個靜態(tài)字段被使用,而且這個字段不是一個常量字段。
  5. T是一個頂級類(其他類外面聲明的類),而且一個斷言語句嵌套在T內(nèi)部被執(zhí)行。

在InstanceFactory示例代碼中,首次執(zhí)行g(shù)etInstance()方法的線程將導(dǎo)致InstanceHolder類被初始化(符合情況4)。

由于Java語言是多線程的,多個線程可能在同一時間嘗試去初始化同一個類或接口(比如這里多個線程可能在同一時刻調(diào)用getInstance()方法來初始化InstanceHolder類)。因此,在Java中初始化一個類或者接口時,需要做細致的同步處理。
Java語言規(guī)范規(guī)定,對于每一個類或接口C,都有一個唯一的初始化鎖LC與之對應(yīng)。從C到LC的映射,由JVM的具體實現(xiàn)去自由實現(xiàn)。JVM在類初始化期間會獲取這個初始化鎖,并且每個線程至少獲取一次鎖來確保這個類已經(jīng)被初始化過了(事實上,Java語言規(guī)范允許JVM的具體實現(xiàn)在這里做一些優(yōu)化)。

總結(jié)

通過對比基于volatile的雙重檢查鎖定的方案和基于類初始化的方案,我們會發(fā)現(xiàn)基于類初始化的方案的實現(xiàn)代碼更簡潔。但基于volatile的雙重檢查鎖定的方案有一個額外的優(yōu)勢:**除了可以對靜態(tài)字段實現(xiàn)延遲初始化外,還可以對實例字段實現(xiàn)延遲初始化。 **

字段延遲初始化降低了初始化類或創(chuàng)建實例的開銷,但增加了訪問被延遲初始化的字段的開銷。在大多數(shù)時候,正常的初始化要優(yōu)于延遲初始化。

如果確實需要對實例字段使用線程安全的延遲初始化,請使用上面介紹的基于volatile的延遲初始化的方案;如果確實需要對靜態(tài)字段使用線程安全的延遲初始化,請使用上面介紹的基于類初始化的方案。

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

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容