天天看點

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

《Java并發程式設計的藝術》 讀書筆記: - -  原作者:方騰飛

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

(1)、雙重檢查鎖定的由來

雙重檢查鎖定的由來在Java程式中,有時候可能需要推遲一些高開銷的對象初始化操作,并且隻有在使用這些對象時才進行初始化。此時,程式員可能會采用延遲初始化。但要正确實作線程安全的延遲初始化需要一些技巧,否則很容易出現問題。比如,下面是非線程安全的延遲初始化對象的示例代碼。

public class UnsafeLazyInitialization {
    private static Instance instance;

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

    static class Instance {
    }
}
           

在UnsafeLazyInitialization類中,假設A線程執行代碼1的同時,B線程執行代碼2。此時,線程A可能會看到instance引用的對象還沒有完成初始化(出現這種情況的原因見3.8.2節)。

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

public class SafeLazyInitialization {
    private static Instance instance;

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

    static class Instance {
    }
}
           

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

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

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
        return instance; //10
    } //11

    static class Instance {
    }
}
           

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

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

(2)、問題的根源

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

memory = allocate();  // 1:配置設定對象的記憶體空間

ctorInstance(memory);  // 2:初始化對象

instance = memory;   // 3:設定instance指向剛配置設定的記憶體位址

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

    memory = allocate();  // 1:配置設定對象的記憶體空間

    instance = memory;   // 3:設定instance指向剛配置設定的記憶體位址

    // 注意,此時對象還沒有被初始化!

    ctorInstance(memory);  // 2:初始化對象

根據《The Java Language Specification,Java SE 7 Edition》(後文簡稱為Java語言規範),所有線程在執行Java程式時必須要遵守intra-thread semantics。intra-thread semantics保證重排序不會改變單線程内的程式執行結果。換句話說,intra-thread semantics允許那些在單線程内,不會改變單線程程式執行結果的重排序。上面3行僞代碼的2和3之間雖然被重排序了,但這個重排序

并不會違反intra-thread semantics。這個重排序在沒有改變單線程程式執行結果的前提下,可以提高程式的執行性能。

為了更好地了解intra-thread semantics,請看如圖3-37所示的示意圖(假設一個線程A在構造對象後,立即通路這個對象)。

如圖3-37所示,隻要保證2排在4的前面,即使2和3之間重排序了,也不會違反intra-thread semantics。

下面,再讓我們檢視多線程并發執行的情況。如圖3-38所示。

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化
《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

由于單線程内要遵守intra-thread semantics,進而能保證A線程的執行結果不會被改變。但是,當線程A和B按圖3-38的時序執行時,B線程将看到一個還沒有被初始化的對象。

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

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

這裡A2和A3雖然重排序了,但Java記憶體模型的intra-thread semantics将確定A2一定會排在A4前面執行。是以,線程A的intra-thread semantics沒有改變,但A2和A3的重排序,将導緻線程B在B1處判斷出instance不為空,線程B接下來将通路instance引用的對象。此時,線程B将會通路到一個還未初始化的對象。

在知曉了問題發生的根源之後,我們可以想出兩個辦法來實作線程安全的延遲初始化。

1)不允許2和3重排序。

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

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

(3)、基于volatile的解決方案

對于前面的基于雙重檢查鎖定來實作延遲初始化的方案(指DoubleCheckedLocking示例代碼),隻需要做一點小的修改(把instance聲明為volatile型),就可以實作線程安全的延遲初始化。請看下面的示例代碼。

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為volatile,現在沒問題了
            }
        }
        return instance;
    }

    static class Instance {
    }
}
           

注意:這個解決方案需要JDK 5或更高版本(因為從JDK 5開始使用新的JSR-133記憶體模型規範,這個規範增強了volatile的語義)。

當聲明對象的引用為volatile後,3.8.2節中的3行僞代碼中的2和3之間的重排序,在多線程環境中将會被禁止。上面示例代碼将按如下的時序執行,如圖3-39所示。

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

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

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

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

基于這個特性,可以實作另一種線程安全的延遲初始化方案(這個方案被稱之為

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類被初始化
    }

    static class Instance {
    }
}
           

假設兩個線程并發執行getInstance()方法,下面是執行的示意圖,如圖3-40所示。

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

這個方案的實質是:允許3.8.2節中的3行僞代碼中的2和3重排序,但不允許非構造線程(這裡指線程B)“看到”這個重排序。

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

