`

“双重检查锁定被打破”的声明

阅读更多

“双重检查锁定被打破”的声明
The "Double-Checked Locking is Broken" Declaration

Signed by: 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中不可用。

先解释所期望的行为,考虑如下代码:

// 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()时都会进行同步。双重检查锁定试图避免在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...
  }

很可惜,在优化编译器或者共享内存的多处理器计算机上,前面的代码不能正常运行。

不能正常运行

不能正常运行有很多原因。我们描述的第一对原因很明显。理解了这些,你可能试图想办法“解决”双重检查锁定存在的问题。但你的解决方案不起作用:因为有很微妙的原因。理解这些原因后,会提出更好的解决方案,但解决方案还是有问题,因为还有更微妙的原因。

许多非常聪明的人花了大量的时间关注这个问题。但是除了让每个线程同步访问helpser对象外没有办法解决这个问题。

不能正常运行的第一个原因

不能正常运行最明显的原因是,初始化Helper对象和helpser字段的赋值没有按顺序去做完。因此,调用getHelper()方法的线程,已经拥有helper对象的非空引用,但看到helper对象的字段的默认值,而不是构造函数里设置的值。

如果编译器内联构造函数的调用且能证明构造函数不抛出异常或不执行同步,那么初始化对象和对象字段的赋值是可以自由的调整顺序的。

即使编译器不调整这些写操作的顺序,在多处理器计算机上,如果一个线程在另一个处理器上运行,处理器或内存系统也可能调整这些写操作的顺序。

Doug Lea 详细描述了基于编译的调整顺序。

表明不能正常运行的测试案例

Paul Jakubik给出了使用双重检查锁定不能正确运行的例子。稍微简化的代码如下。

当在使用Symantec JIT(just-in-time )编译器的系统上运行程序,程序不能正常运行。

如下所示(注意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++里也是合法的(因为它们都没有自己的内存模型)

不能正常运行的解决方法

鉴于上述解释,有人建议这样编码:
// (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对象的构造放到最里面的同步块里。这里认为在同步块被释放时应该有内存屏障(memory barrier), 以阻止初始化Helper对象和给helper字段赋值的顺序颠倒。

很可惜,这种想法是绝对错误的。同步的规则不是这样的。退出监视器(释放同步)的规则是,所有退出监视器之前的动作必须在释放监视器之前执行。然而,并没有规定说,退出监视器之后的动作不可以在释放监视器之前执行。也就是说同步块里的代码必须在退出同步时完成,而同步块后面的代码则可以被编译器或运行时环境移到同步块中执行。对于编译器,将instance = temp移动到最里层的同步块内完全合法,也合理。这样就出现了上个版本同样的问题。很多处理器提供执行这种单向内存屏障的指令。但如果改变语义,要求释放锁为一个完整内存屏障会带来性能损失。

不能正常运行的更多修改

你可以强制写操作执行全双向内存屏障。但这是粗放的,低效的,并且,一旦java内存模型改变,几乎无法保证工作。不要这样用,我专门为这个技术写了一篇文章,不要这样用。

然而,即使初始化helper对象的线程执行一个完整内存屏障,它仍然不能正常运行。

这是因为在一些操作系统里,看到helper字段非空值的线程同样需要执行内存屏障

为什么?因为处理器有它们自己的本地内存拷贝。有一些处理器,除非处理器执行缓存一致的指令(例如内存屏障),否则可能读到脏的本地内存拷贝,即使其他处理器使用内存屏障来强制写入全主内存。

我已经写了几篇web文章来讨论,在alpha处理器上这种情况是怎么发生的。

麻烦值得吗?

大部分程序,简单的同步getHelper()代价并不高。只有当你认为同步getHelper()会引起巨大的开销时,你才应该考虑采用这种详细的优化方案。

很多时候,更聪明的方法是使用内建的归并排序而不是处理交换排序会更有效。

静态单例下正常运行

如果你创建的单例是静态的(例如,只有一个Helper对象被创建),相对于另一种对象属性(例如,每一个Foo对象都有一个Helper对象)则有一个简单优雅的解决方案。

在一个独立的类里定义单例作为静态字段。java语义会保证字段被引用后才被初始化,访问字段的任何线程都会看到初始化字段后写入的结果。

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

32位原始类型的值能正常运行。

尽管双重检查锁定不能被用到对象引用上,但它可用在32位原始类型的值上(例如,int型或float型)。注意,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()函数总是返回同样的结果且没有副作用(例如,幂等),你甚至可以删除所有的同步。

// 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...
  }

使用显示的内存屏障

如果有明确的内存屏障指令,双重检查锁定模式正常运行是有可能的。例如,如果用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 <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;
    }

使用Thread Local 存储解决双重检查锁定问题

Alexander Terekhov 提出了一个聪明的建议:用thread local 存储实现双重检查锁定。每个线程保留线程本地标志位,决定线程是否已完成所需的同步

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的版本,在jdk1.2里,ThreadLocal's 是非常慢的。1.3里明显快了很多,并且1.4里被期望更快。Doug Lea 分析了实现延迟加载的一些技术的性能。

新的java内存模型

在JDK5里,有新的java内存模型和线程规范。

用Volatile解决双重检查锁定问题

JDK5及以后的版本扩展了volatile的语义,这样系统不允许volatile变量的写操作和前面的读写操作调整顺序,也不允许volatile变量的读操作和后面的读写操作调整顺序。Jeremy Manson's 的博客里可以看到这个条目的更详细的介绍。

因为这个变化,通过声明helper字段为volatile保证双重检查锁定正常运行,但在jdk4及之前不能正常运行。

// 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;
        }
    }

双重检查锁定不可变对象

如果Helper是不可变对象,就是说Helper的所有字段是final的,那么双重检查锁定不需要使用volatile就可以正常运行。不可变对象的引用(例如String或Integer)应该和int或float有类似的行为。读写不可变对象的引用是原子的。

Descriptions of double-check idiom
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

 

原文 http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics