[翻译]"双重检查锁被打破了"宣言

本文探讨了双重检查锁在多线程环境下懒加载的应用,并详细分析了其在Java中为何无法跨平台可靠工作。通过测试案例展示了编译器优化和多处理器环境下的问题,并提出了几种可能的解决方案。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

  1. 在看书的时候看到了这篇文章的引用,因而上网查了这篇文章,看着看着觉得,不如翻译了写出来吧,所以有了这篇博客.
  2. 第一次翻译,如有不足还请包涵.
  3. 文章中我加入的话都有(注:)字样,文章翻译我尽量逐字逐句并且表达清晰,所以可能有些绕嘴.
  4. 原文地址:The “Double-Checked Locking is Broken” Declaration

“双重检查锁”正被当作一个实现多线程环境中懒加载的有效方法而被广泛使用.

然而,不幸的是,Java实现的它(双重检查锁),并不能跨平台(注:独立于平台)地可靠工作.在这种方式被以其他语言,例如C++实现的时候,它依赖于处理器的内存模型,编译器重排序的执行,以及编译器与同步库的交互.然而这三个依赖当中的任何一个,在语言中,比如在C++中,都没有规范.我们很难判断它(注:双重检查锁)能生效的场景.C++中有明确的内存屏障可以使用,让双重检查锁生效,但是这些屏障在Java中并不可用.

首先,解释下我们期望的行为,考虑如下代码

// 单线程版本
class Foo { 
  private Helper helper = null;
  public Helper getHelper() {
    if (helper == null) 
        helper = new Helper();
    return helper;
    }
  // 其他函数和成员...
  }

如果这段代码被用于多线程的环境,很多地方都会出错.最明显的,可能会实例化两个甚至多个Helper(稍后我们将说明其他的问题).解决它的简单方法是将getHelper()方法同步.

// 正确的多线程版本
class Foo { 
  private Helper helper = null;
  public synchronized Helper getHelper() {
    if (helper == null) 
        helper = new Helper();
    return helper;
    }
  // 其他函数和成员...
  }

上面的代码将会在每一次调用getHelper()的时候同步,”双重检查锁”尝试在Helper被实例化后避免同步:

// 出错的多线程版本
// "双重检查锁"模式
class Foo { 
  private Helper helper = null;
  public Helper getHelper() {
    if (helper == null) 
      synchronized(this) {
        if (helper == null) 
          helper = new Helper();
      }    
    return helper;
    }
  // 其他函数和成员...
  }

然而,不幸的是,这段代码在优化的编译器或者共享内存的多处理器下都不会生效.

它不会生效的

它不生效有很多原因,我们首先要说明的两个原因很明显,当明白这两个原因后,也许你会尝试重新设计,来修复”双重检查锁”方法,但你的修复不会有用的:它不生效还有很多其他细微的原因,当你知道这些原因后,又打算修复它,但是因为它还有更多细微的问题,还是不会生效.

有很多非常聪明的人在这个问题上花了很多时间,但是为了让它生效,没有任何办法可以避免每个线程访问方法时的同步问题.

它不生效的第一个原因

它不生效的最明显的原因是,是对于初始化Helper这个对象(注:初始化引用)和初始化这个对象的域(注:即属性)的操作,可以乱序地完成或者乱序地被(注:客户代码)察觉到.因此,一个线程调用getHelper()方法时,可以得到一个不为null的引用,但是内部的值却是默认值(注:内部属性没有初始化)而不是构造器中赋予的值.

如果编译器将构造器的代码(优化)内联调用,而且如果编译器可以证明(注:即确认)构造器不会抛出异常或者不会同步的话,初始化对象和给对象域赋值的操作可以自由的重排序.
即便构造器不对这二者重排序,在多处理器环境,处理器或者内存系统都可能对它们重排序,并且被运行在另一个处理器上的线程感知到.

Doug Lea 写过一个对基于编译器重排序的更详细的描述

展示”双重检查锁”不能生效的测试用例

Paul Jakubik 找到了一个使用”双重检查锁”但是不能正确工作的例子:一个稍整洁的版本在这儿(注:本来在其他页面,整合到一起了)

