“双重校验锁不可靠”声明

双重校验锁被广泛用作一种多线程环境中高效的懒加载实现。然而,当在Java中被以平台无关的形式实现时,在没有额外的同步措施的情况下,它将是不可靠的。当它在其他语言如C++中被实现时,它的可靠与否取决于处理器的内存模型,编译器进行的指令重排序以及编译器与同步库的交互。由于以上几点都未在语言规范中被明确定义(如C++),我们无法确定双重校验锁在什么情况下有效。在C++中,可以使用显式的内存屏障来使双重校验锁正确工作,但是这些屏障在Java中并不可用。

为了解释我们想要的行为,思考以下代码:

1
2
3
4
5
6
7
8
9
10
// 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()方法加上同步:

1
2
3
4
5
6
7
8
9
10
// 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()的时候都会进行同步,双重校验锁试图避免helper创建后的同步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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...
}

不幸的是,以上代码在编译优化或共享内存多处理器环境中行不通。

这行不通


行不通的原因有很多,我们马上要阐述几个比较明显的。在理解了这部分原因之后,你可能会设法“修复”双重校验锁。然而你的修不好:因为还有一些隐秘的问题导致你修不好。理解了这部分原因之后,你可能会提出一个更好的修复方法,然而你还是修不好,因为还有更多隐秘的问题。很多非常聪明的人花了大量的时间在这件事上,然而并没有找到可以不在每个访问helper对象的线程上进行同步的方法。

第一个原因

最显而易见的原因是初始化Helper对象和写入helper字段这两步可能会被重排序。因此,当一个线程调用了getHelper()方法时可能会发现helper对象是非空引用,但是它看到的helper对象是默认值而不是构造方法中为helper对象设置的值。

如果编译器内联了构造方法的调用,并且编译器能够证明构造方法不会抛出异常或进行同步操作,那么初始化Helper对象和写入helper字段这两步就可以被自由地重排序。即使编译器不进行重排序,在多处理器环境中,处理器或内存系统仍然可能对这些写操作进行重排序,比如被在另一个处理器上运行的一个线程访问。

Doug Lea写了一篇更详细的关于基于编译器的重排序的描述

测试案例

Paul Jakubik发现了一个使用双重校验锁后未正确工作的例子。一个经过轻微清理的版本代码在这

当运行在使用Symantec JIT的系统上时,双重校验锁失效了。特别是,Symantec JIT将以下代码:

1
singletons[i].reference = new Singleton();

编译成以下汇编代码(记住Symantec JIT使用的是基于句柄的对象分配系统):

1
2
3
4
5
6
7
8
9
10
11
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++中也是合法的(因为他们都没有内存模型这种东西)。

一个失败的修复

在给出了上面的解释后,一部分人提出了以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// (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对象的初始化和字段赋值这两步的重排序。

然而,直觉完全错了。同步锁的规则根本不是这样的。monitorexit(如释放同步锁)的规则是在monitorexit之前的动作必须在管程被释放前执行。然而,并没有规则说在monitorexit之后的操作不能再管程被释放之前执行。对于编译器来说,将helper = h;移动到同步块中是非常合理的,而这又回到了之前的情况。很多处理器提供了设置这种单向内存屏障的指令。修改指令语义来要求释放锁操作变成一个完全的内存屏障将会有性能问题。

更多失败的修复

确实有一些可以强制要求设置完全的双向的内存屏障的方法,但这是不优雅且低效的,并且一旦Java内存模型被修改,这些方法将不再有效。我另写了一个单独的页面用于描述这些方法,有兴趣可以看看,但不要使用这些方法。

然而,即使在初始化helper对象时设置了完全的内存屏障,双重校验锁仍然是行不通的。

问题在于在某些系统上,对于看到了helper字段具有非空值的线程也需要设置内存屏障。

为什么?因为处理器对内存有它们各自的本地缓存拷贝。在一些处理器上,除非处理器执行了缓存一致指令(比如使用内存屏障),否则即使本地缓存的拷贝已经过期且其他处理器使用内存屏障来强制同步写操作到全局内存中,读取操作依然可以被执行。

我已经创建了一个单独了网页来讨论这一切是如何真实发生在Alpha处理器上的。

真的要这么麻烦吗?


对于大多数应用,简单的将getHelper()方法设置为同步产生的代价并不高。只有在你确定同步方法产生了显著的性能开支的时候才应该考虑这种细节优化。通常,使用内置的归并排序而不是手动交换排序(handling exchange sort),这种优化会产生更大的影响。

对静态单例可行的方法


如果你要创建的单例是静态的(如只创建一个Helper),而不是作为另一个对象的一个属性(如每个Foo对象都有一个Helper),有一个简单并且优雅的解决方案。

将单例定义成一个单独类里的一个静态字段。Java的语义保证了只有这个字段被引用的时候才会被初始化,并且任何访问这个字段的线程都能看到初始化的结果。

1
2
3
class HelperSingleton {
static Helper singleton = new Helper();
}

对于32位基本数据类型有效


虽然双重校验锁不能用于引用类型,但它对于32位基本数据类型(如intfloat)是有效的。注意它对于longdouble是无效的,因为对于64位基本数据类型的非同步读写是非原子性的(非原子性协定)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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函数总是返回相同的结果并且没有副作用(如幂等),那么你甚至可以去除所有同步锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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...
}

借助显式的内存屏障使其有效


如果你可以使用显式的内存屏障指令,那么使双重校验锁有效是可能的。比如,如果你使用C++,那么你可以使用Doug Schmidt书中的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 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;
}
}

使用线程本地存储修复双重校验锁


Alexander Terekhov (TEREKHOV@de.ibm.com)提出了一个聪明的建议,通过线程本地存储来实现双重校验锁。每个线程保存了一个线程本地标志用于判断线程是否完成了要求的同步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 JDK 1.2的实现中,ThreadLocal是非常慢的,在1.3中,他们显著地变快了,并且在1.4中变得更快了。Doug Lea分析了一些懒加载实现方法的性能

在新Java内存模型下的情况


在JDK5中,我们有了新的Java内存模型和线程定义。

使用volatile修复双重校验锁

JDK5之后扩展了volatile的语义,系统将不再允许对volatile变量的读写操作进行重排序。详细信息可以看Jeremy Manson博客的这一篇文章

有了这个改变之后,我们可以将双重校验锁声明成volatile使其正确工作。这在JDK4及之前是行不通的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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;
}
}

对不可变对象的双重校验锁

如果Helper是个不可变对象,比如Helper的所有字段都是final的,那么双重校验锁在不使用volatile字段的情况下仍然有效。这是因为引用不可变对象(如String或Integer)和引用int或float的方式是类似的——对不可变对象的读写操作是原子性的。

原文:https://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html