`

“双重检查锁定被打破”的声明

阅读更多

“双重检查锁定被打破”的声明
The "Double-Checked Locking is Broken" Declaration

Signed by: 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
翻译 彭强兵

在多线程环境下实现延迟加载时,双重检查锁定是广泛使用的而且高效的方法。

很可惜,如果没有额外的同步机制,它也许不能在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对象被创建后的同步。

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

很可惜,在优化编译器或者共享内存的多处理器计算机上,前面的代码不能正常运行。

不能正常运行

不能正常运行有很多原因。我们描述的第一对原因很明显。理解了这些,你可能试图想办法“解决”双重检查锁定存在的问题。但你的解决方案不起作用:因为有很微妙的原因。理解这些原因后,会提出更好的解决方案,但解决方案还是有问题,因为还有更微妙的原因。

许多非常聪明的人花了大量的时间关注这个问题。但是除了让每个线程同步访问helpser对象外没有办法解决这个问题。

不能正常运行的第一个原因

不能正常运行最明显的原因是,初始化Helper对象和helpser字段的赋值没有按顺序去做完。因此,调用getHelper()方法的线程,已经拥有helper对象的非空引用,但看到helper对象的字段的默认值,而不是构造函数里设置的值。

如果编译器内联构造函数的调用且能证明构造函数不抛出异常或不执行同步,那么初始化对象和对象字段的赋值是可以自由的调整顺序的。

即使编译器不调整这些写操作的顺序,在多处理器计算机上,如果一个线程在另一个处理器上运行,处理器或内存系统也可能调整这些写操作的顺序。

Doug Lea 详细描述了基于编译的调整顺序。

表明不能正常运行的测试案例

Paul Jakubik给出了使用双重检查锁定不能正确运行的例子。稍微简化的代码如下。

当在使用Symantec JIT(just-in-time )编译器的系统上运行程序,程序不能正常运行。

如下所示(注意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对象的构造放到最里面的同步块里。这里认为在同步块被释放时应该有内存屏障(memory barrier), 以阻止初始化Helper对象和给helper字段赋值的顺序颠倒。

很可惜,这种想法是绝对错误的。同步的规则不是这样的。退出监视器(释放同步)的规则是,所有退出监视器之前的动作必须在释放监视器之前执行。然而,并没有规定说,退出监视器之后的动作不可以在释放监视器之前执行。也就是说同步块里的代码必须在退出同步时完成,而同步块后面的代码则可以被编译器或运行时环境移到同步块中执行。对于编译器,将instance = temp移动到最里层的同步块内完全合法,也合理。这样就出现了上个版本同样的问题。很多处理器提供执行这种单向内存屏障的指令。但如果改变语义,要求释放锁为一个完整内存屏障会带来性能损失。

不能正常运行的更多修改

你可以强制写操作执行全双向内存屏障。但这是粗放的,低效的,并且,一旦java内存模型改变,几乎无法保证工作。不要这样用,我专门为这个技术写了一篇文章,不要这样用。

然而,即使初始化helper对象的线程执行一个完整内存屏障,它仍然不能正常运行。

这是因为在一些操作系统里,看到helper字段非空值的线程同样需要执行内存屏障

为什么?因为处理器有它们自己的本地内存拷贝。有一些处理器,除非处理器执行缓存一致的指令(例如内存屏障),否则可能读到脏的本地内存拷贝,即使其他处理器使用内存屏障来强制写入全主内存。

我已经写了几篇web文章来讨论,在alpha处理器上这种情况是怎么发生的。

麻烦值得吗?

大部分程序,简单的同步getHelper()代价并不高。只有当你认为同步getHelper()会引起巨大的开销时,你才应该考虑采用这种详细的优化方案。

很多时候,更聪明的方法是使用内建的归并排序而不是处理交换排序会更有效。

静态单例下正常运行

如果你创建的单例是静态的(例如,只有一个Helper对象被创建),相对于另一种对象属性(例如,每一个Foo对象都有一个Helper对象)则有一个简单优雅的解决方案。

在一个独立的类里定义单例作为静态字段。java语义会保证字段被引用后才被初始化,访问字段的任何线程都会看到初始化字段后写入的结果。

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

事实上,假设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++编程,可以使用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;
    }

使用Thread Local 存储解决双重检查锁定问题

Alexander Terekhov 提出了一个聪明的建议:用thread local 存储实现双重检查锁定。每个线程保留线程本地标志位,决定线程是否已完成所需的同步

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的版本,在jdk1.2里,ThreadLocal's 是非常慢的。1.3里明显快了很多,并且1.4里被期望更快。Doug Lea 分析了实现延迟加载的一些技术的性能。

新的java内存模型

在JDK5里,有新的java内存模型和线程规范。

用Volatile解决双重检查锁定问题

JDK5及以后的版本扩展了volatile的语义,这样系统不允许volatile变量的写操作和前面的读写操作调整顺序,也不允许volatile变量的读操作和后面的读写操作调整顺序。Jeremy Manson's 的博客里可以看到这个条目的更详细的介绍。

因为这个变化,通过声明helper字段为volatile保证双重检查锁定正常运行,但在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的所有字段是final的,那么双重检查锁定不需要使用volatile就可以正常运行。不可变对象的引用(例如String或Integer)应该和int或float有类似的行为。读写不可变对象的引用是原子的。

Descriptions of double-check idiom
Reality Check, Douglas C. Schmidt, C++ Report, SIGS, Vol. 8, No. 3, March 1996.
Double-Checked Locking: An Optimization Pattern for Efficiently Initializing and Accessing Thread-safe Objects, Douglas Schmidt and Tim Harrison. 3rd annual Pattern Languages of Program Design conference, 1996
Lazy instantiation, Philip Bishop and Nigel Warren, JavaWorld Magazine
Programming Java threads in the real world, Part 7, Allen Holub, Javaworld Magazine, April 1999.
Java 2 Performance and Idiom Guide, Craig Larman and Rhett Guthrie, p100.
Java in Practice: Design Styles and Idioms for Effective Java, Nigel Warren and Philip Bishop, p142.
Rule 99, The Elements of Java Style, Allan Vermeulen, Scott Ambler, Greg Bumgardner, Eldon Metz, Trvor Misfeldt, Jim Shur, Patrick Thompson, SIGS Reference library
Global Variables in Java with the Singleton Pattern, Wiebe de Jong, Gamelan

 

原文 http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html

分享到:
评论

相关推荐

    【Java设计模式-源码】双重检查锁定模式:以最小开销确保线程安全

    双重检查锁定设计模式的目的是通过首先在不实际获取锁的情况下测试锁定条件(“锁提示”)来减少获取锁的开销。只有当锁定条件似乎为真时,实际的锁定逻辑才会继续。Java中的双重检查锁定有助于优化性能并确保线程...

    java双重检查锁定的实现代码

    Java 双重检查锁定的实现代码 Java 双重检查锁定是一种常用的线程安全机制,用于延迟初始化对象。在 Java 程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。这种机制...

    双重检查锁

    ### 双重检查锁——新旧内存模型的对比 #### 一、双重检查锁概念解析 双重检查锁(Double-Checked Locking, DCL)是一种在多线程环境中用于实现懒加载(lazy loading)的设计模式。它通过两次检查来确定是否需要获取锁...

    单例双检锁

    在努力创建更有效的代码时,Java 程序员们创建了双重检查锁定习语,将其和单例创建模式一起使用,从而限制同步代码量。然而,由于一些不太常见的 Java 内存模型细节的原因,并不能保证这个双重检查锁定习语有效。 ...

    Java双重检查加锁单例模式的详解

    "Java双重检查加锁单例模式的详解" Java双重检查加锁单例模式是一种常用的单例模式实现方法,但是在多线程环境下,它存在一些问题。在这篇文章中,我们将探讨Java双重检查加锁单例模式的详解,包括它的优点和缺点,...

    Java-设计模式-单例模式-实现源码(简单实现、双重检查锁、静态内部类、枚举类)

    在Java中,有多种实现单例模式的方法,包括简单实现、双重检查锁定(Double-Checked Locking)、静态内部类和枚举类。下面我们将详细探讨这些不同的实现方式。 1. **简单实现(非线程安全)** 最简单的单例实现...

    双重预防体系建设检查表.doc

    双重预防体系建设检查表.doc

    电子政务-兆瓦级风力发电机组具有双重锁定功能的主轴锁.zip

    在进行设备检查、维修或更换部件时,可以快速可靠地锁定主轴,减少停机时间。同时,这样的设计也有助于防止因锁定失效引发的事故,保护工作人员的生命安全,降低设备损坏风险。 在"行业分类-电子政务-兆瓦级风力...

    电子政务-双重锁定式电连接器.zip

    双重锁定式电连接器的特点在于其独特的锁定机制,它不仅有基本的物理插拔锁定,还增加了一层机械或电气的二次锁定,确保在极端环境或振动条件下保持稳固的连接。这种设计能够防止因意外松动导致的连接中断,提高整体...

    行业资料-交通装置-一种抽油机的双重刹车锁定装置.zip

    行业资料-交通装置-一种抽油机的双重刹车锁定装置.zip

    行业资料-电子功用-具有双重锁定功能的电器电源控制装置的介绍分析.rar

    标题中的“行业资料-电子功用-具有双重锁定功能的电器电源控制装置的介绍分析”表明了这份资料专注于电子工程领域,特别是关于一种特殊设计的电器电源控制装置,它具有双重锁定功能,这样的设计是为了增强安全性和...

    行业资料-电子功用-具有双重锁定件的电连接器的介绍分析.rar

    《具有双重锁定件的电连接器的介绍分析》 在电子工程领域,电连接器扮演着至关重要的角色,它们是电路系统中不可或缺的组件,确保设备间的电气连接稳定可靠。本资料主要关注一种特殊的电连接器——具有双重锁定件的...

    单例模式七种写法_转

    为了解决双重检查锁定失效的问题,Java 5.0及之后的版本对内存模型进行了改进,严格规定了指令重排序的行为,使得在多线程环境下,双重检查锁定的实现可以按预期工作。但即使如此,由于各种复杂的内存模型和指令优化...

    C++ and the Perils of Double Checked Locking.zip

    双重检查锁定是一种设计模式,用于确保单例模式(Singleton)的实例在多线程环境中被正确且高效地创建。其基本思想是,在创建单例对象时,先检查是否已经创建了该对象(第一次检查),如果未创建,则进行加锁操作,...

    空间双重差分代码(SDID),空间双重差分是传统双重差分的升级,考虑了空间相关性

    这个要求有点太高了,随着地区间的交流越来越密切,政策的实施效果难免会有扩散效应,因此,这个假设在考虑到空间相关性时被打破了,当不同空间单元之间存在相关性即存在空间溢出效应时,SUTVA不再成立(Kolak&...

    Java中的双重检查(Double-Check)详解

    Java中的双重检查(Double-Check)是一种用于实现线程安全单例模式的设计策略,它的核心思想是在确保对象只被初始化一次的同时,尽可能地减少同步的使用以提高性能。然而,在早期的Java版本中,双重检查模式存在一些...

    双重qlist用法

    如果内部QList是临时对象,确保在外部QList中保留引用之前,内部QList不会被销毁。 5. 避免指针悬空:如果内部QList存储的是指针类型,如`QList*&gt; &gt;`,需要确保所有指针都指向有效的对象,并且在适当的时候释放内存...

    双重预防体系建设检查表.pdf

    而《双重预防体系建设检查表》则是一个评估工具,用于检验企业是否按照相关要求构建了这一重要体系。 在组织机构建设方面,双重预防体系的实施需要企业建立明确的组织架构。这意味着企业必须由主要负责人、分管负责...

Global site tag (gtag.js) - Google Analytics