
雙重檢查鎖定與延遲初始化
在 java 程式中,有時候可能需要推遲一些高開銷的物件初始化操作,并且只有在使用這些物件時才進行初始化,此時程式員可能會采用延遲初始化,但要正確實作執行緒安全的延遲初始化需要一些技巧,否則很容易出現問題,比如,下面是非執行緒安全的延遲初始化物件的示例代碼:
COPYpublic 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 的同時,B 執行緒執行代碼 2,此時,執行緒 A 可能會看到 instance 參考的物件還沒有完成初始化(出現這種情況的原因見后文的“問題的根源”),
對于 UnsafeLazyInitialization,我們可以對 getInstance() 做同步處理來實作執行緒安全的延遲初始化,示例代碼如下:
COPYpublic class SafeLazyInitialization {
private static Instance instance;
public synchronized static Instance getInstance() {
if (instance == null)
instance = new Instance();
return instance;
}
}
由于對 getInstance() 做了同步處理,synchronized 將導致性能開銷,如果 getInstance() 被多個執行緒頻繁的呼叫,將會導致程式執行性能的下降,反之,如果 getInstance() 不會被多個執行緒頻繁的呼叫,那么這個延遲初始化方案將能提供令人滿意的性能,
在早期的 JVM 中,synchronized(甚至是無競爭的 synchronized)存在這巨大的性能開銷,因此,人們想出了一個“聰明”的技巧:雙重檢查鎖定(double-checked locking),人們想通過雙重檢查鎖定來降低同步的開銷,下面是使用雙重檢查鎖定來實作延遲初始化的示例代碼:
COPYpublic 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
} //12
如上面代碼所示,如果第一次檢查 instance 不為 null,那么就不需要執行下面的加鎖和初始化操作,因此可以大幅降低 synchronized 帶來的性能開銷,上面代碼表面上看起來,似乎兩全其美:
- 在多個執行緒試圖在同一時間創建物件時,會通過加鎖來保證只有一個執行緒能創建物件,
- 在物件創建好之后,執行 getInstance() 將不需要獲取鎖,直接回傳已創建好的物件,
雙重檢查鎖定看起來似乎很完美,但這是一個錯誤的優化!在執行緒執行到第 4 行代碼讀取到 instance 不為 null 時,instance 參考的物件有可能還沒有完成初始化,
問題的根源
前面的雙重檢查鎖定示例代碼的第 7 行(instance = new Singleton();)創建一個物件,這一行代碼可以分解為如下的三行偽代碼:
COPYmemory = allocate(); //1:分配物件的記憶體空間
ctorInstance(memory); //2:初始化物件
instance = memory; //3:設定 instance 指向剛分配的記憶體地址
上面三行偽代碼中的 2 和 3 之間,可能會被重排序(在一些 JIT 編譯器上,這種重排序是真實發生的,詳情見參考文獻 1 的“Out-of-order writes”部分),2 和 3 之間重排序之后的執行時序如下:
COPYmemory = 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 允許那些在單執行緒內,不會改變單執行緒程式執行結果的重排序,上面三行偽代碼的 2 和 3 之間雖然被重排序了,但這個重排序并不會違反 intra-thread semantics,這個重排序在沒有改變單執行緒程式的執行結果的前提下,可以提高程式的執行性能,
為了更好的理解 intra-thread semantics,請看下面的示意圖(假設一個執行緒 A 在構造物件后,立即訪問這個物件):

如上圖所示,只要保證 2 排在 4 的前面,即使 2 和 3 之間重排序了,也不會違反 intra-thread semantics,
下面,再讓我們看看多執行緒并發執行的時候的情況,請看下面的示意圖:

