资讯专栏INFORMATION COLUMN

双重检查锁定失效分析

keke / 3335人阅读

摘要:双重检查锁定以下称为已被广泛当做多线程环境下延迟初始化的一种高效手段。由于没有对这些做出明确规定,很难说是否有效。可以在中使用显式的内存屏障来使生效,但中并没有这些屏障。如果改变锁释放的语义释放时执行一个双向的内存屏障将会带来性能损失。

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

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

// Single threaded version
class Foo { 
  private Helper helper = null;
  public Helper getHelper() {
    if (helper == null) 
        helper = new Helper();
    return helper;
    }
  // other functions and members...
}

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

// Correct multithreaded version
class Foo { 
  private Helper helper = null;
  public synchronized Helper getHelper() {
    if (helper == null) 
        helper = new Helper();
    return helper;
    }
  // other functions and members...
}

上面的代码在每次调用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构造方法里设置的那些值。

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

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

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

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

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

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" +
                         "  ");
      }
    // 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用了一种基于句柄的对象分配系统)。

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已经有内存模型了))。

一种不起作用的“修复”

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

// (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();
            } // release inner synchronization lock
        helper = h;
        } 
      }    
    return helper;
    }
  // other functions and members...
}

将创建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的语义保证该字段被引用前是不会被初始化的,且任一访问该字段的线程都会看到由初始化该字段所引发的所有写操作。

class HelperSingleton {
    static Helper singleton = new Helper();
}
对32位的基本类型变量DCL是有效的

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

// 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方法总是返回相同的结果且没有其它附属作用时(即,computeHashCode是个幂等方法),甚至可以消除这里的所有同步。

// 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有效

如果有显式的内存屏障指令可用,则有可能使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  TYPE *
Singleton::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 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;
}
用线程局部存储来修复DCL

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

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实现。在Sun 1.2的实现中,ThreadLocal是非常慢的。在1.3中变得更快了,期望能在1.4上更上一个台阶。Doug Lea分析了一些延迟初始化技术实现的性能

在新的Java内存模型下

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

用volatile修复DCL

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

// 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的,那么不使用volatile也能使DCL生效。主要是因为指向不可变对象的引用应该表现出形如int和float一样的行为;读写不可变对象的引用是原子操作。

原文 Double Checked Locking
翻译 丁一
via ifeve

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/64060.html

相关文章

  • 为什么双重检查锁模式需要 volatile ?

    摘要:注意,禁止指令重排序在之后才被修复使用局部变量优化性能重新查看中双重检查锁定代码。帮助文档双重检查锁定与延迟初始化有关双重检查锁定失效的说明 双重检查锁定(Double check locked)模式经常会出现在一些框架源码中,目的是为了延迟初始化变量。这个模式还可以用来创建单例。下面来看一个 Spring 中双重检查锁定的例子。 showImg(https://segmentfaul...

    geekzhou 评论0 收藏0
  • 单例模式

    摘要:构造函数被调用或者,我们利用初始化块,在初始化的时候就完成实例化构造器被调用双重检查锁定避免懒汉模式造成性能低下的另一个思路就是双重检查锁定。 1. 什么是单例 保证一个类仅有一个实例,并提供一个访问它的全局访问点。适用于: 当类只能有一个实例而且客户可以从一个众所周知的访问点访问它时。 当这个唯一实例应该是通过子类化可扩展的,并且客户应该无需更改代码就能使用一个扩展的实例时。 在...

    Backache 评论0 收藏0
  • 浅谈双重检查锁定和延迟初始化

    摘要:非线程安全的双重检查锁这里看起来很完美,但是是一个错误的优化,代码在读取到不为的时候,引用的对象有可能换没有完成初始化,这样返回的是有问题的。 在Java多线程程序中,有时需要采用延迟初始化来降低初始化类和创建对象的开销,双重检查锁定是常见的延迟初始化技术,但它是一种错误的用法 双重检查锁的演进以及问题 使用syncronized实现 public synchronized stati...

    Shonim 评论0 收藏0
  • 双重检查锁定与延迟初始化

    摘要:基于的双重检查锁定的解决方案对于前面的基于双重检查锁定来实现延迟初始化的方案指示例代码,我们只需要做一点小的修改把声明为型,就可以实现线程安全的延迟初始化。 双重检查锁定的由来 在java程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。此时程序员可能会采用延迟初始化。但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题。比如,下...

    yvonne 评论0 收藏0
  • 单例模式与双重检查锁定(double-checked locking)

    摘要:对于而言,它执行的是一个个指令。在指令中创建对象和赋值操作是分开进行的,也就是说语句是分两步执行的。此时线程打算使用实例,却发现它没有被初始化,于是错误发生了。 1.饿汉式单例 public class Singleton { private static Singleton instance = new Singleton(); ...

    yearsj 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<