1)T是一個類,而且一個T類型的執行個體被建立。

2)T是一個類,且T中聲明的一個靜态方法被調用。

3)T中聲明的一個靜态字段被指派。

4)T中聲明的一個靜态字段被使用,而且這個字段不是一個常量字段。

5)T是一個頂級類(Top Level Class,見Java語言規範的§7.6),而且一個斷言語句嵌套在T内部被執行。

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

由于Java語言是多線程的,多個線程可能在同一時間嘗試去初始化同一個類或接口(比如這裡多個線程可能在同一時刻調用getInstance()方法來初始化InstanceHolder類)。是以,在Java中初始化一個類或者接口時,需要做細緻的同步處理。

Java語言規範規定,對于每一個類或接口C,都有一個唯一的初始化鎖LC與之對應。從C到LC的映射,由JVM的具體實作去自由實作。JVM在類初始化期間會擷取這個初始化鎖,并且每個線程至少擷取一次鎖來確定這個類已經被初始化過了(事實上,Java語言規範允許JVM的具體實作在這裡做一些優化,見後文的說明)。

對于類或接口的初始化,Java語言規範制定了精巧而複雜的類初始化處理過程。Java初始化一個類或接口的處理過程如下(這裡對類初始化處理過程的說明,省略了與本文無關的部分;同時為了更好的說明類初始化過程中的同步處理機制,筆者人為的把類初始化的處理過程分為了5個階段)。

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

假設Class對象目前還沒有被初始化(初始化狀态state,此時被标記為state=noInitialization),

且有兩個線程A和B試圖同時初始化這個Class對象。圖3-41是對應的示意圖。

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

表3-7是這個示意圖的說明。

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

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

表3-8是這個示意圖的說明。

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化
《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

第3階段:線程A設定state=initialized,然後喚醒在condition中等待的所有線程。

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

表3-9是這個示意圖的說明。

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

第4階段:線程B結束類的初始化處理。

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

表3-10是這個示意圖的說明。

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化
《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

線程A在第2階段的A1執行類的初始化,并在第3階段的A4釋放初始化鎖;線程B在第4階段的B1擷取同一個初始化鎖,并在第4階段的B4之後才開始通路這個類。根據Java記憶體模型規範的鎖規則,這裡将存在如下的happens-before關系。

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

第5階段:線程C執行類的初始化的處理。

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

表3-11是這個示意圖的說明。

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

在第3階段之後,類已經完成了初始化。是以線程C在第5階段的類初始化處理過程相對簡單一些(前面的線程A和B的類初始化處理過程都經曆了兩次鎖擷取-鎖釋放,而線程C的類初始化處理隻需要經曆一次鎖擷取-鎖釋放)。

線程A在第2階段的A1執行類的初始化,并在第3階段的A4釋放鎖;線程C在第5階段的C1擷取同一個鎖,并在在第5階段的C4之後才開始通路這個類。根據Java記憶體模型規範的鎖規則,将存在如下的happens-before關系。

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

注意:這裡的condition和state标記是本文虛構出來的。Java語言規範并沒有硬性規定一定要使用condition和state标記。JVM的具體實作隻要實作類似功能即可。

注意:Java語言規範允許Java的具體實作,優化類的初始化處理過程(對這裡的第5階段做優化),具體細節參見Java語言規範的12.4.2節。

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(八)雙重檢查鎖定與延遲初始化

通過對比基于volatile的雙重檢查鎖定的方案和基于類初始化的方案,我們會發現基于類初始化的方案的實作代碼更簡潔。但基于volatile的雙重檢查鎖定的方案有一個額外的優勢:

除了可以對靜态字段實作延遲初始化外,還可以對執行個體字段實作延遲初始化。

字段延遲初始化降低了初始化類或建立執行個體的開銷,但增加了通路被延遲初始化的字段的開銷。在大多數時候,正常的初始化要優于延遲初始化。如果确實需要對執行個體字段使用線程安全的延遲初始化,請使用上面介紹的基于volatile的延遲初始化的方案;如果确實需要對靜态字段使用線程安全的延遲初始化,請使用上面介紹的基于類初始化的方案。

以上内容摘要自《Java并發程式設計的藝術》 - - 方騰飛

下一篇:

《Java并發程式設計的藝術》 讀書筆記 之 Java記憶體模型(九)Java記憶體模型綜述

繼續閱讀