“双重检验锁失效”的问题说明

原文地址:The “Double-Checked Locking is Broken” Declaration

译文:

“双重检验锁失效”的问题说明

作者:David Bacon (IBM Research) Joshua Bloch (Javasoft), Jeff Bogda, Cliff Click (Hotspot JVM project), Paul Haahr, Doug Lea, Tom May, Jan-Willem Maessen, Jeremy Manson, John D. Mitchell (jGuru) Kelvin Nilsen, Bill Pugh, Emin Gun Sirer
双重检验锁

双重检验锁是一个高效的懒加载方法,被广泛引用或应用于多线程环境中。

但是,在Java中,如果没有额外的同步,它并不能以独立的方式可靠地工作。当在其它语言上时,比如C++,它依赖于处理器的内存模型,编译器重排序以及编译器和同步库的交互。由于C++并没有对以上内容作出具体说明,所以很难说明在什么情况下双重检验锁会正常运行。在C++中可以显式地使用内存屏障来保证双重检验锁正常运行,但是在Java中没有这样的屏障可用。

为了说明我们想要的结果,首先考虑下面的代码:

// 单线程版本
class Foo {
  private Helper helper = null;
  public Helper getHelper() {
    if (helper == null)
        helper = new Helper();
    return helper;
    }
  // other functions and members...
  }

如果上述代码被用在多线程中,将会出现诸多错误。最明显的便是,可能会创建出两个或者更多的Helper对象(将在下文提出更多错误)。最简单的修复这个问题的方式便是使用同步方法。

// 正确的多线程版本
class Foo {
  private Helper helper = null;
  public synchronized Helper getHelper() {
    if (helper == null)
        helper = new Helper();
    return helper;
    }
  // other functions and members...
  }

上述代码将在每一次执行getHelper()方法时使用同步锁。而双重检验锁的方式可以避免在创建helper对象后依然使用同步方法:

// 错误的多线程版本
// “双重检验锁”方式
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...
  }

可惜,这一段代码并不能在使用优化过的编译器或者共享内存的多处理器的情况下正确执行。(译者注:我们使用的hotspot就有指令重排序等优化,指令重排序会影响双重检验锁正常运行)

它不能运行

有许多原因可以导致它不能运行。我们将描述几个比较显而易见的原因。理解了这些之后,你可能会想要尝试设计出一个方式去“修复”双重检验锁形式。但是你的修复并不会起作用:因为这里有很多细微的因素会影响它。理解了这些原因后,你可能会进一步修复,但却是重蹈覆辙,因为还有更加细致的因素。

大多数非常聪明的人曾花费大量时间在这里。但是除了在每一个线程取得helper对象时运行同步锁以外,没有别的方法。

第一个不能运行的原因

最明显的一个原因是初始化Helper对象和把对象写入到helper变量中这两步操作的并不是按顺序的。因此,一个线程调用getHelper()方法时,可能看到一个“非空”引用,但是却得到不同的helper对象,而不是在构造函数中设置的值。

如果编译器使用内联方式调用构造函数,那么只要编译器能够证明构造函数不会抛出异常或者调用同步锁,初始化对象和写入对象到helper变量中就可能进行自由的重排序。

即使编译器没有进行指令重排序,在一个多核心处理器中,处理器或者内存系统也可能会重排序这两步写入操作,运行在其它处理器上的线程就会看到重排序后的结果。

Doug Lea 曾经写过一篇文章对编译重排序进行了更加详细的叙述

一个展示它不能正常工作的测试方法
Paul Jakubik 发现一个使用了双重检验锁但是不能正常运行的例子。在这里有一个稍微清晰的代码
译者:这里我直接贴出代码,有兴趣可以去研究一下

