关于双查锁失效问题
一句话总结下整篇文章内容,在某些jdk实现中,对于对象中的单例属性,如果这个单例属性构造函数非默认空函数的话,双查锁可能会出现线程问题。
然而我在i7-4790 openjdk1.8环境下结合文章中的代码和我自己构造的代码做了很多很多的测试,并没有复现出来文中的问题。谁复现出来了麻烦跟我说下^_^
原文 The "Double-Checked Locking is Broken" Declaration
双查锁(Double-Checked Locking,dcl)被认为是一种在多线程环境下高效的懒加载机制的实现而被广泛使用。然而,在java的实现中,在没有额外的同步限制下,它并不是一种平台无关的可靠方式。在类似C++的其他语言实现中,dcl机制依赖处理器的内存模型、编译器语序重排机制和编译器与同步库的交互。在像C++这样的语言中,这些机制都是没有明确规定的,很难说在那种情况下dcl会起作用。在c++中,可以通过显示的使用内存屏障来使之生效,但是在java中不存在这种屏障机制。
首先看下下面的代码
//单线程环境实现
class Foo {
private Helper helper = null;
public Helper getHelper() {
if (helper == null)
helper = new Helper();
return helper;
}
//其他属性和方法.
}
如果上面的代码在多线程环境中使用的话,会出现很多问题。最明显的,Helper对象对被实例化2次或者更多次。一个最简单的修复方法是用synchronize关键字来修饰下getHelper()这个方法:
// 正确的多线程环境实现
class Foo {
private Helper helper = null;
public synchronized Helper getHelper() {
if (helper == null)
helper = new Helper();
return helper;
}
//其他属性和方法.
}
上面的代码在每次调用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属性赋值的操作顺序更是可以自由的重排。
个人注释:假设存在两个线程A、B同时调用getHelper方法,错误是指下面的线程B的情况 -->A:读到helper为空 -->A:进入同步块 -->A:读到helper为空 -->A:申请一个Helper对象空间h(此时h内属性为默认值) 将h的地址赋值给引用helper -->B:读到helper非空 <--B:返回helper,使用Helper未经构造函数处理属性的默认值进行操作 -->A:调用Helper构造函数,设置helper对象属性正确值 <--A:返回helper,使用Helper经过构造函数处理属性的正确值进行操作
即使编译器没有重排这些写入操作,在一个多核处理器上处理器或内存系统也可能把这些操作重排,从而被在其它核上运行的线程感知到。
Doug Lea 写过一篇相关的文章more detailed description of compiler-based reorderings.
关于问题的一个测试
Paul Jakubik发现了一个dcl问题的例子.A slightly cleaned up version of that code is available here
当代码运行在赛门铁克(Symantec)的jit上时,它就出问题了。赛门铁克(Symantec)会把语句
singletons[i].reference = new Singleton();
编译成下面的形式(赛门的jit是一种基于 handle-based 的对象分配系统)
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赋值操作是在单例构造函数调用前执行的。这样的操作在现有java内存模型中是完全合法的,同样在C和C++中也不存在问题(因为他们都没有内存模型一说^_^).
一种无效的修复
在给出了上面的解释后,一些人提出了下面的代码:
// (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();
} // 这里释放内层的同步锁(试图让Helper初始化操作锁定在内存同步块,阻止在完成初始化前给helper引用赋值。然而并不一定会)
helper = h;
}
}
return helper;
}
// other functions and members...
}
上面的代码把Helper对象的构造函数放在了同步块里面。这种直观的想法是,在同步原语(synchronization)被释放时,应该会有一个内存屏障阻止Helper对象初始化操作和helper引用的赋值操作重排。不幸的是,这种假设是完全错误的。同步机制并不是这样工作的。monitorexit(释放同步原语synchronization)的规则是,任何在monitorexit之前的动作必须在monitor释放前完成。但是,没有任何规则规定说在monitorexit之后的动作不能在monitor释放前执行。因此,在上面的例子中,编译器把赋值操作helper = h;
挪到内层同步块中去是完全合理合法的。很多处理器提供这种单向内存屏障指令。更改语义来要求把释放锁操作变成一个完整的内存屏障的话会带来性能损失。
更多无效的修复
你还有可以做到强制写操作做到双向内存屏障的。然而这些做法是笨重且低效的,尤其是当java内存模型变动时几乎可以确定不再有效。千万别这么干。如果对这种技术感兴趣的话,我把对它的描述放在了这里.千万别这么干。
然而,即使完全的内存屏障在初始化helper对象的那个线程中被实现了,它仍然无效。
问题在于在一些系统中,读取非空helper的另外的线程也需要实现内存屏障。为什么呢?因为处理器也有他们自己的本地缓存的内存(指的是cpu用于提高速度的L1、L2、L3缓存等)。在一些处理器中,除非处理器实现了 cache coherence(个人注释:谁知道咋翻,缓存相干性?)指令(一种内存屏障),读取操作可以在过时的本地缓存拷贝上完成,即使其他核使用内存屏障把他们的写如强制刷新到全局内存中了。
我创建了一个额外的web页来讨论在 Alpha 处理器上这种情况是怎样发神的。
这破问题值得大动干戈吗?
对于大多数应用来说,简单的把getHelper()方法同步起来的代价并不大,只有在你明确知道了同步会给你的应用带来大量开销时,你才应该考虑这种优化问题。 通常来说,使用一些高级技巧,比如使用内置的归并排序(mergesort)而不是交换排序(参见SPECJVM DB 基准测试)带来的影响更大。
正确的静态单例
如果你创建的是一个静态单例(从始至终只有一个Hepler对象被创建),而不是每个对象一个独立属性(每个Foo对象一个单独的Helper实例),有一个简单且优雅的方案。
只需要把这个单例在一个额外的类中定义成一个静态属性。java语义上保证了字段在被引用前不会被初始化(懒加载),并且任何访问这个字段的线程看到的都是初始化后的结果。
class HelperSingleton { static Helper singleton = new Helper();}
dcl对32位的原始数据类型其实是有效的
尽管dcl不能用于对象的引用,但它对32位的原始类型(int4、float等)是没问题的。值得注意的是它对long、double是没用的,因为非同步的读写64位原始类型不能保证原子性(意思是给前32位写值指令和给后32位写值指令之间可以插入其他指令)。
// 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函数总是返回相同的值且没有边际效应(即幂等idempotent),你甚至可以不用同步。
个人注释:我觉得这里说的是废话,如果构造函数也是幂等的,用对象引用的像直接判断返回也是可以的
// 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模式也是可以工作的。比如在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;
}
用ThreadLocal解决dcl
Alexander Terekhov (TEREKHOV@de.ibm.com)有个聪明的主意,使用threadlocal来实现dcl机制。每个线程使用ThreadLocal标记来判断是否线程已经把必要的同步工作搞定了。
个人注释:每个线程一定会进入一次同步块,但也只会进入一次,效率高低看ThreadLocal的访问速度
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版本。在sun1.2的实现中,ThreadLocal慢的一比。但在1.3中有了显著提高,1.4中被认为会更更快。Doug Lea分析了一些懒加载技术的性能表现
在新java内存模型下
在jdk5中,实现了新的java内存模型和线程规范。
使用volatile来搞dcl问题
jdk5及更高版本扩展了volatile关键字的语义,系统不允许将对volatile变量的写入操作与之前的读写操作重排,同时对volatile变量的读取操作也不会与之后的读写操作重排。参见 this entry in Jeremy Manson's blog
- 个人注释:这段话的意思是,对于volatile修饰的变量的写操作来讲,它之前的任何读写操作不会跑到它之后进行;:对于volatile修饰的变量的读取操作来讲,它之后的任何读写操作不会跑到它之后进行
在把helper属性声明为volatile后,dcl就可以就可以正常干活了。这在1.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,则dcl会正常起作用,而不需要使用volatile修饰。这是因为不可变对象的引用的表现很大程度上和int/float相同,读写不可变对象的引用是原子的。
dcl相关说明
- Reality Check, Douglas C. Schmidt, C++ Report, SIGS, Vol. 8, No. 3, March 1996.
- Double-Checked Locking: An Optimization Pattern for Efficiently Initializing and Accessing Thread-safe Objects, Douglas Schmidt and Tim Harrison. 3rd annual Pattern Languages of Program Design conference, 1996
- Lazy instantiation, Philip Bishop and Nigel Warren, JavaWorld Magazine
- Programming Java threads in the real world, Part 7, Allen Holub, Javaworld Magazine, April 1999.
- Java 2 Performance and Idiom Guide, Craig Larman and Rhett Guthrie, p100.
- Java in Practice: Design Styles and Idioms for Effective Java, Nigel Warren and Philip Bishop, p142.
- Rule 99, The Elements of Java Style, Allan Vermeulen, Scott Ambler, Greg Bumgardner, Eldon Metz, Trvor Misfeldt, Jim Shur, Patrick Thompson, SIGS Reference library
- Global Variables in Java with the Singleton Pattern, Wiebe de Jong, Gamelan