Double-Checked Locking方法被广泛的使用于实现多线程环境下单例模式的懒加载方式实现,不幸的是,在JAVA中,这种方式有可能不能够正常工作。在其他语言环境中,如C++,依赖于处理器的内存模型、编译器的重排序以及编译器和同步库之间的工作方式。由于这些问题在C++中并不确定,因此我们不能够确定具体的行为。但是在C++中显示的内存屏障是可以被用来让其正常工作的,而这些屏障在JAVA中又不好用。
一、Double-Checked Locking入门
首先来看看下面这段代码我们期望得到的行为:
// 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对象将会被分配内存,其他问题我们会在后面提到,我们先简单的给方法加一个synchronized关键字。
// 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方法的时候都要进行同步,下面的Double-Checked Locking方式避免了当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... }
不幸的是,这段代码在存在编译优化或多处理器共享内存的情况下不能够正常工作。
二、Double-Checked Locking不能够正常工作
为什么上文说Double-Checked Locking不能够正常工作有很多的原因,我们将会描述一对很显而易见的原因。通过理解存在的问题,我们尝试着去修复Double-Checked Locking存在的问题,然而我们的修复可能并没有用,我们可以一起看看为什么没有用,理解这些原因,我们去尝试着寻找更好的方法,可能还是没有用,因为还是存在一些微妙的原因。
1)第一个不能正常工作的原因
Double-Checked Locking不能够正常工作的一个很显然的原因是对helper属性的写指令和初始化Helper对象的指令可能被冲排序,因此当其他线程再次调用getHelper方法的时候,将会得到一个没有被初始化完成的Helper对象,如果这个线程访问了这个对象没有被初始化的属性,那么就会出现位置错误。
我们来看看对于下面这行代码,在Symantec JIT编译器环境下的指令重排序的例子:
singletons[i].reference = new Singleton();
下面是实际执行的代码:
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++中也是合法的。
2)一种无用的修复
理解了上面的问题,有些同学给出了下面的这段代码,试图避免问题:
// (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... }
上面的代码将对象构造放在一个内部的synchronized块里面,直觉的想法是想通过synchronized释放之后的屏障来避免问题,从而阻止对helper属性的赋值和对Helper对象的构造的指令重排序。不幸的是,直觉是错误的。因为synchronization的规则能保证所有在monitorexit之前的动作都能够生效而并不包含在monitorexit之后的动作在monitorexit之前不生效。也就是我们能够保证在退出内部同步块之前Helper能够被实例化,h能够被复制,但是不能保证helper被赋值一定发生在退出同步块之后,因此同样会出现没有被构造完的Helper实例被其他线程引用并访问。
3)其他无用的修复
我们可以通过完全双向的内存屏障来强制行为生效,这么做是粗鲁的,非高效的,并且几乎可以保证一旦JAVA内存模型被修订,原有方式将不能够正常工作。所以,请不要这么做。然而,即使通过完全内存屏障,还是不能够正常工作。问题是在一些系统上,线程对非空的helper属性字段同样需要内存屏障。为什么呢?因为处理器拥有自己的缓存,在一些处理器中,除非处理器执行缓存一致性指令,否则将有可能从缓存读取错误内容,尽管其他处理器将内容从缓存刷新到了主存。
4)至于搞这么复杂么?
在很多应用中,简单的将getHelper方法同步开销其实并不大,除非能够证明其他优化方案确实能够为应用带来不少的性能提升。
三、使用静态域
如果我们正要创建的实例是static的,我们有一种很简单的方法,仅仅将单例静态属性字段在一个单独的类中定义:
class HelperSingleton { static Helper singleton = new Helper(); }
这么做既保证的懒加载,又保证单例被引用的时候已经被构造完成。
四、Double-Checked Locking对32位原始类型有效
尽管Double-Checked Locking对对象引用类型无效,对于32位原始类型却是有效的,值得注意的是对64位的long和double类型并不是有效的,因为64为的long和double不能够保证被原子地读写。
// 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函数总是有固定的返回值,我们可以不使用同步块:
// 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... }
五、利用ThreadLocal修复Double-Checked Locking问题
Alexander Terekhov提出了一个聪明的方法,通过ThreadLocal来实现Double-Checked Locking,每个Thread保持一个local 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公司的JDK1.2版本中,ThreadLocal是很慢的,在1.3版本之后变得非常快了。
六、在新的JAVA内存模型中
在JDK1.5或者更晚的版本中,扩展了volatile的语义,使得我们可以通过将helper属性字段设置为volatile来修复Double-Checked的问题:
// 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; } }
七、使用不变实例
还有一种方法是讲单例对象变为不可变对象,如所有字段都声明为final或者类似String类或Integer类这种。