public class DoubleCheckTest
  {


  // static data to aid in creating N singletons
  static final Object dummyObject = new Object(); // for reference init
  static final int A_VALUE = 256; // value to initialize 'a' to
  static final int B_VALUE = 512; // value to initialize 'b' to
  static final int C_VALUE = 1024;
  static ObjectHolder[] singletons;  // array of static references
  static Thread[] threads; // array of racing threads
  static int threadCount; // number of threads to create
  static int singletonCount; // number of singletons to create


  static volatile int recentSingleton;


  // I am going to set a couple of threads racing,
  // trying to create N singletons. Basically the
  // race is to initialize a single array of
  // singleton references. The threads will use
  // double checked locking to control who
  // initializes what. Any thread that does not
  // initialize a particular singleton will check
  // to see if it sees a partially initialized view.
  // To keep from getting accidental synchronization,
  // each singleton is stored in an ObjectHolder
  // and the ObjectHolder is used for
  // synchronization. In the end the structure
  // is not exactly a singleton, but should be a
  // close enough approximation.
  //


  // This class contains data and simulates a
  // singleton. The static reference is stored in
  // a static array in DoubleCheckFail.
  static class Singleton
    {
    public int a;
    public int b;
    public int c;
    public Object dummy;

    public Singleton()
      {
      a = A_VALUE;
      b = B_VALUE;
      c = C_VALUE;
      dummy = dummyObject;
      }
    }

  static void checkSingleton(Singleton s, int index)
    {
    int s_a = s.a;
    int s_b = s.b;
    int s_c = s.c;
    Object s_d = s.dummy;
    if(s_a != A_VALUE)
      System.out.println("[" + index + "] Singleton.a not initialized " +
s_a);
    if(s_b != B_VALUE)
      System.out.println("[" + index
                         + "] Singleton.b not intialized " + s_b);

    if(s_c != C_VALUE)
      System.out.println("[" + index
                         + "] Singleton.c not intialized " + s_c);

    if(s_d != dummyObject)
      if(s_d == null)
        System.out.println("[" + index
                           + "] Singleton.dummy not initialized,"
                           + " value is null");
      else
        System.out.println("[" + index
                           + "] Singleton.dummy not initialized,"
                           + " value is garbage");
    }

  // Holder used for synchronization of
  // singleton initialization.
  static class ObjectHolder
    {
    public Singleton reference;
    }

  static class TestThread implements Runnable
    {
    public void run()
      {
      for(int i = 0; i < singletonCount; ++i)
        {
    ObjectHolder o = singletons[i];
        if(o.reference == null)
          {
          synchronized(o)
            {
            if (o.reference == null) {
              o.reference = new Singleton();
          recentSingleton = i;
          }
            // shouldn't have to check singelton here
            // mutex should provide consistent view
            }
          }
        else {
          checkSingleton(o.reference, i);
      int j = recentSingleton-1;
      if (j > i) i = j;
      }
        }
      }
    }

  public static void main(String[] args)
    {
    if( args.length != 2 )
      {
      System.err.println("usage: java DoubleCheckFail" +
                         " <numThreads> <numSingletons>");
      }
    // read values from args
    threadCount = Integer.parseInt(args[0]);
    singletonCount = Integer.parseInt(args[1]);

    // create arrays
    threads = new Thread[threadCount];
    singletons = new ObjectHolder[singletonCount];

    // fill singleton array
    for(int i = 0; i < singletonCount; ++i)
      singletons[i] = new ObjectHolder();

    // fill thread array
    for(int i = 0; i < threadCount; ++i)
      threads[i] = new Thread( new TestThread() );

    // start threads
    for(int i = 0; i < threadCount; ++i)
      threads[i].start();

    // wait for threads to finish
    for(int i = 0; i < threadCount; ++i)
      {
      try
        {
        System.out.println("waiting to join " + i);
        threads[i].join();
        }
      catch(InterruptedException ex)
        {
        System.out.println("interrupted");
        }
      }
    System.out.println("done");
    }
  }

上述代码使用在Symantec JIT系统之上时不能正常工作。尤其是,Symantec JIT 编译singletons[i].reference = new Singleton();成下面的样子(注:Symantec JIT是一个使用基于句柄的对象分配系统)
译者注:Symantec JIT是一个Java编译器

0206106A   mov         eax,0F97E78h
0206106F   call        01F6B210                  ; 为单实例分配空间
                                                 ; 返回结果到寄存器eax中
02061074   mov         dword ptr [ebp],eax       ; EBP 中一个对象指针引用
                                                 ; 存储了一个没有初始化的对象
02061077   mov         ecx,dword ptr [eax]       ; 间接引用句柄得到原始指针
02061079   mov         dword ptr [ecx],100h      ; 下面4行代码是单实例对象的
0206107F   mov         dword ptr [ecx+4],200h    ; 内联构造方法
02061086   mov         dword ptr [ecx+8],400h
0206108D   mov         dword ptr [ecx+0Ch],0F84030h

正如所见,声名对象引用在对象构造方法执行之前就已经被调用了。这在现有的Java模型上是完全合法的(译者:即被允许),在C/C++中也是如此(即使在C/C++中没有内存模型。)
译者注:C++11已经存在内存模型,简单来说,内存模型是为了解决在多线程中的编译器优化程度,保证多线程程序正常运行的一种可靠的方式。

一种修复但是并没有效果

给出了上面的解释,有很多人便想出了下面的代码:

// 多线程下依然不能正常工作
// “双重检验锁”方式
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...
  }

上述代码把构造函数一个Helper对象放在了内部同步块里。直觉上来说,在释放同步块之前会有一个内存屏障,这也将阻止初始化对象和设置变量值的指令重排序。

然而,这种直觉是错误的。同步锁规则并不是这样使用的。释放同步块的规则(monitorexit:释放同步块)是释放同步块前的代码必需在释放动作执行前执行。然而,并没有规则规定在释放同步块后的代码不能在释放锁之前执行。这才是编译器把helper=h移到同步块里的真正合理的原因——这也就回到了之前的问题上。许多处理器提供了单向内存屏障处理指令。改变锁的语义会导致需要释放一个完整的内存屏障,这也将导致性能损失。

更多不生效的修复方法

