有关“双重检查锁定失效”的说明

原文地址 译者:丁一

双重检查锁定(以下称为DCL)已被广泛当做多线程环境下延迟初始化的一种高效手段。

遗憾的是,在Java中,如果没有额外的同步,它并不可靠。在其它语言中,如c++,实现DCL,需要依赖于处理器的内存模型、编译器实行的重排序以及编译器与同步库之间的交互。由于c++没有对这些做出明确规定,很难说DCL是否有效。可以在c++中使用显式的内存屏障来使DCL生效,但Java中并没有这些屏障。

来看下面的代码

01 // Single threaded version
02 class Foo {
03   private Helper helper = null;
04   public Helper getHelper() {
05     if (helper == null)
06         helper = new Helper();
07     return helper;
08     }
09   // other functions and members...
10 }

如果这段代码用在多线程环境下,有几个可能出错的地方。最明显的是,可能会创建出两或多个Helper对象。(后面会提到其它问题)。将getHelper()方法改为同步即可修复此问题。

01 // Correct multithreaded version
02 class Foo {
03   private Helper helper = null;
04   public synchronized Helper getHelper() {
05     if (helper == null)
06         helper = new Helper();
07     return helper;
08     }
09   // other functions and members...
10 }

上面的代码在每次调用getHelper时都会执行同步操作。DCL模式旨在消除helper对象被创建后还需要的同步。

01 // Broken multithreaded version
02 // "Double-Checked Locking" idiom
03 class Foo {
04   private Helper helper = null;
05   public Helper getHelper() {
06     if (helper == null)
07       synchronized(this) {
08         if (helper == null)
09           helper = new Helper();
10       }   
11     return helper;
12     }
13   // other functions and members...
14 }

不幸的是,这段代码无论是在优化型的编译器下还是在共享内存处理器中都不能有效工作。

不起作用

上面代码不起作用的原因有很多。接下来我们先说几个比较显而易见的原因。理解这些之后,也许你想找出一种方法来“修复”DCL模式。你的修复也不会起作用:这里面有很微妙的原因。在理解了这些原因之后,可能想进一步进行修复,但仍不会正常工作,因为存在更微妙的原因。

很多聪明的人在这上面花费了很多时间。除了在每个线程访问helper对象时执行锁操作别无他法。

不起作用的第一个原因

最显而易见的原因是,Helper对象初始化时的写操作与写入helper字段的操作可以是无序的。这样的话,如果某个线程调用getHelper()可能看到helper字段指向了一个Helper对象,但看到该对象里的字段值却是默认值,而不是在Helper构造方法里设置的那些值。

如果编译器将调用内联到构造方法中,那么,如果编译器能证明构造方法不会抛出异常或执行同步操作,初始化对象的这些写操作与hepler字段的写操作之间就能自由的重排序。

即便编译器不对这些写操作重排序,在多处理器上,某个处理器或内存系统也可能重排序这些写操作,运行在其它 处理器上的线程就可能看到重排序带来的结果。

Doug Lea写了一篇更详细的有关编译器重排序的文章。

展示其不起作用的测试案例

Paul Jakubik找到了一个使用DCL不能正常工作的例子。下面的代码做了些许整理:

001 public class DoubleCheckTest
002 {
003    
004  
005   // static data to aid in creating N singletons
006   static final Object dummyObject = new Object(); // for reference init
007   static final int A_VALUE = 256; // value to initialize 'a' to
008   static final int B_VALUE = 512; // value to initialize 'b' to
009   static final int C_VALUE = 1024;
010   static ObjectHolder[] singletons;  // array of static references
011   static Thread[] threads; // array of racing threads
012   static int threadCount; // number of threads to create
013   static int singletonCount; // number of singletons to create
014    
015  
016   static volatile int recentSingleton;
017  
018  
019   // I am going to set a couple of threads racing,
020   // trying to create N singletons. Basically the
021   // race is to initialize a single array of
022   // singleton references. The threads will use
023   // double checked locking to control who
024   // initializes what. Any thread that does not
025   // initialize a particular singleton will check
026   // to see if it sees a partially initialized view.
027   // To keep from getting accidental synchronization,
028   // each singleton is stored in an ObjectHolder
029   // and the ObjectHolder is used for
030   // synchronization. In the end the structure
031   // is not exactly a singleton, but should be a
032   // close enough approximation.
033   //
034  
035  
036   // This class contains data and simulates a
037   // singleton. The static reference is stored in
038   // a static array in DoubleCheckFail.
039   static class Singleton
040     {
041     public int a;
042     public int b;
043     public int c;
044     public Object dummy;
045  
046     public Singleton()
047       {
048       a = A_VALUE;
049       b = B_VALUE;
050       c = C_VALUE;
051       dummy = dummyObject;
052       }
053     }
054  
055   static void checkSingleton(Singleton s, int index)
056     {
057     int s_a = s.a;
058     int s_b = s.b;
059     int s_c = s.c;
060     Object s_d = s.dummy;
061     if(s_a != A_VALUE)
062       System.out.println("[" + index + "] Singleton.a not initialized " +
063 s_a);
064     if(s_b != B_VALUE)
065       System.out.println("[" + index
066                          + "] Singleton.b not intialized " + s_b);
067      
068     if(s_c != C_VALUE)
069       System.out.println("[" + index
070                          + "] Singleton.c not intialized " + s_c);
071      
072     if(s_d != dummyObject)
073       if(s_d == null)
074         System.out.println("[" + index
075                            + "] Singleton.dummy not initialized,"
076                            + " value is null");
077       else
078         System.out.println("[" + index
079                            + "] Singleton.dummy not initialized,"
080                            + " value is garbage");
081     }
082  
083   // Holder used for synchronization of
084   // singleton initialization.
085   static class ObjectHolder
086     {
087     public Singleton reference;
088     }
089  
090   static class TestThread implements Runnable
091     {
092     public void run()
093       {
094       for(int i = 0; i < singletonCount; ++i)
095         {
096     ObjectHolder o = singletons[i];
097         if(o.reference == null)
098           {
099           synchronized(o)
100             {
101             if (o.reference == null) {
102               o.reference = new Singleton();
103           recentSingleton = i;
104           }
105             // shouldn't have to check singelton here
106             // mutex should provide consistent view
107             }
108           }
109         else {
110           checkSingleton(o.reference, i);
111       int j = recentSingleton-1;
112       if (j > i) i = j;
113       }
114         }
115       }
116     }
117  
118   public static void main(String[] args)
119     {
120     if( args.length != 2 )
121       {
122       System.err.println("usage: java DoubleCheckFail" +
123                          " <numThreads> <numSingletons>");
124       }
125     // read values from args
126     threadCount = Integer.parseInt(args[0]);
127     singletonCount = Integer.parseInt(args[1]);
128      
129     // create arrays
130     threads = new Thread[threadCount];
131     singletons = new ObjectHolder[singletonCount];
132  
133     // fill singleton array
134     for(int i = 0; i < singletonCount; ++i)
135       singletons[i] = new ObjectHolder();
136  
137     // fill thread array
138     for(int i = 0; i < threadCount; ++i)
139       threads[i] = new Thread( new TestThread() );
140  
141     // start threads
142     for(int i = 0; i < threadCount; ++i)
143       threads[i].start();
144  
145     // wait for threads to finish
146     for(int i = 0; i < threadCount; ++i)
147       {
148       try
149         {
150         System.out.println("waiting to join " + i);
151         threads[i].join();
152         }
153       catch(InterruptedException ex)
154         {
155         System.out.println("interrupted");
156         }
157       }
158     System.out.println("done");
159     }
160 }

当上述代码运行在使用Symantec JIT的系统上时,不能正常工作。尤其是,Symantec JIT将

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

编译成了下面这个样子(Symantec JIT用了一种基于句柄的对象分配系统)。

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++中也是合法的(因为c/c++都没有内存模型(译者注:这篇文章写作时间较久,c++11已经有内存模型了))。

一种不起作用的“修复”

基于前文解释的原因,一些人提出了下面的代码:

