单例的双检锁
转自:http://www.blogjava.net/Jhonney/archive/2011/04/08/110280.html
前几天在看一段.NET源代码的时候偶尔遇到了Double-checked Locking (双检锁)的一个使用,于是想到了以前看过的一些资料,写出来分享一下。
主要参考:The "Double-Checked Locking is Broken" Delaration (http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html)
双检锁是在多线程环境下很常见的一种实现singleton模式里lazy initialization的方法。
先看一下最这个模式的起源(注:代码为Java,不过这个问题适用各种语言,比如C++):
// Single threaded version
class Foo {
private Helper helper = null;
public Helper getHelper() {
if (helper == null)
helper = new Helper();
return helper;
}
// other functions and members...
}
很容易看出,在多线程的情况下,上面的getHelper是不能正确工作的(可能生成多个helper实体)。
于是有下面的改进代码:
// 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是一个整体的"critical section",但就是效率很不好,因为我们的目的其实只是在第一个初始化helper的时候需要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...
}
思路很简单,就是我们只需要同步(synchronize)初始化helper的那部分代码从而使代码既正确又很有效率。
这就是所谓的“双检锁”机制(顾名思义)。
很可惜,这样的写法在很多平台和优化编译器上是错误的。
原因在于:helper = new Helper()这行代码在不同编译器上的行为是无法预知的。一个优化编译器可以合法地如下实现helper = new Helper():
1. helper = 给新的实体分配内存
2. 调用helper的构造函数来初始化helper的成员变量
现在想象一下有线程A和B在调用getHelper,
线程A先进入,在执行到步骤1的时候被踢出了cpu。然后线程B进入,B看到的是helper 已经不是null了(内存已经分配),于是它开始放心地使用helper,但这个是错误的,因为在这一时刻,helper的成员变量还都是缺省值,A还没 有来得及执行步骤2来完成helper的初始化。
当然编译器也可以这样实现:
1. temp = 分配内存
2. 调用temp的构造函数
3. helper = temp
如果编译器的行为是这样的话我们似乎就没有问题了,但事实却不是那么简单,因为我们无法知道某个编译器具体是怎么做的,因为在Java的 memory model里对这个问题没有定义(C++也一样),而事实上有很多编译器都是用第一种方法(比如symantec的just-in-time compiler),因为第一种方法看起来更自然。
在上面的参考文章中还提到了更复杂的修改方法,不过很可惜,都是错误的,我这里就略去不介绍了。
那么有什么解决方案呢?有如下一些:
1. 如果你的singleton是static的,那你可以将这个singleton申明为一个独立类的一个成员变量:
class HelperSingleton {
static Helper singleton = new Helper();
}
Java的语意会保证:1. lazy initialization, 2. singleton在被调用前已经完全初始化了。
2. 双检锁对于基础类型(比如int)适用。很显然吧,因为基础类型没有调用构造函数这一步。事实上,我前面提到的.NET里面的那段代码就是在一个int变量上使用双检锁。
3. 使用explicit memory barrier。这个我不说了,关于memory barrier我们可以写一本小册子来介绍,有兴趣的朋友可以自己查一下资料,上面的参考里也有很多相关链接。
4. 使用Thread Local Storage。也不介绍了。
上面的文章还提到了Java在考虑为volatile关键字定义新的语意来解决这个问题以及双检锁对Java里immutable对象影响,
不过因为这篇文章已经有些年头而我也不是Java的专家,所以不太清楚现在的情况怎样,总之,在遇到双检锁的时候,需要的朋友应该做些必要的调查来确定自己的代 码是线程安全的。