这里有一些方法使用了完整双向的内存屏障去强制写入操作执行。这种方式太过于简单暴力了,而且是非常低效的,并且几乎可以肯定一旦Java的内存模型修改了便不能继续正确运行。请不要这样使用。如果有兴趣,我写了一篇更加详细的描述了这一技术在另一个页面。再次强调,请不要这样使用。

纵然是使用了一个完整的双向内存屏障在线程中去创建一个helper对象,它也不能正常工作。

问题在于一些操作系统,那些从变量helper中获取到一个非空的对象也需要执行内存屏障。

为什么?因为处理器对内存有自己的本地缓存。在一些处理器上,除非处理器执行了缓存连贯性指令(一个内存屏障),否则读操作可能会读到一个过期的缓存,即使其它处理器在全局内存中强制使用了内存屏障。

值得这么麻烦吗?

对于大多数应用程序,直接把getHelper()方法标记为同步方法的代价并不高。除非你知道这样的方法会会对应用程序带来巨大的性能开支时,才应该考虑这些细节上的优化操作。

一般情况下,更高级的见解是,在使用内置的归并排序而不是交换排序(参见 the SPECJVM DB benchmark)时会有更大的影响。

让静态单例生效

如果你创建了一个静态的单例(即只有一个Helper对象被创建),和一个对象拥有一个属性相反(即,所有的Foo对象都只是拥有唯一的一个Helper对象),这里有一个简单优雅的方法(译者注:这就是饿汉方式,不使用懒加载)。

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

在32位变量上可正常运行

虽然双重检验锁会在使用引用对象时失效,但是它可以在32位变量上生效(如,int、float)。注意它不能运行在long或者double上,因为非同步的读/写操作在64位变量上不保证是原子的。

// 原生32位变量上正确的双重检验锁
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()方法总是能返回一样的值并且没有其它副作用(即,它是幂等的),甚至可以不使用任何同步方法即可实现单例:

// 原生32位变量懒加载
// 如果computeHashCode()方法幂等的,则是线程安全的
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...
  }

译者注:这里也就是说,computeHashCode()返回一个int类型,如果每一次都是返回 100,不管运行多少次,它都是100,而在Java中,int类型(原生32位类型)无论怎样比较都是一样的,不会不同,所以它是幂等的,所以它也是线程安全的。这种类型不包含包装类型如Integer。

在明确的内存屏障下正常运行

如果你使用了明确的内存屏障,也是可以让双重检验锁按照规则正常运行的。例如,如果你用C++编程,你可以使用Doug Schmidt 等人所著的书中的代码:

// 明确使用内存屏障的 C++ 实现
// 可以在任何平台下正确运行, 包括 DEC Alphas(译者:早就死掉了,可见这篇文章写的时间之早)
// 来自书籍 "Patterns for Concurrent and Distributed Objects",
// 作者 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;
    }

使用本地线程存储修复双重检验锁

Alexander Terekhov (TEREKHOV@de.ibm.com) 提出了利用线程本地存储实现双重检查锁定的巧妙建议。每一个线程保持一个线程本地标记来决定这个线程是否已经执行了必要的同步。

  class Foo {
     /** 如果 perThreadInstance.get() 返回一个非空值, 这个线程已经执行过了同步块并          且可以获取到已经初始化了的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();
             }
         // 任何非空值均可以做为参数
             perThreadInstance.set(perThreadInstance);
         }
    }

这个实现技巧的的性能决定于你使用的JDK实现版本。在Sun的1.2实现版本里,ThreadLocal是非常低效的。在1.3版本里有了显著的提高,并且预计在1.4版本里会更快。Doug Lea 分析了使用这种个技巧去实现懒加载方式的性能

使用新的Java内存模型

在JDK5(译者:即JDK1.5,后来就都不用1.x了直接使用版本号)中,有一个新的Java内存模型和线程标准

用Volatic修复双重检验锁
JDK5及以后版本扩展了volatile的语义,因此系统将不再允许对一个volatile变量的写操作与它之前的读写操作进行重排序,并且一个volatile的读操作也不能与它之后的读写操作进行重排序。详细信息参见 Jeremy Manson 的博客

有了这个改变,只要在声名变量helper时添加关键字volatile,则双重检验锁的方式可以正常工作。这在JDK4及以前版本是不可行的。

// 正常工作在 获取/释放锁 volatile 语义 之下
// 在当前主义下不能正常工作(译者:作者的意思应该是现在的JDK4不能正常运行,可在JDK5运行,可能当时JDK5并没有正式发布)
  class Foo {
        private volatile Helper helper = null;
        public Helper getHelper() {
            if (helper == null) {
                synchronized(this) {
                    if (helper == null)
                        helper = new Helper();
                }
            }
            return helper;
        }
    }

双重检验锁定不可改变对象

如果Helper对象是一个不一变对象,即Helper对象的所有域(变量)都是final类型,那么即使没有使用 volatile ,双重检验锁也可以正常运行。这个思想主要是引用一个和int或者float一样的不可变对象(像String 或者 Integer),读写不可变对象的引用时都是原子的。

其相关双重检验锁:

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

  • 4
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值