由于單執行緒內要遵守 intra-thread semantics,從而能保證 A 執行緒的程式執行結果不會被改變,但是當執行緒 A 和 B 按上圖的時序執行時,B 執行緒將看到一個還沒有被初始化的物件,
注:本文統一用紅色的虛箭線標識錯誤的讀操作,用綠色的虛箭線標識正確的讀操作,
回到本文的主題,DoubleCheckedLocking 示例代碼的第 7 行(instance = new Singleton();)如果發生重排序,另一個并發執行的執行緒 B 就有可能在第 4 行判斷 instance 不為 null,執行緒 B 接下來將訪問 instance 所參考的物件,但此時這個物件可能還沒有被 A 執行緒初始化!下面是這個場景的具體執行時序:
| 時間 | 執行緒 A | 執行緒 B |
|---|---|---|
| t1 | A1:分配物件的記憶體空間 | |
| t2 | A3:設定 instance 指向記憶體空間 | |
| t3 | B1:判斷 instance 是否為空 | |
| t4 | B2:由于 instance 不為 null,執行緒 B 將訪問 instance 參考的物件 | |
| t5 | A2:初始化物件 | |
| t6 | A4:訪問 instance 參考的物件 |
這里 A2 和 A3 雖然重排序了,但 java 記憶體模型的 intra-thread semantics 將確保 A2 一定會排在 A4 前面執行,因此執行緒 A 的 intra-thread semantics 沒有改變,但 A2 和 A3 的重排序,將導致執行緒 B 在 B1 處判斷出 instance 不為空,執行緒 B 接下來將訪問 instance 參考的物件,此時,執行緒 B 將會訪問到一個還未初始化的物件,
在知曉了問題發生的根源之后,我們可以想出兩個辦法來實作執行緒安全的延遲初始化:
- 不允許 2 和 3 重排序;
- 允許 2 和 3 重排序,但不允許其他執行緒“看到”這個重排序,
volatile解決方案
對于前面的基于雙重檢查鎖定來實作延遲初始化的方案(指 DoubleCheckedLocking 示例代碼),我們只需要做一點小的修改(把 instance 宣告為 volatile 型),就可以實作執行緒安全的延遲初始化,請看下面的示例代碼:
COPYpublic 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;
}
}
注意,這個解決方案需要 JDK5 或更高版本(因為從 JDK5 開始使用新的 JSR-133 記憶體模型規范,這個規范增強了 volatile 的語意),
當宣告物件的參考為 volatile 后,“問題的根源”的三行偽代碼中的 2 和 3 之間的重排序,在多執行緒環境中將會被禁止,上面示例代碼將按如下的時序執行:

這個方案本質上是通過禁止上圖中的 2 和 3 之間的重排序,來保證執行緒安全的延遲初始化,
基于類初始化的解決方案
JVM 在類的初始化階段(即在 Class 被加載后,且被執行緒使用之前),會執行類的初始化,在執行類的初始化期間,JVM 會去獲取一個鎖,這個鎖可以同步多個執行緒對同一個類的初始化,
基于這個特性,可以實作另一種執行緒安全的延遲初始化方案(這個方案被稱之為 Initialization On Demand Holder idiom):
COPYpublic class InstanceFactory {
private static class InstanceHolder {
public static Instance instance = new Instance();
}
public static Instance getInstance() {
return InstanceHolder.instance ; // 這里將導致 InstanceHolder 類被初始化
}
}
假設兩個執行緒并發執行 getInstance(),下面是執行的示意圖:

這個方案的實質是:允許“問題的根源”的三行偽代碼中的 2 和 3 重排序,但不允許非構造執行緒(這里指執行緒 B)“看到”這個重排序,
初始化一個類,包括執行這個類的靜態初始化和初始化在這個類中宣告的靜態欄位,根據 java 語言規范,在首次發生下列任意一種情況時,一個類或介面型別 T 將被立即初始化:
- T 是一個類,而且一個 T 型別的實體被創建;
- T 是一個類,且 T 中宣告的一個靜態方法被呼叫;
- T 中宣告的一個靜態欄位被賦值;
- T 中宣告的一個靜態欄位被使用,而且這個欄位不是一個常量欄位;
- 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 初始化一個類或介面的處理程序如下(這里對類初始化處理程序的說明,省略了與本文無關的部分;同時為了更好的說明類初始化程序中的同步處理機制,筆者人為的把類初始化的處理程序分為了五個階段):
第一階段
第一階段:通過在 Class 物件上同步(即獲取 Class 物件的初始化鎖),來控制類或介面的初始化,這個獲取鎖的執行緒會一直等待,直到當前執行緒能夠獲取到這個初始化鎖,
假設 Class 物件當前還沒有被初始化(初始化狀態 state 此時被標記為 state = noInitialization),且有兩個執行緒 A 和 B 試圖同時初始化這個 Class 物件,下面是對應的示意圖:

下面是這個示意圖的說明:
| 時間 | 執行緒 A | 執行緒 B |
|---|---|---|
| t1 | A1: 嘗試獲取 Class 物件的初始化鎖,這里假設執行緒 A 獲取到了初始化鎖 | B1: 嘗試獲取 Class 物件的初始化鎖,由于執行緒 A 獲取到了鎖,執行緒 B 將一直等待獲取初始化鎖 |
| t2 | A2:執行緒 A 看到執行緒還未被初始化(因為讀取到 state == noInitialization),執行緒設定 state = initializing | |
| t3 | A3:執行緒 A 釋放初始化鎖 |
第二階段
第二階段:執行緒 A 執行類的初始化,同時執行緒 B 在初始化鎖對應的 condition 上等待:

