原文地址 译者:丁一
双重检查锁定(以下称为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 (TEREKHOV@de.ibm.com)提出了个能实现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内存模型下
JDK5使用了新的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一样的行为;读写不可变对象的引用是原子操作。
原创文章,作者:3628473679,如若转载,请注明出处:https://blog.ytso.com/141002.html