在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。