雙重檢查鎖定與延遲初始化
雙重檢查鎖定的由來
在java程式中,有時候可能需要推遲一些高開銷的物件初始化操作,並且只有在使用這些物件時才進行初始化。此時程式設計師可能會採用延遲初始化。但要正確實現執行緒安全的延遲初始化需要一些技巧,否則很容易出現問題。比如,下面是非執行緒安全的延遲初始化物件的示例程式碼:
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的同時,B執行緒執行程式碼2。此時,執行緒A可能會看到instance引用的物件還沒有完成初始化(出現這種情況的原因見後文的“問題的根源”)。
對於UnsafeLazyInitialization,我們可以對getInstance()做同步處理來實現執行緒安全的延遲初始化。示例程式碼如下:
遲初始化。示例程式碼如下:
public 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)。人們想通過雙重檢查鎖定來降低同步的開銷。下面是使用雙重檢查鎖定來實現延遲初始化的示例程式碼:
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 } //12
如上面程式碼所示,如果第一次檢查instance不為null,那麼就不需要執行下面的加鎖和初始化操作。因此可以大幅降低synchronized帶來的效能開銷。上面程式碼表面上看起來,似乎兩全其美:
- 在多個執行緒試圖在同一時間建立物件時,會通過加鎖來保證只有一個執行緒能建立物件。
- 在物件建立好之後,執行getInstance()將不需要獲取鎖,直接返回已建立好的物件。
雙重檢查鎖定看起來似乎很完美,但這是一個錯誤的優化!線上程執行到第4行程式碼讀取到instance不為null時,instance引用的物件有可能還沒有完成初始化。
問題的根源
前面的雙重檢查鎖定示例程式碼的第7行(instance = new Singleton();)建立一個物件。這一行程式碼可以分解為如下的三行虛擬碼:
memory = allocate(); //1:分配物件的記憶體空間 ctorInstance(memory); //2:初始化物件 instance = memory; //3:設定instance指向剛分配的記憶體地址
上面三行虛擬碼中的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允許那些在單執行緒內,不會改變單執行緒程式執行結果的重排序。上面三行虛擬碼的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型),就可以實現執行緒安全的延遲初始化。請看下面的示例程式碼:
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; } }
注意,這個解決方案需要JDK5或更高版本(因為從JDK5開始使用新的JSR-133記憶體模型規範,這個規範增強了volatile的語義)。
當宣告物件的引用為volatile後,“問題的根源”的三行虛擬碼中的2和3之間的重排序,在多執行緒環境中將會被禁止。上面示例程式碼將按如下的時序執行:
這個方案本質上是通過禁止上圖中的2和3之間的重排序,來保證執行緒安全的延遲初始化。
基於類初始化的解決方案
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類被初始化 } }
假設兩個執行緒併發執行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的延遲初始化的方案;如果確實需要對靜態欄位使用執行緒安全的延遲初始化,請使用上面介紹的基於類初始化的方案。
參考文獻
感謝方騰飛對本文的審校。