Chapter 1-7. DCL and Lazy Initialization
Last updated
Last updated
在Java程式中, 有時候可能需要延遲一些高開銷的物件初始化操作, 並且只有在使用這些物件時才進行初始化. 此時開發者可能會採用惰性初始化(Lazy Initialization). 但是要正確實作執行緒安全的惰性初始化需要一些技巧, 否則很容易出現問題. 譬如, 以下是一個非執行緒安全的惰性初始化物件的範例:
在UnsafeLazyInitialization中, 假設執行緒A執行1的同時, 執行緒B執行2. 此時, 執行緒A可能會看到instance參照的物件還沒有完成初始化(出現這種情況的原因後面會提到, 即問題的根源).
對於UnsafeLazyInitialization中, 我們可以對getInstance()做同步處理來實現執行緒安全的惰性初始化. 範例程式如下: 由於對getInstance()進行了同步處理, synchronized將會導致額外的性能開銷. 若getInstance()被多個執行緒頻繁的呼叫, 將會導致程式執行效能的下降. 反之, 若getInstance()不會被多個執行緒頻繁的呼叫, 那麼這個惰性初始化方案將能提供令人滿意的性能.
在早期的JVM中, synchronized(甚至是race-free的synchronized)存在著巨大的性能開銷. 為此, 人們想出了一個比較聰明的技巧: 雙重檢查鎖定(double-checked locking, a.k.a DCL). 人們想通過DCL來降低同步的開銷. 以下程式片段就是用DCL來實作惰性初始化的範例:
如上面的範例所示, 若第一次檢查instance不為null, 那麼就不需要執行下面的加鎖和初始化操作. 因此可以大幅降低synchronized帶來的性能開銷. 上面的程式在表面上看起來似乎很完美, 因為:
其在多個執行緒試圖於同一個時間點建立物件時, 會通過加鎖來保證只有一個執行緒能創造物件.
在物件創造好之後, 執行getInstance()將不需要獲取鎖, 直接回傳已經創造好的物件.
DCL看起來似乎很完美, 但這其實不是一個正確的最佳化. 在執行緒執行到第一個null check (first time check)時, instance參照的物件有可能還沒有完成初始化.
前面的DCL範例的"root cause here"該行建立了一個物件. 這一行程式可以分解為以下的三行pseudo code: 上面三行pseudo code的2和3之間, 有可能會被重排序(在某些JIT compiler上, 這種重排序是真實發生的, 詳情請見此文獻的"Out-of-order writes"部分. 2與3之間重排序後的執行時序如下: 根據<The Java Language Specification, Java SE 7 Edition>(後面簡稱Java語言規範), 所有執行緒在執行Java程式時必須要遵守intra-thread semantics. intra-thread semantics保證重排序不會改變單執行緒內的程式執行結果. 換句話來說, intra-thread semantics允許那些在單執行緒內, 不會改變單執行緒程式執行結果的重排序.上面三行pseudo code的2跟3之間雖然被重排序了, 但這個重排序並不會違反intra-thread semantics. 這個重排序在沒有改變單執行緒程式的執行結果之前提下, 可以提高程式效能.
為了更好的理解intra-thread semantics, 請看以下示意圖(假設一個執行緒A在建構物件後, 立刻存取此物件): 如上圖所示, 這裡儘管2和3重排序了, 但是只要保證2排在4之前, 單執行緒內的執行結果並不會被改變, 也不會違反intra-thread semantics.
再來看一下多執行緒並發執行時的情況, 如以下示意圖(紅色虛線表示錯誤的讀取操作):
由於單執行緒內要遵守intra-thread semantics, 從而能保證執行緒A的程式執行結果不會被改變. 但是當執行緒A和B按上圖的時序執行時, 執行緒B將看到一個還沒有被初始化的物件.
回到本文主題, UnsafeDoubleCheckedLocking範例中, root cause的部分, 若發生了重排序, 另一個執行緒B就有可能在第一個null check的時候判斷instance不為null. 故執行緒B接下來將會存取instance所參照的物件, 但此時這個物件可能還沒有被執行緒A初始化.下表是這個情境下的具體執行時序:
此處A2與A3雖然重排序了, 但JMM的intra-thread semantics將確保A2一定會排在A4之前執行. 因此執行緒A的intra-thread semantics沒有改變. 但A2和A3的重排序, 將導致執行緒B在B1處判斷出instance不為空, 執行緒B接下來將存取instance參照的物件. 此時, 執行緒B將會存取到一個還未初始化的物件.
在瞭解問題發生的根源後, 我們可以用兩種方式來實作執行緒安全的惰性初始化:
不允許2跟3進行重排序
允許2跟3重排序, 但不允許其他執行緒看到這個重排序
接下來要介紹的兩個解決方案, 分別對應到以上這兩種方式.
要注意的是, 這個方案需要JDK5或更高的版本, 因為從JDK5開始使用了新的JSR-133規範, 這個規範增強了volatile的語意.
此方案在本質上是通過禁止上圖中的2與3之間進行重排序, 來保證執行緒安全的惰性初始化.
JVM在類別的初始化階段(即在class被載後, 且被執行緒使用之前), 會執行類別的初始化. 在執行類別的初始化期間, JVM會去獲取一個lock. 這個lock可以同步多個執行緒對同一個類別的初始化.
初始化一個類別, 包含執行這個類別的靜態初始化和初始化在這個類別中宣告的靜態欄位(static field). 根據Java語言規範, 在首次發生下列任意一種情況時, 一個類別或介面類型T將立刻被初始化:
T是一個類別, 且一個T類型的實例被建立
T是一個類別, 且T中宣告的一個靜態方法被呼叫
T中宣告的一個靜態欄位被賦值
T中宣告的一個靜態欄位被使用, 且這個欄位不是一個常數欄位
T是一個頂級類別(top level class, 參見Java語言規範的7.6), 而且一個斷言語句嵌套在T內部被執行
在InstanceFactory範例中, 首次執行getInstance()的執行緒將導致InstanceHolder類別被初始化(符合上述的第四種情況)
由於Java是多執行緒的, 多個執行緒可能在同一時間嘗試去初始化同一個類別或是介面(譬如這裡多個執行緒可能在同一時間點呼叫getInstance()來初始化InstanceHolder類別). 因此在Java中初始化一個類別或著是介面時, 需要做細緻的同步處理.
Java語言規範規定, 對於每一個類別或介面C, 都有一個唯一的初始化鎖LC與之對應. 從C到LC的映射, 由JVM的具體實作去自由實現. JVM在類別初始化期間會獲取這個初始化鎖, 並且每個執行緒至少獲取一次鎖來確保這個類別已經被初始化過了(事實上, Java語言規範允許JVM的具體實作在這裡做一些最佳化, 之後會說明).
對於類別或介面的初始化, Java語言規範制定了精巧而複雜的類別初始化處理過程. Java初始化一個類別或介面的處理過程如下(這裡對類別初始化處理過程的說明, 省略了與本文無關的部分; 同時為了更好的說明類別初始化過程中的同步處理機制, 這邊就人為的把類別初始化的處理過程分成了五個階段):
通過對比基於volatile的DCL和基於類別初始化的解決方案, 我們會發現基於類別初始化的方案其實更為簡潔. 但基於volatile的DCL有一個額外的優勢: 除了可以對static field實現惰性初始化之外, 還可以對instance field實現惰性初始化.
惰性初始化降低了初始化類別或是建立實例的開銷, 但增加了存取被惰性初始化的field之開銷. 在大多數時候, 正常的初始化要優於惰性初始化. 如果確實需要對instance field使用執行緒安全的惰性初始化, 可以使用基於volatile的惰性初始化方案; 若確實需要對static field使用執行緒安全的初始化, 可以使用基於類別初始化的方案.
註1: 此處的condition/state標記是文中虛構出來的. Java語言規格並沒有硬性規定一定要用condition/state標記. JVM的實現只要實作類似功能即可.
註2: Java語言規範允許Java的具體實現去最佳化類別的初始化處理過程(即對上述的第五階段進行最佳化), 具體細節可參考Java語言規範的12.4.2章節.
對於前面的基於DCL來實作惰性初始化的方案(即UnsafeDoubleCheckedLocking), 我們只需要做一點小修正(把instance宣告為volatile類型), 即可實現執行緒安全的惰性初始化. 請見下方範例:
當宣告物件的參照為volatile之後, 在問題的根源中的三行pseudo code的2與3之間的重排序, 在多執行緒環境中將會被禁止. 以上範例程式將按以下的時序執行:
基於此特性, 可以實現另一種執行緒安全的惰性初始化方案(此方案被稱之為Initialization On Demand Holder idiom): 假設兩個執行緒並發執行getInstance(), 以下是執行的示意圖: 此方案的本質是: 允許問題根源中的2與3進行重排序, 但不允許非建構子執行緒(圖中的執行緒B)看到這個重排序.
第一階段: 通過在class物件上同步(即獲取class物件的初始化鎖), 來控制類別或介面的初始化. 這個獲取鎖的執行緒會一直等待, 直到當前執行緒能夠獲取到這個初始化鎖. 假設class物件當前還沒有被初始化(初始化狀態state此時被標記為 state = noInitialization), 且有兩個執行緒A與B試圖同時初始化這個class物件, 以下是對應的示意圖: 以下是這張示意圖的逐步說明:
第二階段: 執行緒A執行類別的初始化, 同時執行緒B在初始化鎖對應的condition上等待: 以下是這張示意圖的逐步說明:
第三階段: 執行緒A設置state = initialized, 並且喚醒在condition中等待的所有執行緒: 以下是這張示意圖的逐步說明:
第四階段: 執行緒B結束類別的初始化處理過程: 以下是這張示意圖的逐步說明: 執行緒A在第二階段的A1執行類別的初始化, 並在第三階段的A4釋放初始化鎖;執行緒B在第四階段的B1獲取同一個初始化鎖, 並在第四階段的B4之後才開始存取這個類別. 根據JMM的鎖規則, 這裡將存在如下的happens-before關係: 這個happens-before關係將保證:執行緒A執行類別的初始化時的寫入操作(執行類別的靜態初始化與初始化類別中宣告的靜態欄位), 執行緒B必定能看到.
第五階段: 執行緒C執行類別的初始化的處理: 以下是這張示意圖的逐步說明: 在第三階段之後, 類別已經完成了初始化. 因此執行緒C在第五階段的類別初始化處理過程相對簡單許多 (前面的執行緒A與B的類別初始化處理過程都經歷了兩次的鎖獲取-鎖釋放, 而執行緒C只需要經歷一次的鎖獲取-鎖釋放).
執行緒A在第二階段的A1執行類別的初始化, 並在第三階段的A4釋放鎖; 執行緒C在第五階段的C1獲取同一個鎖, 並在第五階段的C4之後才開始存取這個類別. 根據JMM的鎖規則, 這裡會存在如下的happens-before關係: 這個happens-before關係將保證: 執行緒A執行類別的初始化時的寫入操作, 執行緒C一定能看到.