当前位置: 首页 > 工具软件 > Checked C > 使用案例 >

双重检查锁(Double-Checked Locking)的缺陷

章威
2023-12-01

  • (Double-Checked Locking)双重检查锁被广泛引用并用作在多线程环境中实现延迟初始化的有效方法。
    思考:双重检查锁(Double-Checked Locking)被广泛用于多线程环境下实现延迟初始化实例。
  • 不幸的是,当在Java中实现时,它将无法以独立于平台的方式可靠地工作,而无需额外的同步。当用其他语言(如C
    ++)实现时,它取决于处理器的内存模型,编译器执行的重新排序以及编译器和同步库之间的交互。由于这些都不是用C ++这样的语言指定的,因此对它的工作情况几乎没有什么可说的。
    思考:那么在Java中这种写法是是否可以可靠执行取决于Java 内存模型。
  • 可以使用显式内存屏障使其在C ++中工作,但这些障碍在Java中不可用。
    思考: 为什么这么说呢?因为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对象。(我们稍后会提出其他问题)。
    思考:这是因为这种写法在多线程环境下是有可能创建两个或者多个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()时都会执行同步。

第三种有问题的写法

双重检查的锁定习惯用法在分配帮助程序后尝试避免同步:

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

不幸的是,该代码在优化编译器或共享内存多处理器的存在下不起作用。
思考:这种写法在优化编译器或共享内存多处理器的存在下不起作用

它不起作用

  • 有很多原因它不起作用。我们将描述的前几个原因更为明显。在理解了这些之后,你可能会试图设法一种“修复”双重检查锁定习语的方法。您的修复程序将无法正常工作:您的修复程序无法正常工作的原因更为微妙。了解这些原因,提出更好的解决方案,但仍然无法正常工作,因为还有更微妙的原因。
  • 很多非常聪明的人花了很多时间看这个。有没有办法让它无需访问辅助对象进行同步每个线程工作。

它不起作用的第一个原因

  • 最明显的原因,它不工作,它认为该初始化写操作助手对象,并在写帮手场可以做或感觉不正常。因此,调用getHelper()的线程可以看到对辅助对象的非null引用,但是请参阅辅助对象的字段的默认值,而不是构造函数中设置的值。
  • 如果编译器内联对构造函数的调用,那么如果编译器可以证明构造函数不能抛出异常或执行同步,则可以自由地重新排序初始化对象和写入辅助对象字段的写入。
    -即使编译器没有重新排序这些写入,在多处理器上,处理器或内存系统也可能重新排序这些写入,正如在另一个处理器上运行的线程所感知的那样。
  • Doug Lea撰写了更详细的基于编译器的重新排序的描述。

一个测试用例显示它不起作用

保罗·雅库比克发现了一个使用双重检查锁定无法正常工作的例子。此处提供了该代码的略微清理版本。

在使用Symantec JIT的系统上运行时,它不起作用。特别是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

如您所见,在 调用Singleton的构造函数之前执行对singletons [i].reference的赋值。这在现有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对象的构造放在内部同步块中。这里的直观想法是,在释放同步的位置应该有一个内存屏障,这应该可以防止重新排序Helper对象的初始化和对字段助手的赋值。

不幸的是,这种直觉是绝对错误的。同步规则不起作用。monitorexit(即释放同步)的规则是必须在监视器释放之前执行monitorexit之前的操作。但是,没有规则表明在监视器发布之前可能无法执行monitorexit之后的操作。编译器移动赋值helper
= h是完全合理合法的; 在synchronized块中,在这种情况下,我们回到了之前的位置。许多处理器提供执行此类单向内存屏障的指令。将语义更改为要求将锁定释放为完整的内存屏障会产生性能损失。

更多不起作用的修复

  • 您可以采取一些措施来强制编写器执行完全双向内存屏障。这是粗略的,低效的,并且一旦Java内存模型被修改,几乎可以保证不起作用。不要使用它。为了科学的利益,我在一个单独的页面上对这种技术进行了描述。不要使用它。
  • 但是,即使初始化辅助对象的线程执行完整的内存屏障,它仍然不起作用。
  • 问题是在某些系统上,看到辅助字段的非空值的线程也需要执行内存屏障。
  • 为什么?因为处理器有自己的本地缓存内存副本。在一些处理器上,除非处理器执行高速缓存一致性指令(例如,存储器屏障),否则可以从陈旧的本地高速缓存的副本执行读取,即使其他处理器使用存储器屏障来强制其写入全局存储器。

我已经创建了一个单独的网页,讨论了如何在Alpha处理器上实际发生这种情况。

这值得吗?

  • 对于大多数应用程序,简单地使getHelper()方法同步的成本并不高。如果您知道它会导致应用程序的大量开销,那么您应该只考虑这种详细的优化。
  • 通常,更高级别的聪明,例如使用内置mergesort而不是处理交换排序(请参阅SPECJVM DB基准测试)将产生更大的影响。

使其适用于静态单例(存在局限性)

  • 如果您正在创建的单例是静态的(即,只创建一个Helper),而不是另一个对象的属性(例如,每个Foo对象将有一个Helper,则有一个简单而优雅的解决方案。
  • 只需将单例定义为单独类中的静态字段。Java的语义保证在引用字段之前不会初始化字段,并且访问该字段的任何线程都将看到初始化该字段所产生的所有写入。
class HelperSingleton {
      static Helper singleton = new Helper();
}
  • 它适用于32位原始值
  • 虽然双重检查的锁定习惯用法不能用于对象的引用,但它可以用于32位原始值(例如,int或float)。
  • 请注意,它不适用于long或double,因为64位原语的不同步读/写不保证是原子的。

//对32位基元进行正确的双重检查锁定

// 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 ++)

如果您有明确的内存屏障指令,则可以使双重检查锁定模式有效。例如,如果您使用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;
    }

使用线程本地存储修复双重检查锁定(比较复杂)

Alexander Terekhov(TEREKHOV@de.ibm.com)提出了使用线程本地存储实现双重检查锁定的聪明建议。每个线程都保留一个线程本地标志,以确定该线程是否已完成所需的同步。

 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内存模型和线程规范。
  • JDK5和更高版本扩展了volatile的语义,以便系统不允许对任何先前的读或写进行重新排序的volatile的写入,并且对于任何后续的读或写,不能重新排序volatile的读取。
  • 有关详细信息,请参阅Jeremy Manson博客中的此条目。

通过这种改变,可以通过声明辅助字段是易失性来使双重锁定的习语起作用。这不起作用 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的所有字段都是最终的,那么双重检查锁定将无需使用volatile字段即可工作。我们的想法是对不可变对象(如String或Integer)的引用应该与int或float的行为方式大致相同;
  • 读取和写入对不可变对象的引用是原子的。

阅读原文:The “Double-Checked Locking is Broken” Declaration

 类似资料: