避免Double-Checked Locking

2019-04-26  本文已影响0人  YoungJadeStone

Double-Checked Locking是一个广泛使用于多线程环境下lazy initialization的实现方法。但实际上在Java里,由于Java的内存环境和编译器,它并没有真正的实现我们期望的功能。

1. 什么是Double-Checked Locking

首先我们来看一下我们希望的功能。在单线程时:

// 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是null的时候,我们创建一个它的instance。如果不是,我们直接使用。我们并不总是给helper赋个值,这也是为什么叫lazy initialization。

但是在多线程的时候,上面的代码并不工作。因为可能有两个线程同时访问helper。比如开始的时候helpernull,线程A检查if (helper == null),进入赋值语句,但是还没赋值的时候,线程B也开始检查if (helper == null),哪怕接下来线程A已经完成了helper的赋值,线程B也还是会再进行一遍。

如果简单的用synchronize,我们会有下面的一段代码:

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

synchronized能保证不同线程在读取getHelper的时候是线程安全的。这是synchronized method的概念。但是我们每次调用getHelper的时候,都会用到synchronization,很浪费资源。我们可以采用两次检测helper是否为null的方式来避免过度的使用synchronization。

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

上面这种方法就是Double-Checked Locking。

2. 为什么Double-Checked Locking不工作

很遗憾的是,Double-Checked Locking实际上在Java里并没有按预期工作。实际上,如果不能让每个access到helper这个object的线程执行synchronization,是没办法让这个代码工作的。

2.1 不工作的第一个原因

最显著的原因就是对helper的初始化指令和写指令可能会被重排序。当其他线程再次调用getHelper的时候,会得到一个没有被初始化完成的helper对象。

比如下面这段代码(出自博文):

public static Singleton getInstance(){        
    if (instance == null)        
        synchronized(instance){        
            if(instance == null)        
                instance = new Singleton();        
        }        
    return instance;         
} 

我们来分析一下这个过程(同样出自博文):

对于JVM而言,它执行的是一个个Java指令。在Java指令中创建对象和赋值操作是分开进行的,也就是说instance = new Singleton();语句是分两步执行的。但是JVM并不保证这两个操作的先后顺序,也就是说有可能JVM会为新的Singleton实例分配空间,然后直接赋值给instance成员,然后再去初始化这个Singleton实例。(即先赋值指向了内存地址,再初始化)这样就使出错成为了可能,我们仍然以A、B两个线程为例:

此时B线程打算使用Singleton实例,却发现它没有被初始化,于是错误发生了。”

2.2 无效的修复

下面这个例子是一个看似有用,实则无效的修复:

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

因为synchronization的规则保证我们能在退出内部同步块之前Helper能够被实例化,h能够被赋值,但是不能保证Helper被赋值一定发生在退出同步块之后,所以还是有可能没有被初始化的Helper实例被其他线程引用/访问。

3. 解决方法

3.1 使用static singletons

有一个最简单而有效地方法就是创建一个static singleton。这样就只有一个Helper被创建。

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

这个singleton在被引用之前不会被初始化,而一次初始化之后也不会再被重复初始化。

3.2 如果是32位原始类型,不需要考虑修复

对于32-bit primitive values(比如int, float),double-checked locking是能正常工作的。但是对于64-bit primitive values(比如long, double)就不行。因为对于64-bit primitive的unsynchronized的读写操作是不保证原子性的。

实际上,我们还能进一步简略。在下面代码中,如果computeHashCode()总是返回相同值(没有副作用),那么我们甚至可以省略掉synchronized

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

3.3 用Thread Local Storage修复

让每一个线程都保持一个flag,这个flag用来决定这个线程有没有完成需要的synchronization。

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就有显著提升。

3.4 使用Volatile字节 (JDK5及以上)

在JDK5及以上,如果一个field被标为Volatile,系统对它的读或写的重排序有了更多的规定。(更详细的资料

所以我们可以通过对helpervolatile的方式来解决问题:

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

3.5 使用不变实例 (JDK5及以上)

如果helper是一个immutable object,比如Helper这个类型的所有field都是final的,那么不需要标记Volatile也可以让double-checked locking工作。因为我们对immutable object的读写操作都是atomic的。


Reference

上一篇 下一篇

猜你喜欢

热点阅读