public class DoubleCheckTest
  {


  // 帮助创建N个单例的静态数据(注:没赋值的变量将在main中赋值)
  static final Object dummyObject = new Object(); // 为引用初始化
  static final int A_VALUE = 256; // 初始化'a'的值
  static final int B_VALUE = 512; // 初始化'b'的值
  static final int C_VALUE = 1024;
  static ObjectHolder[] singletons;  // 静态引用的数组
  static Thread[] threads; // 竞争线程的数组
  static int threadCount; // 线程创建的数量
  static int singletonCount; // 单例创建的数量


  static volatile int recentSingleton;


  // I am going to set a couple of threads racing,
  // trying to create N singletons. Basically the
  // race is to initialize a single array of 
  // singleton references. The threads will use
  // double checked locking to control who 
  // initializes what. Any thread that does not
  // initialize a particular singleton will check 
  // to see if it sees a partially initialized view.
  // To keep from getting accidental synchronization,
  // each singleton is stored in an ObjectHolder 
  // and the ObjectHolder is used for 
  // synchronization. In the end the structure
  // is not exactly a singleton, but should be a
  // close enough approximation.
  // 

  // 我将设置两个线程的竞争,尝试创建N个单例.竞争仅是要初始化单例数组的引用.线程将会使用双重检查锁来控制谁初始化了什么.没有初始化一个特定单例的线程将会检查这个特定单例是否它有特定的初始化的结果.为了防止意外的同步,每一个单例被储存在ObjectHolder中,并且ObjectHolder将用于同步.最后,这个结构并不是准确的单例,但是应当已经足够接近了.


  // This class contains data and simulates a 
  // singleton. The static reference is stored in
  // a static array in DoubleCheckFail.
  //这个类包含了一些数据并且模拟了创建一个单例.静态引用将会存储在DoubleCheckFail的静态数组中
  static class Singleton
    {
    public int a;
    public int b;
    public int c;
    public Object dummy;

    public Singleton()
      {
      a = A_VALUE;
      b = B_VALUE;
      c = C_VALUE;
      dummy = dummyObject;
      }
    }

  static void checkSingleton(Singleton s, int index)
    {
    int s_a = s.a;
    int s_b = s.b;
    int s_c = s.c;
    Object s_d = s.dummy;
    if(s_a != A_VALUE)
      System.out.println("[" + index + "] Singleton.a not initialized " +
s_a);
    if(s_b != B_VALUE)
      System.out.println("[" + index 
                         + "] Singleton.b not intialized " + s_b);

    if(s_c != C_VALUE)
      System.out.println("[" + index 
                         + "] Singleton.c not intialized " + s_c);

    if(s_d != dummyObject)
      if(s_d == null)
        System.out.println("[" + index 
                           + "] Singleton.dummy not initialized," 
                           + " value is null");
      else
        System.out.println("[" + index 
                           + "] Singleton.dummy not initialized," 
                           + " value is garbage");
    }

  // 单例同步初始化的Holder
  static class ObjectHolder
    {
    public Singleton reference;
    }

  static class TestThread implements Runnable
    {
    public void run()
      {
      for(int i = 0; i < singletonCount; ++i)
        {
    ObjectHolder o = singletons[i];
        if(o.reference == null)
          {
          synchronized(o)
            {
            if (o.reference == null) {
              o.reference = new Singleton();
          recentSingleton = i;
          }
            // 这里无需检查singelton,信号量会提供一致的结果
            }
          }
        else {
          checkSingleton(o.reference, i);
      int j = recentSingleton-1;
      if (j > i) i = j;
      }
        } 
      }
    }

  public static void main(String[] args)
    {
    if( args.length != 2 )
      {
      System.err.println("usage: java DoubleCheckFail" +
                         " <numThreads> <numSingletons>");
      }
    // 从参数读值
    threadCount = Integer.parseInt(args[0]);
    singletonCount = Integer.parseInt(args[1]);

    // 创建数组
    threads = new Thread[threadCount];
    singletons = new ObjectHolder[singletonCount];

    // 填充单例数组
    for(int i = 0; i < singletonCount; ++i)
      singletons[i] = new ObjectHolder();

    // 填充线程数组
    for(int i = 0; i < threadCount; ++i)
      threads[i] = new Thread( new TestThread() );

    // 启动线程
    for(int i = 0; i < threadCount; ++i)
      threads[i].start();

    // 等待线程结束
    for(int i = 0; i < threadCount; ++i)
      {
      try
        {
        System.out.println("waiting to join " + i);
        threads[i].join();
        }
      catch(InterruptedException ex)
        {
        System.out.println("interrupted");
        }
      }
    System.out.println("done");
    }
  }

当在’Symantec JIT’的系统上运行它时,它将不会生效,特别的,’Symantec JIT’将编译singletons[i].reference = new Singleton();语句为以下字节码:

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].referenceSingleton的构造器被调用之前就分配了,而且这在C和C++中也是合法的(因为它们没有内存模型).

一个不起作用的补丁

对于上述示例代码,很多人建议使用如下代码:

// (仍然)出错的多线程版本
// "双重检查锁"模式
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();
            } // 释放内层同步锁
        helper = h;
        } 
      }    
    return helper;
    }
  // 其他函数和成员...
  }

这段代码将Helper对象的构造放到了内层的同步块,这个建议直观的想法是在释放同步的时候应当有内存屏障,并且内存屏障应当阻止对初始化Helper对象和分配helper对象域的重排序.

不幸的是,这个直觉是完全错误的,同步的规则并不会那么做.monitorexit指令(例如在释放同步时)的规则是,在monitorexit指令之前的操作必须在监视器(注:monitor)被释放前执行.然而,并没有规则说monitorexit指令之后的操作不会在监视器释放之前执行.