下面是這個示意圖的說明:
| 時間 | 執行緒 A | 執行緒 B |
|---|---|---|
| t1 | A1: 執行類的靜態初始化和初始化類中宣告的靜態欄位 | B1:獲取到初始化鎖 |
| t2 | B2:讀取到 state == initializing | |
| t3 | B3:釋放初始化鎖 | |
| t4 | B4:在初始化鎖的 condition 中等待 |
第三階段
第三階段:執行緒 A 設定 state = initialized,然后喚醒在 condition 中等待的所有執行緒:

下面是這個示意圖的說明:
| 時間 | 執行緒 A |
|---|---|
| t1 | A1:獲取初始化鎖 |
| t2 | A2:設定 state = initialized |
| t3 | A3:喚醒在 condition 中等待的所有執行緒 |
| t4 | A4:釋放初始化鎖 |
| t5 | A5:執行緒 A 的初始化處理程序完成 |
第四階段
第四階段:執行緒 B 結束類的初始化處理:

下面是這個示意圖的說明:
| 時間 | 執行緒 B |
|---|---|
| t1 | B1:獲取初始化鎖 |
| t2 | B2:讀取到 state == initialized |
| t3 | B3:釋放初始化鎖 |
| t4 | B4:執行緒 B 的類初始化處理程序完成 |
執行緒 A 在第二階段的 A1 執行類的初始化,并在第三階段的 A4 釋放初始化鎖;執行緒 B 在第四階段的 B1 獲取同一個初始化鎖,并在第四階段的 B4 之后才開始訪問這個類,根據 java 記憶體模型規范的鎖規則,這里將存在如下的 happens-before 關系:

這個 happens-before 關系將保證:執行緒 A 執行類的初始化時的寫入操作(執行類的靜態初始化和初始化類中宣告的靜態欄位),執行緒 B 一定能看到,
第五階段
第五階段:執行緒 C 執行類的初始化的處理:

下面是這個示意圖的說明:
| 時間 | 執行緒 B |
|---|---|
| t1 | C1:獲取初始化鎖 |
| t2 | C2:讀取到 state == initialized |
| t3 | C3:釋放初始化鎖 |
| t4 | C4:執行緒 C 的類初始化處理程序完成 |
在第三階段之后,類已經完成了初始化,因此執行緒 C 在第五階段的類初始化處理程序相對簡單一些(前面的執行緒 A 和 B 的類初始化處理程序都經歷了兩次鎖獲取 - 鎖釋放,而執行緒 C 的類初始化處理只需要經歷一次鎖獲取 - 鎖釋放),
執行緒 A 在第二階段的 A1 執行類的初始化,并在第三階段的 A4 釋放鎖;執行緒 C 在第五階段的 C1 獲取同一個鎖,并在在第五階段的 C4 之后才開始訪問這個類,根據 java 記憶體模型規范的鎖規則,這里將存在如下的 happens-before 關系:
這個 happens-before 關系將保證:執行緒 A 執行類的初始化時的寫入操作,執行緒 C 一定能看到,
注 1:這里的 condition 和 state 標記是本文虛構出來的,Java 語言規范并沒有硬性規定一定要使用 condition 和 state 標記,JVM 的具體實作只要實作類似功能即可,
注 2:Java 語言規范允許 Java 的具體實作,優化類的初始化處理程序(對這里的第五階段做優化),具體細節參見 java 語言規范的 12.4.2 章,
通過對比基于 volatile 的雙重檢查鎖定的方案和基于類初始化的方案,我們會發現基于類初始化的方案的實作代碼更簡潔,但基于 volatile 的雙重檢查鎖定的方案有一個額外的優勢:除了可以對靜態欄位實作延遲初始化外,還可以對實體欄位實作延遲初始化,
總結
延遲初始化降低了初始化類或創建實體的開銷,但增加了訪問被延遲初始化的欄位的開銷,在大多數時候,正常的初始化要優于延遲初始化,如果確實需要對實體欄位使用執行緒安全的延遲初始化,請使用上面介紹的基于 volatile 的延遲初始化的方案;如果確實需要對靜態欄位使用執行緒安全的延遲初始化,請使用上面介紹的基于類初始化的方案,
本文由
傳智教育博學谷教研團隊發布,如果本文對您有幫助,歡迎
關注和點贊;如果您有任何建議也可留言評論或私信,您的支持是我堅持創作的動力,轉載請注明出處!
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/542844.html
標籤:Java
