Java关键字synchronized 使用中的 Double-Checked Locking is Broken

“Double-Checked Locking is Broken”声明

签名人: 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

Double-Checked Locking被广泛引用,并被用作在多线程环境中实现延迟初始化的有效方法。

不幸的是,在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之后,同步锁双重检查会尝试避免同步:

// 破坏多线程版本
// "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...
  }

不幸的是,该代码在优化编译器或共享内存多处理器的情况下不起作用。
它不起作用

有很多原因不起作用。我们要描述的第一个原因更明显。理解完这些之后,你可能会试图想出一种方法来“修复”双重检查的锁定习惯用法。您的修复程序无法正常工作:修复程序无法正常工作的原因更为细微。理解这些原因,提出更好的解决方案,但它仍然无法正常工作,因为还有更多细微的原因。

很多非常聪明的人花了很多时间看这个。有没有办法让它无需访问helper 对象进行同步每个线程工作。

第一个原因不起作用

最明显的原因是它不起作用,即初始化helper对象和写入helper字段的写入操作可以完成或完成顺讯不可知。因此,调用getHelper()的线程可以获得对helper对象的非空引用,但请参阅helper对象的字段的默认值,而不是在构造函数中设置的值。

如果编译器将调用内联到构造函数中,那么如果编译器可以证明构造函数不能抛出异常或执行同步,那么初始化对象和写入helper字段的写入可以自由重新排序。即使编译器不对这些写入重新排序,在多处理器上,处理器或内存系统可能会重新排列这些写入,如同在另一个处理器上运行的线程所感知的那样。

Doug Lea已经编写了基于编译器的重排序的更详细的描述。

一个测试用例表明它不起作用

Paul Jakubik发现了一个使用双重检查锁定的例子,它无法正常工作。这里有一个稍微清理过的代码版本

在使用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 ++中也是合法的(因为它们都没有内存模型)。

修复不起作用

鉴于上面的解释,一些人提出了以下代码:

// (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是完全合理和合法的; 在同步块内部,在这种情况下,我们又回到了之前的位置。许多处理器提供执行这种单向内存屏障的指令。更改语义以要求释放锁以成为完整的内存障碍将会导致性能损失。

更多修复程序无法使用

你可以做的事情是强迫作者执行完整的双向记忆障碍。这是严重的,效率低下的,而且几乎可以保证一旦Java内存模型被修改就不能工作。不要使用这个。为了科学的利益,我在一个单独的页面上描述了这种技术。不要使用它。

但是,即使初始化helper对象的线程执行完全内存屏障,它仍然不起作用。

问题在于,在某些系统上,为helper字段看到非空值的线程也需要执行内存屏障。

为什么?因为处理器拥有自己的本地高速缓存的内存副本。在某些处理器上,除非处理器执行缓存一致性指令(例如内存屏障),否则即使其他处理器使用内存屏障将其写入全局内存,也可以在本地缓存副本之外执行读取操作。

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

这是否值得麻烦?

对于大多数应用程序来说,简单地使getHelper() 方法同步的成本并不高。如果您知道这会对应用程序造成大量开销,那么您应该只考虑这种详细的优化。

通常,更高层次的聪明性,比如使用内置mergesort而不是处理交换排序(请参阅SPECJVM DB基准测试)将会产生更多影响。

使它适用于静态单例

如果你正在创建的单例是静态的(即只有一个Helper创建),而不是另一个对象的属性(例如,每个Foo对象都有一个Helper,那么就有一个简单而优雅的解决方案。

只需将单例定义为单独类中的静态字段即可。Java的语义保证字段不会被初始化,直到字段被引用,并且任何访问该字段的线程都将看到初始化该字段所产生的所有写入。

class Helper {
  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...
  }

事实上,假设compute Hash Code函数总是返回相同的结果并且没有副作用(即幂等),那么您甚至可以摆脱所有的同步。

// 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 显式内存障碍的C ++实现
// 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及更高版本扩展了易失性的语义,以便系统不会允许写入与先前的读取或写入相关的易失性数据,并且读取易失性数据不能针对后续读取或写入进行重新排序。请参阅 Jeremy Manson博客中的这个条目了解更多详情。

通过这种改变,可以通过声明helper字段是挥发性的,来使双重检查锁定成语工作。这在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的所有字段都是最终的,那么双重检查锁定就可以工作,而不必使用易失性字段。这个想法是,对一个不可变对象(比如一个String或一个Integer)的引用应该和int或者float类似。读取和写入对不可变对象的引用是原子的。

双重检查成语的描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值