"Double-Checked Locking"失效问题详解

本文探讨了双检锁(Double-Checked Locking)在多线程环境下实现懒加载时遇到的问题,包括指令重排和多处理器导致的不稳定状态。文章提出了几种解决方法,并详细解释了如何利用Java的静态变量和volatile关键字来正确地实现懒加载。

Double-Checked Locking原本是为了解决多线程场景下的懒加载问题。但是当用Java实现时,如果不用额外的同步而造成的结果是不稳定的。总结原因有如下几个点:

1) 由于多线程同时进入临界区,造成重复创建单例对象

2) 由于编译器和处理器的指令重排,造成将未完全初始化的对象暴露出去

实现懒加载的解决方案:

1) 将成员变量声明为static,不用double-checked

2) 将成员变量声明为volatile,使用double-checked

首先看个例子:

class Foo { 
  private Helper helper = null;
  public Helper getHelper() {
    if (helper == null) 
        helper = new Helper();
    return helper;
    }
  // other functions and members...
  }
并发场景下很显然会有多个线程会同时进入helper = new Helper()这行代码,从而导致会初始化多个helper对象。一个改进版本如下:

class Foo { 
  private Helper helper = null;
  public synchronized Helper getHelper() {
    if (helper == null) 
        helper = new Helper();
    return helper;
    }
  // other functions and members...
  }
即每次进入函数getHelper()都需要加锁,显然这也违反了double-check的初衷:避免多线程懒加载初次判断的时候同步加锁。另一个改进版本为:

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对象暴露给其他线程。

为了修复乱序的问题,又有人想出了如下方案:

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之前的指令一定会在monitorexit执行之前(锁释放)被执行,而无法保障monitorexit语句之后指令不会提前到锁释放之前执行。换句话说,synchronize只保证同步块内的语句不会被调序出同步块之后执行,而不保证同步块之后的代码不会提前进入同步块执行。即monitorenter和monitorexit的内存屏障是单向的,而不是双向。即使有办法实现双向内存屏障也不要这么干,因为一旦Java内存模型修改,程序的正确性将无法得到保障。

那到底有没有正确实现懒加载的方案呢?有!方案一:

class HelperSingleton {
  static Helper singleton = new Helper();
  }
Java静态变量的语义保证了声明为静态变量的单例对象只有在第一次被访问时才会加载并初始化,而且会保证一定拿到的是一个被完整初始化的对象。

虽然double-check无法保证对象引用的正确多线程懒加载,但是对于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...
  }
原因很显然,32为原始类型的赋值操作是原子的。而由于64位的long型和double类型操作不是原子的,故此方法无效。如果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...
  }
JDK5扩展的volatile的语义,为实现double-check的懒加载提供了另一种方案:

class Foo {
        private volatile Helper helper = null;
        public Helper getHelper() {
            if (helper == null) {
                synchronized(this) {
                    if (helper == null)
                        helper = new Helper();
                }
            }
            return helper;
        }
    }
volatile的新语义为:

1) 不允许对于volatile的写操作与其之前的任何读/写操作调序

2) 不允许对于volatile的读操作与其之后的任何读/写操作调序

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值