來源:《The "Double-Checked Locking is Broken" Declaration》
1. 單例模式的簡單實現
// 只支援單線程的版本class Foo { private Helper helper = null; public Helper getHelper() { if (helper == null) helper = new Helper(); return helper; }}
在多線程的情況下,可能會產生多個Helper執行個體。
2. 同步getHelper方法
// 支援多線程的版本class Foo { private Helper helper = null; public synchronized Helper getHelper() { if (helper == null) helper = new Helper(); return helper; }}
getHelper()方法被標記為synchronized後,JVM會只允許同時只有一個線程能執行getHelper方法。所以不會產生多個Helper執行個體。但是同步的開銷會導致多線程執行效率降低。
3. 一種錯誤的雙檢鎖方法
// 一種錯誤的雙檢鎖方法class Foo { private Helper helper = null; public Helper getHelper() { if (helper == null) synchronized(this) { if (helper == null) helper = new Helper(); } return helper; }}
為什麼是錯的。
原因:在 helper = new Helper(); 這句中有兩個主要操作。一是建立Helper的一個執行個體,二是將這個新建立的Helper執行個體的引用賦給helper這個欄位。編譯器規定這兩個操作的順序可能是先賦值執行個體的引用,後執行Helper執行個體內部的初始化構建。這可能導致另一線程在調用getHelper()方法時,因為helper欄位不為null,所以開始使用helper所指的執行個體,而該執行個體的初始化工作卻仍在前一線程中處於未完成狀態,這就導致第二個線程使用了一個“壞”的Helper。
即使編譯器事先規定了先構建執行個體後賦值,在一個多處理器系統中,處理器和記憶體系統還是有可能顛倒這兩個操作的順序。
4. 另一種錯誤的雙檢鎖方法
// 另一種錯誤的雙檢鎖方法class Foo { private Helper helper = null;wei public Helper getHelper() { if (helper == null) { Helper h; synchronized(this) { h = helper; if (h == null) synchronized (this) { h = new Helper(); } // 釋放內部的 synchronized 鎖 helper = h; } } return helper; }}
為什麼是錯的。
原因:退出監控(monitorexit)(如釋放 sychronized 鎖)的規則是“在 monitorexit 前的操作必須在釋放鎖之前執行”。但是沒有規則保證“在 monitorexit 後的操作必須在釋放鎖之後才能執行”。也就是說編譯器可能會把 helper = h; 這句移到內部的 synchronized 代碼塊內。這就又回到了3中的情況,即其它線程可能拿到一個“壞”的未完工的Helper執行個體。
註:在.Net CLR中情況有所不同。在CLR中,任何鎖方法的調用都構成了一個完整的記憶體柵欄,在柵欄之前寫入的任何變數都必須在柵欄之前完成;在柵欄之後的任何變數讀取都必須在柵欄之後開始。
同步用的越多,越有可能導致效能問題,也增大了出錯的可能。
另外每個處理器都緩衝了的變數值的備份,在某些類型的處理器中,即使其它處理器利用記憶體柵欄(memory barriers)將新值寫入了共用記憶體,因為處理器用的是它自己的備份值,還是會認為helper值為null,導致建立了Helper執行個體,並使用了這個新執行個體。(Alpha處理器)
5. 可以對32位的原始類型資料變數用雙檢鎖(如int和float)
因為原始類型資料的變數存的就是值本身,所以對它賦值就直接改了變數內容。但是64位的原始類型資料變數,如long和double,就無法保證該操作的原子性。
class Foo { private int cachedHashCode = 0; public int hashCode() { int h = cachedHashCode; if (h == 0) synchronized(this) { if (cachedHashCode != 0) return cachedHashCode; h = computeHashCode(); cachedHashCode = h; } return h; }}
6. 利用執行緒區域儲存的雙檢鎖方法
class Foo { // 如果 perThreadInstance.get() 返回非null的值,說明已經有線程執行過該方法,helper已經被初始化好了 private final ThreadLocal perThreadInstance = new ThreadLocal(); private Helper helper = null; public Helper getHelper() { if (perThreadInstance.get() == null) createHelper(); return helper; } private final void createHelper() { synchronized(this) { if (helper == null) helper = new Helper(); } // 任何非空的值都可作為set的參數 perThreadInstance.set(perThreadInstance); }}
該方法的效率取決於JDK中ThreadLocal的實現。
7. 利用volatile的雙檢鎖方法
從JDK5開始,volatile嚴格地限制了變數的讀寫順序,不允許重排。
class Foo { private volatile Helper helper = null; public Helper getHelper() { if (helper == null) { synchronized(this) { if (helper == null) helper = new Helper(); } } return helper; }}
對不可變對象(Immutable Objects)引用的讀寫都是原子性的操作。所以如果Helper是不可變對象,如String和Integer,可以不用volatile關鍵字。
8. 總結:
盡量參考使用已有的最佳實務,不要自己去發明。
一門合適的程式設計語言應該是優雅的。如果發現已有的問題很難用優雅的方式解決,要麼換一種語言,要麼發出聲音,讓開發這門語言的人從源頭改進該語言。
對於絕大多數只是使用語言的你,不要為了研究語言而研究,應該基於現有的業務問題去研究。多接觸各種業務,自然就會多遇到各種問題,自然有機會學得更多。
《C# 單例模式整理》