有關“雙重檢查鎖定失效”的說明
原文地址 譯者:丁一
雙重檢查鎖定(以下稱為DCL)已被廣泛當做多執行緒環境下延遲初始化的一種高效手段。
遺憾的是,在Java中,如果沒有額外的同步,它並不可靠。在其它語言中,如c++,實現DCL,需要依賴於處理器的記憶體模型、編譯器實行的重排序以及編譯器與同步庫之間的互動。由於c++沒有對這些做出明確規定,很難說DCL是否有效。可以在c++中使用顯式的記憶體屏障來使DCL生效,但Java中並沒有這些屏障。
來看下面的程式碼
// Single threaded version class Foo { private Helper helper = null; public Helper getHelper() { if (helper == null) helper = new Helper(); return helper; } // other functions and members... }
如果這段程式碼用在多執行緒環境下,有幾個可能出錯的地方。最明顯的是,可能會創建出兩或多個Helper物件。(後面會提到其它問題)。將getHelper()方法改為同步即可修復此問題。
// Correct multithreaded version class Foo { private Helper helper = null; public synchronized Helper getHelper() { if (helper == null) helper = new Helper(); return helper; } // other functions and members... }
上面的程式碼在每次呼叫getHelper時都會執行同步操作。DCL模式旨在消除helper物件被建立後還需要的同步。
// Broken multithreaded version // "Double-Checked Locking" idiom class Foo { private Helper helper = null; public Helper getHelper() { if (helper == null) synchronized(this) { if (helper == null) helper = new Helper(); } return helper; } // other functions and members... }
不幸的是,這段程式碼無論是在優化型的編譯器下還是在共享記憶體處理器中都不能有效工作。
不起作用
上面程式碼不起作用的原因有很多。接下來我們先說幾個比較顯而易見的原因。理解這些之後,也許你想找出一種方法來“修復”DCL模式。你的修復也不會起作用:這裡面有很微妙的原因。在理解了這些原因之後,可能想進一步進行修復,但仍不會正常工作,因為存在更微妙的原因。
很多聰明的人在這上面花費了很多時間。除了在每個執行緒訪問helper物件時執行鎖操作別無他法。
不起作用的第一個原因
最顯而易見的原因是,Helper物件初始化時的寫操作與寫入helper欄位的操作可以是無序的。這樣的話,如果某個執行緒呼叫getHelper()可能看到helper欄位指向了一個Helper物件,但看到該物件裡的欄位值卻是預設值,而不是在Helper構造方法裡設定的那些值。
如果編譯器將呼叫內聯到構造方法中,那麼,如果編譯器能證明構造方法不會丟擲異常或執行同步操作,初始化物件的這些寫操作與hepler欄位的寫操作之間就能自由的重排序。
即便編譯器不對這些寫操作重排序,在多處理器上,某個處理器或記憶體系統也可能重排序這些寫操作,執行在其它 處理器上的執行緒就可能看到重排序帶來的結果。
Doug Lea寫了一篇更詳細的有關編譯器重排序的文章。
展示其不起作用的測試案例
Paul Jakubik找到了一個使用DCL不能正常工作的例子。下面的程式碼做了些許整理:
public class DoubleCheckTest { // static data to aid in creating N singletons static final Object dummyObject = new Object(); // for reference init static final int A_VALUE = 256; // value to initialize 'a' to static final int B_VALUE = 512; // value to initialize 'b' to static final int C_VALUE = 1024; static ObjectHolder[] singletons; // array of static references static Thread[] threads; // array of racing threads static int threadCount; // number of threads to create static int singletonCount; // number of singletons to create static volatile int recentSingleton; // I am going to set a couple of threads racing, // trying to create N singletons. Basically the // race is to initialize a single array of // singleton references. The threads will use // double checked locking to control who // initializes what. Any thread that does not // initialize a particular singleton will check // to see if it sees a partially initialized view. // To keep from getting accidental synchronization, // each singleton is stored in an ObjectHolder // and the ObjectHolder is used for // synchronization. In the end the structure // is not exactly a singleton, but should be a // close enough approximation. // // This class contains data and simulates a // singleton. The static reference is stored in // a static array in DoubleCheckFail. static class Singleton { public int a; public int b; public int c; public Object dummy; public Singleton() { a = A_VALUE; b = B_VALUE; c = C_VALUE; dummy = dummyObject; } } static void checkSingleton(Singleton s, int index) { int s_a = s.a; int s_b = s.b; int s_c = s.c; Object s_d = s.dummy; if(s_a != A_VALUE) System.out.println("[" + index + "] Singleton.a not initialized " + s_a); if(s_b != B_VALUE) System.out.println("[" + index + "] Singleton.b not intialized " + s_b); if(s_c != C_VALUE) System.out.println("[" + index + "] Singleton.c not intialized " + s_c); if(s_d != dummyObject) if(s_d == null) System.out.println("[" + index + "] Singleton.dummy not initialized," + " value is null"); else System.out.println("[" + index + "] Singleton.dummy not initialized," + " value is garbage"); } // Holder used for synchronization of // singleton initialization. static class ObjectHolder { public Singleton reference; } static class TestThread implements Runnable { public void run() { for(int i = 0; i < singletonCount; ++i) { ObjectHolder o = singletons[i]; if(o.reference == null) { synchronized(o) { if (o.reference == null) { o.reference = new Singleton(); recentSingleton = i; } // shouldn't have to check singelton here // mutex should provide consistent view } } else { checkSingleton(o.reference, i); int j = recentSingleton-1; if (j > i) i = j; } } } } public static void main(String[] args) { if( args.length != 2 ) { System.err.println("usage: java DoubleCheckFail" + " <numThreads> <numSingletons>"); } // read values from args threadCount = Integer.parseInt(args[0]); singletonCount = Integer.parseInt(args[1]); // create arrays threads = new Thread[threadCount]; singletons = new ObjectHolder[singletonCount]; // fill singleton array for(int i = 0; i < singletonCount; ++i) singletons[i] = new ObjectHolder(); // fill thread array for(int i = 0; i < threadCount; ++i) threads[i] = new Thread( new TestThread() ); // start threads for(int i = 0; i < threadCount; ++i) threads[i].start(); // wait for threads to finish for(int i = 0; i < threadCount; ++i) { try { System.out.println("waiting to join " + i); threads[i].join(); } catch(InterruptedException ex) { System.out.println("interrupted"); } } System.out.println("done"); } }
當上述程式碼執行在使用Symantec JIT的系統上時,不能正常工作。尤其是,Symantec JIT將
singletons[i].reference = new Singleton();
編譯成了下面這個樣子(Symantec JIT用了一種基於控制代碼的物件分配系統)。
0206106A mov eax,0F97E78h 0206106F call 01F6B210 ; allocate space for ; Singleton, return result in eax 02061074 mov dword ptr [ebp],eax ; EBP is &singletons[i].reference ; store the unconstructed object here. 02061077 mov ecx,dword ptr [eax] ; dereference the handle to ; get the raw pointer 02061079 mov dword ptr [ecx],100h ; Next 4 lines are 0206107F mov dword ptr [ecx+4],200h ; Singleton's inlined constructor 02061086 mov dword ptr [ecx+8],400h 0206108D mov dword ptr [ecx+0Ch],0F84030h
如你所見,賦值給singletons[i].reference的操作在Singleton構造方法之前做掉了。在現有的Java記憶體模型下這完全是允許的,在c和c++中也是合法的(因為c/c++都沒有記憶體模型(譯者注:這篇文章寫作時間較久,c++11已經有記憶體模型了))。
一種不起作用的“修復”
基於前文解釋的原因,一些人提出了下面的程式碼:
// (Still) Broken multithreaded version // "Double-Checked Locking" idiom class Foo { private Helper helper = null; public Helper getHelper() { if (helper == null) { Helper h; synchronized(this) { h = helper; if (h == null) synchronized (this) { h = new Helper(); } // release inner synchronization lock helper = h; } } return helper; } // other functions and members... }
將建立Helper物件的程式碼放到了一個內部的同步塊中。直覺的想法是,在退出同步塊的時候應該有一個記憶體屏障,這會阻止Helper的初始化與helper欄位賦值之間的重排序。
很不幸,這種直覺完全錯了。同步的規則不是這樣的。monitorexit(即,退出同步塊)的規則是,在monitorexit前面的action必須在該monitor釋放之前執行。但是,並沒有哪裡有規定說monitorexit後面的action不可以在monitor釋放之前執行。因此,編譯器將賦值操作helper = h;挪到同步塊裡面是非常合情合理的,這就回到了我們之前說到的問題上。許多處理器提供了這種單向的記憶體屏障指令。如果改變鎖釋放的語義 —— 釋放時執行一個雙向的記憶體屏障 —— 將會帶來效能損失。
更多不起作用的“修復”
可以做些事情迫使寫操作的時候執行一個雙向的記憶體屏障。這是非常重量級和低效的,且幾乎可以肯定一旦Java記憶體模型修改就不能正確工作了。不要這麼用。如果對此感興趣,我在另一個網頁上描述了這種技術。不要使用它。
但是,即使初始化helper物件的執行緒用了雙向的記憶體屏障,仍然不起作用。
問題在於,在某些系統上,看到helper欄位是非null的執行緒也需要執行記憶體屏障。
為何?因為處理器有自己本地的對記憶體的快取拷貝。在有些處理器上,除非處理器執行一個cache coherence指令(即,一個記憶體屏障),否則讀操作可能從過期的本地快取拷貝中取值,即使其它處理器使用了記憶體屏障將它們的寫操作寫回了記憶體。
我開了另一個頁面來討論這在Alpha處理器上是如何發生的。
值得費這麼大勁嗎?
對於大部分應用來說,將getHelper()變成同步方法的代價並不高。只有當你知道這確實造成了很大的應用開銷時才應該考慮這種細節的優化。
通常,更高級別的技巧,如,使用內部的歸併排序,而不是交換排序(見SPECJVM DB的基準),帶來的影響更大。
讓靜態單例生效
如果你要建立的是static單例物件(即,只會建立一個Helper物件),這裡有個簡單優雅的解決方案。
只需將singleton變數作為另一個類的靜態欄位。Java的語義保證該欄位被引用前是不會被初始化的,且任一訪問該欄位的執行緒都會看到由初始化該欄位所引發的所有寫操作。
class HelperSingleton { static Helper singleton = new Helper(); }
對32位的基本型別變數DCL是有效的
雖然DCL模式不能用於物件引用,但可以用於32位的基本型別變數。注意,DCL也不能用於對long和double型別的基本變數,因為不能保證未同步的64位基本變數的讀寫是原子操作。
// Correct Double-Checked Locking for 32-bit primitives 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; } // other functions and members... }
事實上,如果computeHashCode方法總是返回相同的結果且沒有其它附屬作用時(即,computeHashCode是個冪等方法),甚至可以消除這裡的所有同步。
// Lazy initialization 32-bit primitives // Thread-safe if computeHashCode is idempotent class Foo { private int cachedHashCode = 0; public int hashCode() { int h = cachedHashCode; if (h == 0) { h = computeHashCode(); cachedHashCode = h; } return h; } // other functions and members... }
用顯式的記憶體屏障使DCL有效
如果有顯式的記憶體屏障指令可用,則有可能使DCL生效。例如,如果你用的是C++,可以參考來自Doug Schmidt等人所著書中的程式碼:
// C++ implementation with explicit memory barriers // Should work on any platform, including DEC Alphas // From "Patterns for Concurrent and Distributed Objects", // by Doug Schmidt template <class TYPE, class LOCK> TYPE * Singleton<TYPE, LOCK>::instance (void) { // First check TYPE* tmp = instance_; // Insert the CPU-specific memory barrier instruction // to synchronize the cache lines on multi-processor. asm ("memoryBarrier"); if (tmp == 0) { // Ensure serialization (guard // constructor acquires lock_). Guard<LOCK> guard (lock_); // Double check. tmp = instance_; if (tmp == 0) { tmp = new TYPE; // Insert the CPU-specific memory barrier instruction // to synchronize the cache lines on multi-processor. asm ("memoryBarrier"); instance_ = tmp; } return tmp; }
用執行緒區域性儲存來修復DCL
Alexander Terekhov ([email protected])提出了個能實現DCL的巧妙的做法 —— 使用執行緒區域性儲存。每個執行緒各自儲存一個flag來表示該執行緒是否執行了同步。
class Foo { /** If perThreadInstance.get() returns a non-null value, this thread has done synchronization needed to see initialization of 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(); } // Any non-null value would do as the argument here perThreadInstance.set(perThreadInstance); } }
這種方式的效能嚴重依賴於所使用的JDK實現。在Sun 1.2的實現中,ThreadLocal是非常慢的。在1.3中變得更快了,期望能在1.4上更上一個臺階。Doug Lea分析了一些延遲初始化技術實現的效能
在新的Java記憶體模型下
用volatile修復DCL
JDK5以及後續版本擴充套件了volatile語義,不再允許volatile寫操作與其前面的讀寫操作重排序,也不允許volatile讀操作與其後面的讀寫操作重排序。更多詳細資訊見Jeremy Manson的部落格。
這樣,就可以將helper欄位宣告為volatile來讓DCL生效。在JDK1.4或更早的版本里仍是不起作用的。
// Works with acquire/release semantics for volatile // Broken under current semantics for volatile class Foo { private volatile Helper helper = null; public Helper getHelper() { if (helper == null) { synchronized(this) { if (helper == null) helper = new Helper(); } } return helper; } }
不可變物件的DCL
如果Helper是個不可變物件,那麼Helper中的所有欄位都是final的,那麼不使用volatile也能使DCL生效。主要是因為指向不可變物件的引用應該表現出形如int和float一樣的行為;讀寫不可變物件的引用是原子操作。