01 // (Still) Broken multithreaded version
02 // "Double-Checked Locking" idiom
03 class Foo {
04   private Helper helper = null;
05   public Helper getHelper() {
06     if (helper == null) {
07       Helper h;
08       synchronized(this) {
09         h = helper;
10         if (h == null)
11             synchronized (this) {
12               h = new Helper();
13             } // release inner synchronization lock
14         helper = h;
15         }
16       }   
17     return helper;
18     }
19   // other functions and members...
20 }

将创建Helper对象的代码放到了一个内部的同步块中。直觉的想法是,在退出同步块的时候应该有一个内存屏障,这会阻止Helper的初始化与helper字段赋值之间的重排序。

很不幸,这种直觉完全错了。同步的规则不是这样的。monitorexit(即,退出同步块)的规则是,在monitorexit前面的action必须在该monitor释放之前执行。但是,并没有哪里有规定说monitorexit后面的action不可以在monitor释放之前执行。因此,编译器将赋值操作helper = h;挪到同步块里面是非常合情合理的,这就回到了我们之前说到的问题上。许多处理器提供了这种单向的内存屏障指令。如果改变锁释放的语义 —— 释放时执行一个双向的内存屏障 —— 将会带来性能损失。

更多不起作用的“修复”

可以做些事情迫使写操作的时候执行一个双向的内存屏障。这是非常重量级和低效的,且几乎可以肯定一旦Java内存模型修改就不能正确工作了。不要这么用。如果对此感兴趣,我在另一个网页上描述了这种技术。不要使用它。

但是,即使初始化helper对象的线程用了双向的内存屏障,仍然不起作用。

问题在于,在某些系统上,看到helper字段是非null的线程也需要执行内存屏障。

为何?因为处理器有自己本地的对内存的缓存拷贝。在有些处理器上,除非处理器执行一个cache coherence指令(即,一个内存屏障),否则读操作可能从过期的本地缓存拷贝中取值,即使其它处理器使用了内存屏障将它们的写操作写回了内存。

我开了另一个页面来讨论这在Alpha处理器上是如何发生的。

值得费这么大劲吗?

对于大部分应用来说,将getHelper()变成同步方法的代价并不高。只有当你知道这确实造成了很大的应用开销时才应该考虑这种细节的优化。

通常,更高级别的技巧,如,使用内部的归并排序,而不是交换排序(见SPECJVM DB的基准),带来的影响更大。

让静态单例生效

如果你要创建的是static单例对象(即,只会创建一个Helper对象),这里有个简单优雅的解决方案。

只需将singleton变量作为另一个类的静态字段。Java的语义保证该字段被引用前是不会被初始化的,且任一访问该字段的线程都会看到由初始化该字段所引发的所有写操作。

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

对32位的基本类型变量DCL是有效的

虽然DCL模式不能用于对象引用,但可以用于32位的基本类型变量。注意,DCL也不能用于对long和double类型的基本变量,因为不能保证未同步的64位基本变量的读写是原子操作。

01 // Correct Double-Checked Locking for 32-bit primitives
02 class Foo {
03   private int cachedHashCode = 0;
04   public int hashCode() {
05     int h = cachedHashCode;
06     if (h == 0)
07     synchronized(this) {
08       if (cachedHashCode != 0) return cachedHashCode;
09       h = computeHashCode();
10       cachedHashCode = h;
11       }
12     return h;
13     }
14   // other functions and members...
15 }

事实上,如果computeHashCode方法总是返回相同的结果且没有其它附属作用时(即,computeHashCode是个幂等方法),甚至可以消除这里的所有同步。

01 // Lazy initialization 32-bit primitives
02 // Thread-safe if computeHashCode is idempotent
03 class Foo {
04   private int cachedHashCode = 0;
05   public int hashCode() {
06     int h = cachedHashCode;
07     if (h == 0) {
08       h = computeHashCode();
09       cachedHashCode = h;
10       }
11     return h;
12     }
13   // other functions and members...
14 }

用显式的内存屏障使DCL有效

如果有显式的内存屏障指令可用,则有可能使DCL生效。例如,如果你用的是C++,可以参考来自Doug Schmidt等人所著书中的代码:

