双检查锁失效

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

原文地址 译者:丁一

双重检查锁定(以下称为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 != 0return 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一样的行为;读写不可变对象的引用是原子操作。

原创文章,转载请注明: 转载自并发编程网 – ifeve.com本文链接地址: 有关“双重检查锁定失效”的说明

 
posted @ 2018-01-09 18:48 lupeng2010 阅读(...) 评论(...) 编辑 收藏