Double-Checked Locking是一个广泛使用于多线程环境下lazy initialization的实现方法。但实际上在Java里,由于Java的内存环境和编译器,它并没有真正的实现我们期望的功能。
1. 什么是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
是null的时候,我们创建一个它的instance。如果不是,我们直接使用。我们并不总是给helper
赋个值,这也是为什么叫lazy initialization。
但是在多线程的时候,上面的代码并不工作。因为可能有两个线程同时访问helper
。比如开始的时候helper
是null
,线程A检查if (helper == null)
,进入赋值语句,但是还没赋值的时候,线程B也开始检查if (helper == null)
,哪怕接下来线程A已经完成了helper
的赋值,线程B也还是会再进行一遍。
如果简单的用synchronize
,我们会有下面的一段代码:
// 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...
}
synchronized
能保证不同线程在读取getHelper
的时候是线程安全的。这是synchronized method的概念。但是我们每次调用getHelper
的时候,都会用到synchronization,很浪费资源。我们可以采用两次检测helper
是否为null
的方式来避免过度的使用synchronization。
// 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。
2. 为什么Double-Checked Locking不工作
很遗憾的是,Double-Checked Locking实际上在Java里并没有按预期工作。实际上,如果不能让每个access到helper这个object的线程执行synchronization,是没办法让这个代码工作的。
2.1 不工作的第一个原因
最显著的原因就是对helper
的初始化指令和写指令可能会被重排序。当其他线程再次调用getHelper
的时候,会得到一个没有被初始化完成的helper
对象。
比如下面这段代码(出自博文):
public static Singleton getInstance(){
if (instance == null)
synchronized(instance){
if(instance == null)
instance = new Singleton();
}
return instance;
}
我们来分析一下这个过程(同样出自博文):
“对于JVM而言,它执行的是一个个Java指令。在Java指令中创建对象和赋值操作是分开进行的,也就是说instance = new Singleton();
语句是分两步执行的。但是JVM并不保证这两个操作的先后顺序,也就是说有可能JVM会为新的Singleton实例分配空间,然后直接赋值给instance成员,然后再去初始化这个Singleton实例。(即先赋值指向了内存地址,再初始化)这样就使出错成为了可能,我们仍然以A、B两个线程为例:
- A、B线程同时进入了第一个if判断
- A首先进入synchronized块,由于instance为null,所以它执行
instance = new Singleton();
- 由于JVM内部的优化机制,JVM先画出了一些分配给Singleton实例的空白内存,并赋值给instance成员(注意此时JVM没有开始初始化这个实例),然后A离开了synchronized块。
- B进入synchronized块,由于instance此时不是null,因此它马上离开了synchronized块并将结果返回给调用该方法的程序。
此时B线程打算使用Singleton实例,却发现它没有被初始化,于是错误发生了。”
2.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...
}
因为synchronization的规则保证我们能在退出内部同步块之前Helper
能够被实例化,h
能够被赋值,但是不能保证Helper
被赋值一定发生在退出同步块之后,所以还是有可能没有被初始化的Helper
实例被其他线程引用/访问。
3. 解决方法
3.1 使用static singletons
有一个最简单而有效地方法就是创建一个static singleton。这样就只有一个Helper
被创建。
class HelperSingleton {
static Helper singleton = new Helper();
}
这个singleton
在被引用之前不会被初始化,而一次初始化之后也不会再被重复初始化。
3.2 如果是32位原始类型,不需要考虑修复
对于32-bit primitive values(比如int, float),double-checked locking是能正常工作的。但是对于64-bit primitive values(比如long, double)就不行。因为对于64-bit primitive的unsynchronized的读写操作是不保证原子性的。
实际上,我们还能进一步简略。在下面代码中,如果computeHashCode()
总是返回相同值(没有副作用),那么我们甚至可以省略掉synchronized
:
// 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...
}
3.3 用Thread Local Storage修复
让每一个线程都保持一个flag,这个flag用来决定这个线程有没有完成需要的synchronization。
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就有显著提升。
3.4 使用Volatile字节 (JDK5及以上)
在JDK5及以上,如果一个field被标为Volatile,系统对它的读或写的重排序有了更多的规定。(更详细的资料)
所以我们可以通过对helper
加volatile
的方式来解决问题:
// 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;
}
}
3.5 使用不变实例 (JDK5及以上)
如果helper
是一个immutable object,比如Helper
这个类型的所有field都是final的,那么不需要标记Volatile也可以让double-checked locking工作。因为我们对immutable object的读写操作都是atomic的。
Reference
网友评论