01 // C++ implementation with explicit memory barriers
02 // Should work on any platform, including DEC Alphas
03 // From "Patterns for Concurrent and Distributed Objects",
04 // by Doug Schmidt
05 template <class TYPE, class LOCK> TYPE *
06 Singleton<TYPE, LOCK>::instance (void) {
07     // First check
08     TYPE* tmp = instance_;
09     // Insert the CPU-specific memory barrier instruction
10     // to synchronize the cache lines on multi-processor.
11     asm ("memoryBarrier");
12     if (tmp == 0) {
13         // Ensure serialization (guard
14         // constructor acquires lock_).
15         Guard<LOCK> guard (lock_);
16         // Double check.
17         tmp = instance_;
18         if (tmp == 0) {
19                 tmp = new TYPE;
20                 // Insert the CPU-specific memory barrier instruction
21                 // to synchronize the cache lines on multi-processor.
22                 asm ("memoryBarrier");
23                 instance_ = tmp;
24         }
25     return tmp;
26 }

用线程局部存储来修复DCL

Alexander Terekhov (TEREKHOV@de.ibm.com)提出了个能实现DCL的巧妙的做法 —— 使用线程局部存储。每个线程各自保存一个flag来表示该线程是否执行了同步。

01 class Foo {
02  /** If perThreadInstance.get() returns a non-null value, this thread
03     has done synchronization needed to see initialization
04     of helper */
05      private final ThreadLocal perThreadInstance = new ThreadLocal();
06      private Helper helper = null;
07      public Helper getHelper() {
08          if (perThreadInstance.get() == null) createHelper();
09          return helper;
10      }
11      private final void createHelper() {
12          synchronized(this) {
13              if (helper == null)
14                  helper = new Helper();
15          }
16      // Any non-null value would do as the argument here
17          perThreadInstance.set(perThreadInstance);
18      }
19 }

这种方式的性能严重依赖于所使用的JDK实现。在Sun 1.2的实现中,ThreadLocal是非常慢的。在1.3中变得更快了,期望能在1.4上更上一个台阶。Doug Lea分析了一些延迟初始化技术实现的性能

在新的Java内存模型下

JDK5使用了新的Java内存模型和线程规范。

用volatile修复DCL

JDK5以及后续版本扩展了volatile语义,不再允许volatile写操作与其前面的读写操作重排序,也不允许volatile读操作与其后面的读写操作重排序。更多详细信息见Jeremy Manson的博客

这样,就可以将helper字段声明为volatile来让DCL生效。在JDK1.4或更早的版本里仍是不起作用的。

01 // Works with acquire/release semantics for volatile
02 // Broken under current semantics for volatile
03 class Foo {
04     private volatile Helper helper = null;
05     public Helper getHelper() {
06         if (helper == null) {
07             synchronized(this) {
08                 if (helper == null)
09                     helper = new Helper();
10             }
11         }
12         return helper;
13     }
14 }

不可变对象的DCL

如果Helper是个不可变对象,那么Helper中的所有字段都是final的,那么不使用volatile也能使DCL生效。主要是因为指向不可变对象的引用应该表现出形如int和float一样的行为;读写不可变对象的引用是原子操作。 

时间: 2024-10-14 21:04:16

有关“双重检查锁定失效”的说明的相关文章

我的Java开发学习之旅------&amp;gt;Java双重检查锁定及单例模式详解(转)

简介:          所有的编程语言都有一些共用的习语.了解和使用一些习语很有用,程序员们花费宝贵的时间来创建.学习和实现这些习语.问题是,稍后经过证明,一些习语并不完全如其所声称的那样,或者仅仅是与描述的功能不符.在 Java 编程语言中,双重检查锁定就是这样的一个绝不应该使用的习语.在本文中,Peter Haggar 介绍了双重检查锁定习语的渊源,开发它的原因和它失效的原因.         单例创建模式是一个通用的编程习语.和多线程一起使用时,必需使用某种类型的同步.在努力创建更有效的

java程序中双重检查锁定与延迟初始化

在java程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化.此时程序员可能会采用延迟初始化.但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题.比如,下面是非线程安全的延迟初始化对象的示例代码: public class UnsafeLazyInitialization { private static Instance instance; public static Instance getInstance() { if (instanc

双重检查锁定与延迟初始化

本文属于作者原创,原文发表于InfoQ:http://www.infoq.com/cn/articles/double-checked-locking-with-delay-initialization 双重检查锁定的由来 在java程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化.此时程序员可能会采用延迟初始化.但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题.比如,下面是非线程安全的延迟初始化对象的示例代码: public class

Singleton(单例)模式和Double-Checked Locking(双重检查锁定)模式

问题描述 现在,不管开发一个多大的系统(至少我现在的部门是这样的),都会带一个日志功能:在实际开发过程中,会专门有一个日志模块,负责写日志,由于在系统的任何地方,我们都有可能要调用日志模块中的函数,进行写日志.那么,如何构造一个日志模块的实例呢?难道,每次new一个日志模块实例,写完日志,再delete,不要告诉我你是这么干的.在C++中,可以构造一个日志模块的全局变量,那么在任何地方就都可以用了,是的,不错.但是,我所在的开发部门的C++编码规范是参照Google的编码规范的. 全局变量在项目

单例模式-研磨设计模式--单例模式--双重检查加锁(一)

* 双重检查加锁:  * "双重检查加锁"的方式可以既实现线程安全,又能够使性能不受到很大的影响.  * 那么什么是"双重检查加锁"机制呢?  *       所谓双重检查加锁机制,指的是:并不是每次进入getInstance方法都需要同步,  * 而是先不同步,进入方法过后,先检查实例是否存在,如果不存在才进入下面的同步块,  * 这是第一重检查.进入同步块后,再次检查实例是否存在,如果不存在,就在同步的  * 情况下创建一个实例,这是第二重检查.这样一来,就只需

爱丽儿召回7千婴儿车:折叠锁定失效易致意外收车

每经记者 蒋佩芳 发自上海日前,国家质检总局发布公告称,上海.江苏.浙江.福建.广东省(市)的27家玩具生产企业近期主动召回31种玩具产品.其中,上海爱丽儿工业有限公司(以下简称爱丽儿)一款AILLIAS810婴儿伞车也在召回之列.<每日经济新闻>记者了解到,本次召回范围内的玩具在小零件.锐利尖端.绳索.小球.用于包装玩具的塑料包装袋.刚性材料上的圆孔厚度.突出物.折叠锁定装置.动态耐久性测试.危险夹缝.动态强度等方面存隐患.在召回名录中,爱丽儿生产的AILLIAS810婴儿伞车被发现在折叠锁

爱丽儿召回7千婴儿车:折叠锁定失效或意外收车

日前,国家质检总局发布公告称,上海.江苏.浙江.福建.广东省(市)的27家玩具生产企业近期主动召回31种玩具产品.其中,上海爱丽儿工业有限公司(以下简称爱丽儿)一款AILLIAS810婴儿伞车也在召回之列.<每日经济新闻>记者了解到,本次召回范围内的玩具在小零件.锐利尖端.绳索.小球.用于包装玩具的塑料包装袋.刚性材料上的圆孔厚度.突出物.折叠锁定装置.动态耐久性测试.危险夹缝.动态强度等方面存隐患.在召回名录中,爱丽儿生产的AILLIAS810婴儿伞车被发现在折叠锁定装置释放时,设计的两个连

单例模式中双重锁定的例子

学习单例模式时,好多人都不太理解双重锁定.学完后突然想到一个很有趣的例子. 单例模式结构图: Singleton类 class Singleton { private static Singleton instance; private static readonly object syncRoot = new object(); //程序运行时创建一个静态只读的进程辅助对象 private Singleton() { } //用private修饰构造方法,防止外界利用new创建此类实例 pub

单例模式和双重检测的小结

设计模式的经典模式之一--单例模式 这个也是面试经常被问起的面试,一般都要求手写 [饿汉模式]非延时加载 --以空间换时间 [懒汉模式]延时加载--以时间换空间 看似简单,但是在懒汉模式还隐藏了一个双重检测,其中还是涉及到JVM内存的"无序写入"问题(后面使用的 volatile ) 1.饿汉模式 比较简单,非延时加载,一上来就把对象给new出来了 [java] view plain copy <span style="font-family:Microsoft YaH