双重检查锁定(以下称为DCL)已被广泛当做多线程环境下延迟初始化的一种高效手段。
遗憾的是,在Java中,如果没有额外的同步,它并不可靠。在其它语言中,如c++,实现DCL,需要依赖于处理器的内存模型、编译器实行的重排序以及编译器与同步库之间的交互。由于c++没有对这些做出明确规定,很难说DCL是否有效。可以在c++中使用显式的内存屏障来使DCL生效,但Java中并没有这些屏障。
来看下面的代码
01 | //Singlethreadedversion |
03 | privateHelperhelper=null; |
04 | publicHelpergetHelper(){ |
09 | //otherfunctionsandmembers... |
如果这段代码用在多线程环境下,有几个可能出错的地方。最明显的是,可能会创建出两或多个Helper对象。(后面会提到其它问题)。将getHelper()方法改为同步即可修复此问题。
01 | //Correctmultithreadedversion |
03 | privateHelperhelper=null; |
04 | publicsynchronizedHelpergetHelper(){ |
09 | //otherfunctionsandmembers... |
上面的代码在每次调用getHelper时都会执行同步操作。DCL模式旨在消除helper对象被创建后还需要的同步。
01 | //Brokenmultithreadedversion |
02 | //"Double-CheckedLocking"idiom |
04 | privateHelperhelper=null; |
05 | publicHelpergetHelper(){ |
13 | //otherfunctionsandmembers... |
不幸的是,这段代码无论是在优化型的编译器下还是在共享内存处理器中都不能有效工作。
不起作用
上面代码不起作用的原因有很多。接下来我们先说几个比较显而易见的原因。理解这些之后,也许你想找出一种方法来“修复”DCL模式。你的修复也不会起作用:这里面有很微妙的原因。在理解了这些原因之后,可能想进一步进行修复,但仍不会正常工作,因为存在更微妙的原因。
很多聪明的人在这上面花费了很多时间。除了在每个线程访问helper对象时执行锁操作别无他法。
不起作用的第一个原因
最显而易见的原因是,Helper对象初始化时的写操作与写入helper字段的操作可以是无序的。这样的话,如果某个线程调用getHelper()可能看到helper字段指向了一个Helper对象,但看到该对象里的字段值却是默认值,而不是在Helper构造方法里设置的那些值。
如果编译器将调用内联到构造方法中,那么,如果编译器能证明构造方法不会抛出异常或执行同步操作,初始化对象的这些写操作与hepler字段的写操作之间就能自由的重排序。
即便编译器不对这些写操作重排序,在多处理器上,某个处理器或内存系统也可能重排序这些写操作,运行在其它处理器上的线程就可能看到重排序带来的结果。
DougLea写了一篇更详细的有关
编译器重排序的文章。
展示其不起作用的测试案例
PaulJakubik找到了一个使用DCL不能正常工作的例子。下面的代码做了些许整理:
001 | publicclassDoubleCheckTest |
005 | //staticdatatoaidincreatingNsingletons |
006 | staticfinalObjectdummyObject=newObject();//forreferenceinit |
007 | staticfinalintA_VALUE=256;//valuetoinitialize'a'to |
008 | staticfinalintB_VALUE=512;//valuetoinitialize'b'to |
009 | staticfinalintC_VALUE=1024; |
010 | staticObjectHolder[]singletons;//arrayofstaticreferences |
011 | staticThread[]threads;//arrayofracingthreads |
012 | staticintthreadCount;//numberofthreadstocreate |
013 | staticintsingletonCount;//numberofsingletonstocreate |
016 | staticvolatileintrecentSingleton; |
019 | //Iamgoingtosetacoupleofthreadsracing, |
020 | //tryingtocreateNsingletons.Basicallythe |
021 | //raceistoinitializeasinglearrayof |
022 | //singletonreferences.Thethreadswilluse |
023 | //doublecheckedlockingtocontrolwho |
024 | //initializeswhat.Anythreadthatdoesnot |
025 | //initializeaparticularsingletonwillcheck |
026 | //toseeifitseesapartiallyinitializedview. |
027 | //Tokeepfromgettingaccidentalsynchronization, |
028 | //eachsingletonisstoredinanObjectHolder |
029 | //andtheObjectHolderisusedfor |
030 | //synchronization.Intheendthestructure |
031 | //isnotexactlyasingleton,butshouldbea |
032 | //closeenoughapproximation. |
036 | //Thisclasscontainsdataandsimulatesa |
037 | //singleton.Thestaticreferenceisstoredin |
038 | //astaticarrayinDoubleCheckFail. |
055 | staticvoidcheckSingleton(Singletons,intindex) |
062 | System.out.println("["+index+"]Singleton.anotinitialized"+ |
065 | System.out.println("["+index |
066 | +"]Singleton.bnotintialized"+s_b); |
069 | System.out.println("["+index |
070 | +"]Singleton.cnotintialized"+s_c); |
074 | System.out.println("["+index |
075 | +"]Singleton.dummynotinitialized," |
078 | System.out.println("["+index |
079 | +"]Singleton.dummynotinitialized," |
083 | //Holderusedforsynchronizationof |
084 | //singletoninitialization. |
085 | staticclassObjectHolder |
087 | publicSingletonreference; |
090 | staticclassTestThreadimplementsRunnable |
094 | for(inti=0;i<singletonCount;++i) |
096 | ObjectHoldero=singletons[i]; |
101 | if(o.reference==null){ |
102 | o.reference=newSingleton(); |
105 | //shouldn'thavetochecksingeltonhere |
106 | //mutexshouldprovideconsistentview |
110 | checkSingleton(o.reference,i); |
111 | intj=recentSingleton-1; |
118 | publicstaticvoidmain(String[]args) |
122 | System.err.println("usage:javaDoubleCheckFail"+ |
123 | "<numThreads><numSingletons>"); |
126 | threadCount=Integer.parseInt(args[0]); |
127 | singletonCount=Integer.parseInt(args[1]); |
130 | threads=newThread[threadCount]; |
131 | singletons=newObjectHolder[singletonCount]; |
134 | for(inti=0;i<singletonCount;++i) |
135 | singletons[i]=newObjectHolder(); |
138 | for(inti=0;i<threadCount;++i) |
139 | threads[i]=newThread(newTestThread()); |
142 | for(inti=0;i<threadCount;++i) |
145 | //waitforthreadstofinish |
146 | for(inti=0;i<threadCount;++i) |
150 | System.out.println("waitingtojoin"+i); |
153 | catch(InterruptedExceptionex) |
155 | System.out.println("interrupted"); |
158 | System.out.println("done"); |
当上述代码运行在使用SymantecJIT的系统上时,不能正常工作。尤其是,SymantecJIT将
1 | singletons[i].reference=newSingleton(); |
编译成了下面这个样子(SymantecJIT用了一种基于句柄的对象分配系统)。
0206106Amoveax,0F97E78h
0206106Fcall01F6B210;allocatespacefor
;Singleton,returnresultineax
02061074movdwordptr[ebp],eax;EBPis&singletons[i].reference
;storetheunconstructedobjecthere.
02061077movecx,dwordptr[eax];dereferencethehandleto
;gettherawpointer
02061079movdwordptr[ecx],100h;Next4linesare
0206107Fmovdwordptr[ecx+4],200h;Singleton'sinlinedconstructor
02061086movdwordptr[ecx+8],400h
0206108Dmovdwordptr[ecx+0Ch],0F84030h
如你所见,赋值给singletons[i].reference的操作在Singleton构造方法之前做掉了。在现有的Java内存模型下这完全是允许的,在c和c++中也是合法的(因为c/c++都没有内存模型(译者注:这篇文章写作时间较久,c++11已经有内存模型了))。
一种不起作用的“修复”
基于前文解释的原因,一些人提出了下面的代码:
01 | //(Still)Brokenmultithreadedversion |
02 | //"Double-CheckedLocking"idiom |
04 | privateHelperhelper=null; |
05 | publicHelpergetHelper(){ |
13 | }//releaseinnersynchronizationlock |
19 | //otherfunctionsandmembers... |
将创建Helper对象的代码放到了一个内部的同步块中。直觉的想法是,在退出同步块的时候应该有一个内存屏障,这会阻止Helper的初始化与helper字段赋值之间的重排序。
很不幸,这种直觉完全错了。同步的规则不是这样的。monitorexit(即,退出同步块)的规则是,在monitorexit前面的action必须在该monitor释放之前执行。但是,并没有哪里有规定说monitorexit后面的action不可以在monitor释放之前执行。因此,编译器将赋值操作helper=h;挪到同步块里面是非常合情合理的,这就回到了我们之前说到的问题上。许多处理器提供了这种单向的内存屏障指令。如果改变锁释放的语义——释放时执行一个双向的内存屏障——将会带来性能损失。
更多不起作用的“修复”
可以做些事情迫使写操作的时候执行一个双向的内存屏障。这是非常重量级和低效的,且几乎可以肯定一旦Java内存模型修改就不能正确工作了。不要这么用。如果对此感兴趣,我在另一个网页上描述了这种技术。不要使用它。
但是,即使初始化helper对象的线程用了双向的内存屏障,仍然不起作用。
问题在于,在某些系统上,看到helper字段是非null的线程也需要执行内存屏障。
为何?因为处理器有自己本地的对内存的缓存拷贝。在有些处理器上,除非处理器执行一个cachecoherence指令(即,一个内存屏障),否则读操作可能从过期的本地缓存拷贝中取值,即使其它处理器使用了内存屏障将它们的写操作写回了内存。
我开了另一个页面来讨论这在Alpha处理器上是如何发生的。
值得费这么大劲吗?
对于大部分应用来说,将getHelper()变成同步方法的代价并不高。只有当你知道这确实造成了很大的应用开销时才应该考虑这种细节的优化。
通常,更高级别的技巧,如,使用内部的归并排序,而不是交换排序(见SPECJVMDB的基准),带来的影响更大。
让静态单例生效
如果你要创建的是static单例对象(即,只会创建一个Helper对象),这里有个简单优雅的解决方案。
只需将singleton变量作为另一个类的静态字段。Java的语义保证该字段被引用前是不会被初始化的,且任一访问该字段的线程都会看到由初始化该字段所引发的所有写操作。
2 | staticHelpersingleton=newHelper(); |
对32位的基本类型变量DCL是有效的
虽然DCL模式不能用于对象引用,但可以用于32位的基本类型变量。注意,DCL也不能用于对long和double类型的基本变量,因为不能保证未同步的64位基本变量的读写是原子操作。
01 | //CorrectDouble-CheckedLockingfor32-bitprimitives |
03 | privateintcachedHashCode=0; |
08 | if(cachedHashCode!=0)returncachedHashCode; |
14 | //otherfunctionsandmembers... |
事实上,如果computeHashCode方法总是返回相同的结果且没有其它附属作用时(即,computeHashCode是个幂等方法),甚至可以消除这里的所有同步。
01 | //Lazyinitialization32-bitprimitives |
02 | //Thread-safeifcomputeHashCodeisidempotent |
04 | privateintcachedHashCode=0; |
13 | //otherfunctionsandmembers... |
用显式的内存屏障使DCL有效
如果有显式的内存屏障指令可用,则有可能使DCL生效。例如,如果你用的是C++,可以参考来自DougSchmidt等人所著书中的代码:
01 | //C++implementationwithexplicitmemorybarriers |
02 | //Shouldworkonanyplatform,includingDECAlphas |
03 | //From"PatternsforConcurrentandDistributedObjects", |
05 | template<classTYPE,classLOCK>TYPE* |
06 | Singleton<TYPE,LOCK>::instance(void){ |
09 | //InserttheCPU-specificmemorybarrierinstruction |
10 | //tosynchronizethecachelinesonmulti-processor. |
13 | //Ensureserialization(guard |
14 | //constructoracquireslock_). |
15 | Guard<LOCK>guard(lock_); |
20 | //InserttheCPU-specificmemorybarrierinstruction |
21 | //tosynchronizethecachelinesonmulti-processor. |
用线程局部存储来修复DCL
AlexanderTerekhov(TEREKHOV@de.ibm.com)提出了个能实现DCL的巧妙的做法——使用线程局部存储。每个线程各自保存一个flag来表示该线程是否执行了同步。
02 | /**IfperThreadInstance.get()returnsanon-nullvalue,thisthread |
03 | hasdonesynchronizationneededtoseeinitialization |
05 | privatefinalThreadLocalperThreadInstance=newThreadLocal(); |
06 | privateHelperhelper=null; |
07 | publicHelpergetHelper(){ |
08 | if(perThreadInstance.get()==null)createHelper(); |
11 | privatefinalvoidcreateHelper(){ |
16 | //Anynon-nullvaluewoulddoastheargumenthere |
17 | perThreadInstance.set(perThreadInstance); |
这种方式的性能严重依赖于所使用的JDK实现。在Sun1.2的实现中,ThreadLocal是非常慢的。在1.3中变得更快了,期望能在1.4上更上一个台阶。DougLea分析了一些延迟初始化技术实现的性能
在新的Java内存模型下
JDK5使用了新的Java内存模型和线程规范。
用volatile修复DCL
JDK5以及后续版本扩展了volatile语义,不再允许volatile写操作与其前面的读写操作重排序,也不允许volatile读操作与其后面的读写操作重排序。更多详细信息见JeremyManson的博客。
这样,就可以将helper字段声明为volatile来让DCL生效。在JDK1.4或更早的版本里仍是不起作用的。
01 | //Workswithacquire/releasesemanticsforvolatile |
02 | //Brokenundercurrentsemanticsforvolatile |
04 | privatevolatileHelperhelper=null; |
05 | publicHelpergetHelper(){ |
不可变对象的DCL
如果Helper是个不可变对象,那么Helper中的所有字段都是final的,那么不使用volatile也能使DCL生效。主要是因为指向不可变对象的引用应该表现出形如int和float一样的行为;读写不可变对象的引用是原子操作。
原创文章,转载请注明:转载自并发编程网–ifeve.com本文链接地址:有关“双重检查锁定失效”的说明