在Java多線程中,有時候需要採用延遲初始化來降低初始化類和創建對象的開銷。雙重檢查鎖定是常見的延遲初始化技術。但它是一個錯誤的用法。本文將分析雙重檢查鎖定的錯誤根源,以及兩種線程安全的延遲初始化方案。
一、 雙重檢查鎖定的由來
下面代碼是單例模式中比較常見的寫法(錯誤的、不安全):
public class UnsafeLazyInitialization {
private static UnsafeLazyInitialization instance;
public static UnsafeLazyInitialization getInstance(){
if (instance==null) { //1: A 線程執行
instance=new UnsafeLazyInitialization(); // 2: B線程執行
}
return instance;
}
}
在 UnsafeLazyInitialization
類中,假設 A線程 執行代碼 1 的同時,B線程 執行代碼 2 。此時,線程 A 可能會看到 instance
引用的對象沒有完成初始化(具體原因後面會詳細講解)。
對於 UnsafeLazyInitialization
類,我們可以對 getInstance()
方法做同步處理來實現線程安全的延遲初始化。示例代碼如下(正確的、低效率):
public class UnsafeLazyInitialization {
private static UnsafeLazyInitialization instance;
public synchronized static UnsafeLazyInitialization getInstance(){
if (instance==null) {
instance=new UnsafeLazyInitialization();
}
return instance;
}
}
由於對 getInstance()
方法做了同步處理,synchronized
將導致性能開銷。如果 getInstance()
方法被多個線程頻繁的調用,將會導致程序執行性能的下降。反之,如果 getInstance()
方法不會被多個線程頻繁調用,那麼這個延遲初始化方案將能提供令人滿意的性能。
對於上述方法,有一個優化技巧(只有在未初始化時才做同步操作):雙重檢查鎖定 (Double-Checked Locking) 。通過雙重檢查鎖定來降低同步的開銷。代碼如下:
public class DoubleCheckedLocking { // 1
private static DoubleCheckedLocking instance; // 2
public static DoubleCheckedLocking getInstance(){ // 3
if (instance==null) { // 4:第一次檢查
synchronized (DoubleCheckedLocking.class) { // 5: 加鎖
if (instance==null) { // 6:第二次檢查
instance=new DoubleCheckedLocking(); // 7: 問題的根源出在這裏
}
}
}
return instance;
}
}
如上面代碼所示,如果第一次檢查 instance
不爲 null
,那麼就不需要執行下面的加鎖和初始化操作。因此,可以大幅降低synchronized
帶來的性能開銷。上面代碼表面上看起來兩全其美。
- 多個線程試圖在同一事件創建對象時,會通過加鎖來報正直有一個線程創建對象。
- 在對象創建好之後,執行
getInstance()
方法不需要獲取鎖,直接返回已創建好的對象。
雙重檢查鎖定看起來很完美,但這是一個錯誤的優化!在代碼執行到第 4 行,代碼讀取到 instance
不爲 null 時,instance
引用的對象有可能還沒有完成初始化,這個問題引入下面重點知識 對象初始化流程。
二、 問題的根源
上面的雙重檢查鎖定示例代碼的第 7 行( instance = new DoubleCheckedLocking()
) 創建了一個對象。這一行代碼可以分解爲如下的 3 行僞代碼:
memory = allocate(); // 1: 分配對象的內存空間
ctorInstance(memory); // 2: 初始化對象
instance = memory; // 3: 設置instance 指向 2 分配的內存
上面 3 行僞代碼中的 2 和 3 之間,可能會被重排序。2 和 3 之間重排序會後的執行如下:
memory = allocate(); // 1: 分配對象的內存空間
instance = memory; // 3: 設置instance 指向 2 分配的內存
// 注意,此時對象還沒有被初始化
ctorInstance(memory); // 2: 初始化對象
跟據Java語言規範,所有線程在執行Java程序時必須遵守 intra-thread semantics 。intra-thread semantics 保證重排序不會改變單線程內的程序執行結果。換句話說,intra-thread semantics 允許那些在單線程內,不會改變單線程程序執行結果的重排序。上面 3 行僞代碼的 2 和 3 之間雖然被重排序了,但這個重排序並不會違反 intra-thread semantics 。這個重排序在沒有改變單線程程序執行結果的前提下,可以提高程序的執行性能。
爲了更好地理解 intra-thread semantics ,可以看下圖(假設一個 線程A 在構造對象後,立即訪問這個對象):
如上圖,只要保證 2 排在 4 的前面,即使 2 和 3 之間重排序了,也不會違反 intranet-thread semantics。
下面我們看看多線程併發執行的情況,如下圖:
由於單線程內要遵守 intra-thread semantice , 從而能保證 A線程 的執行結果不會被改變。但是,當 線程A 和 線程B 按上圖的時序執行時,B線程 將看到一個還沒有被初始化的對象。
再回到 DoubleCheckedLocking
示例代碼的第 7 行( instance=new DoubleCheckedLocking()
) 如果發生重排序,另一個併發執行的 線程B 就有可能在第 4 行( if (instance==null)
) 判斷 instance
不爲null
。線程B 接下來將訪問instance 所引用的對象,但此時這個對象可能還沒有被 線程A 初始化!!!
在知曉了問題發生的根源之後,我們可以想出兩個辦法來實現線程安全的延遲初始化:
- 不允許 2 和 3重排序。
- 允許 2 和 3 重排序,但不允許其他線程“看到”這個重排序。
下面將對這兩種方案進行講解實現。
三、 基於volatile的解決方案
對於前面的基於雙重檢查鎖定來實現延遲初始化的方案( DoubleCheckedLocking
),只需要做一點小的修改(把 instance
聲明爲volatile
型),就可以實現線程安全的延遲初始化:
public class DoubleCheckedLocking {
private volatile static DoubleCheckedLocking instance;
public static DoubleCheckedLocking getInstance(){
if (instance==null) {
synchronized (DoubleCheckedLocking.class) {
if (instance==null) {
instance=new DoubleCheckedLocking();
}
}
}
return instance;
}
}
當聲明對象的引用爲 volatile
後,上代碼中 2 和 3 之間的重排序在多線程環境中將會禁止。
四、 基於類初始化的解決方案
JVM 在類的初始化階段(即在 Class
被加載後,且被線程使用之前),會執行類的初始化。在執行類的初始化期間,JVM會去獲取一個鎖。這個鎖可以同步多個線程對同一個類的初始化。
基於這個特性,可以實現另一種線程安全的延遲話初始化方案(Initialization On Demand Holder idiom)。
public class InstanceFactory {
private static class InstanceHolder{
public static InstanceFactory instance = new InstanceFactory();
}
public static InstanceFactory getInstace(){
return InstanceHolder.instance; // 這裏將導致InstanceHolder 類被初始化
}
}
假設兩個線程併發執行 getInstance()
方法,下面是執行的示意圖:
這個方案的實質是:允許 2 和 3 重排序,但不允許非構造線程(這裏指 線程B)“看到”這個重排序。
初始化一個類,包括執行這個類的靜態初始化和初始化在這個類中聲明的靜態字段。在首次發生下列任意一種情況時,一個類或接口類型T將被立即初始化:
- T 是一個類,而且一個T類型的實例被創建。
- T 是一個類,且T中聲明的一個靜態方法被調用。
- T 中聲明的一個靜態字段被賦值。
- T 中聲明的一個靜態字段被使用,而且這個字段不是一個常量字段。
- T 是一個頂級類,而且一個斷言語句嵌套在T內部被執行。
在 InstanceFactory
示例中,首次執行 getInstaance()
方法的現成將導致InstanceHolder
類被初始化(符合情況4)。
五、類的初始化流程
由於Java語言是多線程的,多個線程可能在同一時間嘗試去初始化同一個類或接口。因此,在Java中初始化一個類或者接口時,需要做細緻的同步處理。
Java語言規範規定,對於每一個類或者接口C,都有一個唯一的初始化鎖LC與之對應。從C到LC的映射,由JVM的具體實現去自由實現。JVM在類初始化期間會獲取這個初始化鎖,並且每個線程至少獲取一次鎖來確保這個類已經被初始化過了。
對於類或接口的初始化,Java語言規範制定了精巧而複雜的類初始化處理過程。Java初始化一個類或接口的處理過程如下(這裏對類初始化處理過程的說明,省略了與本無關的部分。同時爲了更好的說明類初始化過程中的同步處理機制,我們人爲的把類初始化的處理過程分爲了5個階段)。
第 1 階段
通過在 Class
對象上同步(即獲取 Class
對象的初始化鎖),來控制類或接口的初始化。這個獲取鎖的線程會一直等待,知道當前線程能夠獲取到這個初始化鎖。
假設 Class
對象當前還沒有被初始化(初始化狀態state
,此時被標記爲 state = noInitialization
),且有兩個線程A 和 B 試圖同時初始化這個 Class
對象。如圖:
第 1 階段的執行時序表:
時間 | 線程 A | 線程B |
---|---|---|
t1 | A1: 嘗試獲取Class對象的初始化鎖。這裏假設線程A獲取到了初始化鎖 | B1: 嘗試獲取Class對象的初始化鎖,由於線程A獲取到了鎖,線程B將一直等待獲取初始化鎖 |
t2 | A2: 線程A看到對象還未被初始化(因爲讀取到state = noInitialization ),線程A設置state = initializing |
|
t3 | A3: 線程A釋放初始化鎖 |
第 2 階段
線程A 執行類的初始化,同時線程B 在初始化鎖對應的condition 上等待。
第 2 階段的執行時序表:
時間 | 線程 A | 線程B |
---|---|---|
t1 | A1: 執行類的靜態初始化和初始化類中聲明的靜態字段 | B1: 獲取到初始化鎖 |
t2 | B2: 讀取到 state = initializing |
|
t3 | B3: 釋放初始化鎖 | |
t4 | B4: 在初始化鎖的condition等待 |
第 3 階段
線程A 設置 state = initialized
,然後喚醒在 condition
中等待的所有線程。
第 3 階段的執行時序表:
時間 | 線程 A |
---|---|
t1 | A1: 獲取初始化鎖 |
t2 | A2: 設置 state = initialized |
t3 | A3: 喚醒在condition中等待的所有線程 |
t4 | A4: 釋放初始化鎖 |
t5 | A5: 線程A的初始化處理過程完成 |
第 4 階段
線程B 結束類的初始化處理
第 4 階段的執行時序表:
時間 | 線程 B |
---|---|
t1 | B1: 獲取初始化鎖 |
t2 | B2: 讀取到 state = initialized |
t3 | B3: 釋放初始化鎖 |
t4 | B4: 線程B的類初始化處理過程完成 |
線程A 在第 2 階段的A1執行類的初始化,並在第 3 階段的A4釋放初始化鎖;線程B在第 4 階段的B1獲取同一個初始化鎖,並在第 4 階段的B4之後纔開始訪問這個類。
根據Java內存模型規範的鎖規則,這裏將存在如下的happens-before關係:線程A 執行類的初始化時的寫入操作(執行類的靜態初始化和初始化類中的聲明的靜態字段),線程B一定能看到。
第 5 階段
線程C執行類的初始化的處理
第 5 階段的執行時序表:
時間 | 線程 C |
---|---|
t1 | C1: 獲取初始化鎖 |
t2 | C2: 讀取到 state = initialized |
t3 | C3: 釋放初始化鎖 |
t4 | C4: 線程B的類初始化處理過程完成 |
在第 3 階段之後,類已經完成了初始化。因此線程C 在第 5 階段的類初始化處理過程相對簡單一些(前面的線程A 和 B 的初始化處理過程都經歷了兩次獲取-釋放鎖,而線程C 的類初始化處理只需要經歷一次鎖獲取-釋放)。
注意: 這裏的
condition
和state
標記是本文虛構的,Java語言規範並沒有硬性規定一定要使用condition
和state
標記。JVM的具體實現只要實現類似的功能即可。
六、總結
通過對比基於 volatile
的雙重檢查鎖定的方案和基於類初始化的方案,我們會發現基於類初始化的方案的實現代碼更簡潔。但基於 volatile
的雙重檢查鎖定的方案有一個優勢:除了可以對靜態字段實現延遲初始化外,還可以對實例字段實現延遲初始化。
字段延遲初始化降低了初始化類或創建實例的開銷,但增加了訪問被延遲化字段的開銷。在大多數的時候,正常的初始化要優於延遲初始化。如果確實需要對實例字段使用線程安全的延遲初始化,請使用上面介紹的基於volatile的延遲初始化方案;如果確實需要對靜態字段使用線程安全的延遲初始化,請使用上面介紹的基於類初始化方案。
本文選自《Java併發編程的藝術》