(注:这段是真的绕,以上面代码为例,这段话的意思是,在内层同步块的h=new Helper()一定会在同步块执行结束前执行成功,因为它在monitorexit指令之前,但是并不排除在内层同步块外的helper=h也在内层执行的时候一起被执行的可能,即便它在monitorexit指令之后).

编译器将赋值语句helper=h移入内层同步块是完全有原因并且合法的,这就使我们回到了问题最初的地方.很多处理器提供执行这种单项内存屏障(注:one-way memory barrier)的指令.要求释放锁成为一个完全内存屏障(full memory barrier)而改变语义是有性能损失的.

更多不会生效的补丁

你能做一些事情去强制写操作执行完全的双向内存屏障(full bidirectional memory barrier).但这是重量级的,低效的,并且不能保证在Java内存模型修订后还可以使用.不要这样做.如果你有学术方面的兴趣的化,我已经将关于这种技术的描写放到了另一个页面上.但请不要使用它.

然而,即使线程执行了完全的内存屏蔽(full momery barrier)来初始化helper对象,它还是不会有效.

问题在于,在一个系统中,看到helper的域不为null值的线程,也需执行内存屏障.

为什么?因为处理器有它们各自拷贝值内存的本地缓存.在一些处理器上,除非本处理器执行内存一致指令(即:内存屏障),否则读操作可以读出本地缓存拷贝中的数据,即便其他处理器使用了内存屏障来强制它们的写操作写入全局内存.

我创建了另一个页面来讨论这如何在Alpha处理器上发生.

值得这么麻烦吗?

对于多数应用,简单地使用同步的getHelper方法并不会有很高开销.如果你知道它为应用造成了大量的开销,你才应当考虑这种细节的优化.

而更常见的,更加聪明的做法,使用内置的归并排序而不是交换排序(参见 SPECJVM DB基准)将有更大的性能影响.

使其对静态单例生效

如果你创建的单例是静态的(即:仅会有一个Helper),而不是成为其他对象的属性.(例如,仅有一个Helper对应每一个Foo对象),有一个简单且优雅的解决方案.

在另一个类内定义单例为一个静态的域.这种Java的语义保证知道域被引用前不会初始化,并且所有访问域的线程都会看到因初始化域而产生的写操作.

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

对32位基本类型,它是有效的

尽管双重检查锁不能在引用对象上使用,但是它能在32位的基本类型(比如int或者float)上使用.注意它不能在long或者double上使用,因为对64位基本类型的未同步读写不保证是原子的.

// 对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;
    }
  // 其他函数和成员...
  }

事实上,如果假设computeHashCode函数永远返回相同的结果并且没有副作用(即幂等),你甚至能摆脱所有同步.

// 32位基本类型的懒加载
// 如果computehashCode是幂等的话,将是线程安全的
class Foo { 
  private int cachedHashCode = 0;
  public int hashCode() {
    int h = cachedHashCode;
    if (h == 0) {
      h = computeHashCode();
      cachedHashCode = h;
      }
    return h;
    }
  // 其他函数和成员...
}

使其在明确的内存屏障下有效

如果你拥有明确的内存屏障指令,让双重检查锁模式生效是有可能的.举个例子,如果你正在编写C++,你能使用来自Schmidt et al.的书:

// C++ implementation with explicit memory barriers
// Should work on any platform, including DEC Alphas
// From "Patterns for Concurrent and Distributed Objects",
// by Doug Schmidt
// C++用显式内存屏障的实现,在任何平台有效,包括DES Alphas
//来自"并发和分布式对象的模式",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 Storage)修复双重检查锁

Alexander Terekhov (TEREKHOV@de.ibm.com) 提出一个聪明的,使用线程局部储存来实现双重检查锁的建议.每一个线程拥有一个局部标志来表明是否该线程完成了要求的同步.

class Foo {
     /** 如果perThreadInstance.get()返回一个非null的值,则这个线程已经完成了初始化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();
        }
  // 任何非null的值都可以当作参数
        perThreadInstance.set(perThreadInstance);
    }
    }

这种技术的性能,很大程度上取决于你拥有哪一个JDK的实现.在Sun的JDK1.2实现上,ThreadLocal很慢.它们在JDK1.3上快了很多,并且预计在JDK1.4上还会更快.Doug Lea 分析了几种实现懒加载技术的性能

在新的Java内存模型下

对于JDK5,有新的Java模型和线程规范.

用volatile修复双重检查锁

JDK5及以上版本扩展了volatile的语义,以致于系统不允许对一个volatile变量的写操作和在它之前的读或写操作重排序.并且对一个volatile变量的读操作不会和在它之后的所有读或写操作重排序.在Heremy Manson的博客查看关于这一问题的更多细节

因为这个改变,双重检查锁模式在声明helper为volatile之后就可以生效了,这个操作不会在JDK1.4及以下版本有用.

// 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应当非常相像;对不可变对象的读和写操作是原子的.

对双重检查锁模式的描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值