锁定老帖子 主题:“双重检查锁定被打破”的声明
该帖已经被评为新手帖
|
|
---|---|
作者 | 正文 |
发表时间:2012-03-30
“双重检查锁定被打破”的声明 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 如果这段代码在多线程环境下运行,会有很多问题。最明显的问题是,两个或多个Helper对象被创建。(稍后我们会讲述其他问题)。简单的解决方法是给getHelper()方法加上同步。 // Correct multithreaded version 上面这段代码,每次调用getHelper()时都会进行同步。双重检查锁定试图避免在helper对象被创建后的同步。 // Broken multithreaded version 很可惜,在优化编译器或者共享内存的多处理器计算机上,前面的代码不能正常运行。 不能正常运行 不能正常运行有很多原因。我们描述的第一对原因很明显。理解了这些,你可能试图想办法“解决”双重检查锁定存在的问题。但你的解决方案不起作用:因为有很微妙的原因。理解这些原因后,会提出更好的解决方案,但解决方案还是有问题,因为还有更微妙的原因。 许多非常聪明的人花了大量的时间关注这个问题。但是除了让每个线程同步访问helpser对象外没有办法解决这个问题。 不能正常运行的第一个原因 不能正常运行最明显的原因是,初始化Helper对象和helpser字段的赋值没有按顺序去做完。因此,调用getHelper()方法的线程,已经拥有helper对象的非空引用,但看到helper对象的字段的默认值,而不是构造函数里设置的值。 如果编译器内联构造函数的调用且能证明构造函数不抛出异常或不执行同步,那么初始化对象和对象字段的赋值是可以自由的调整顺序的。 即使编译器不调整这些写操作的顺序,在多处理器计算机上,如果一个线程在另一个处理器上运行,处理器或内存系统也可能调整这些写操作的顺序。 Doug Lea 详细描述了基于编译的调整顺序。 表明不能正常运行的测试案例 Paul Jakubik给出了使用双重检查锁定不能正确运行的例子。稍微简化的代码如下。 当在使用Symantec JIT(just-in-time )编译器的系统上运行程序,程序不能正常运行。 如下所示(注意Symantec JIT 使用基于句柄的对象分配系统) 0206106A mov eax,0F97E78h 正如你所见到的,给singletons[i].reference赋值在Singleton构造器被调用之前执行。在现有的java内存模型里这是完全合法的,在C和C++里也是合法的(因为它们都没有自己的内存模型) 不能正常运行的解决方法 鉴于上述解释,有人建议这样编码: 这段代码把Helper对象的构造放到最里面的同步块里。这里认为在同步块被释放时应该有内存屏障(memory barrier), 以阻止初始化Helper对象和给helper字段赋值的顺序颠倒。 很可惜,这种想法是绝对错误的。同步的规则不是这样的。退出监视器(释放同步)的规则是,所有退出监视器之前的动作必须在释放监视器之前执行。然而,并没有规定说,退出监视器之后的动作不可以在释放监视器之前执行。也就是说同步块里的代码必须在退出同步时完成,而同步块后面的代码则可以被编译器或运行时环境移到同步块中执行。对于编译器,将instance = temp移动到最里层的同步块内完全合法,也合理。这样就出现了上个版本同样的问题。很多处理器提供执行这种单向内存屏障的指令。但如果改变语义,要求释放锁为一个完整内存屏障会带来性能损失。 不能正常运行的更多修改 你可以强制写操作执行全双向内存屏障。但这是粗放的,低效的,并且,一旦java内存模型改变,几乎无法保证工作。不要这样用,我专门为这个技术写了一篇文章,不要这样用。 然而,即使初始化helper对象的线程执行一个完整内存屏障,它仍然不能正常运行。 这是因为在一些操作系统里,看到helper字段非空值的线程同样需要执行内存屏障 为什么?因为处理器有它们自己的本地内存拷贝。有一些处理器,除非处理器执行缓存一致的指令(例如内存屏障),否则可能读到脏的本地内存拷贝,即使其他处理器使用内存屏障来强制写入全主内存。 我已经写了几篇web文章来讨论,在alpha处理器上这种情况是怎么发生的。 麻烦值得吗? 大部分程序,简单的同步getHelper()代价并不高。只有当你认为同步getHelper()会引起巨大的开销时,你才应该考虑采用这种详细的优化方案。 很多时候,更聪明的方法是使用内建的归并排序而不是处理交换排序会更有效。 静态单例下正常运行 如果你创建的单例是静态的(例如,只有一个Helper对象被创建),相对于另一种对象属性(例如,每一个Foo对象都有一个Helper对象)则有一个简单优雅的解决方案。 在一个独立的类里定义单例作为静态字段。java语义会保证字段被引用后才被初始化,访问字段的任何线程都会看到初始化字段后写入的结果。 class HelperSingleton { 32位原始类型的值能正常运行。 尽管双重检查锁定不能被用到对象引用上,但它可用在32位原始类型的值上(例如,int型或float型)。注意,long型和double型是不可以的,因为64位原始类型不同步的读写不被保证是原子的。 // Correct Double-Checked Locking for 32-bit primitives 事实上,假设computeHashCode()函数总是返回同样的结果且没有副作用(例如,幂等),你甚至可以删除所有的同步。 // Lazy initialization 32-bit primitives 使用显示的内存屏障 如果有明确的内存屏障指令,双重检查锁定模式正常运行是有可能的。例如,如果用c++编程,可以使用Doug Schmidt 等人书中的代码: // C++ implementation with explicit memory barriers 使用Thread Local 存储解决双重检查锁定问题 Alexander Terekhov 提出了一个聪明的建议:用thread local 存储实现双重检查锁定。每个线程保留线程本地标志位,决定线程是否已完成所需的同步 class Foo { 这种技术的性能非常依赖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 双重检查锁定不可变对象 如果Helper是不可变对象,就是说Helper的所有字段是final的,那么双重检查锁定不需要使用volatile就可以正常运行。不可变对象的引用(例如String或Integer)应该和int或float有类似的行为。读写不可变对象的引用是原子的。 Descriptions of double-check idiom
原文 http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html 声明:ITeye文章版权属于作者,受法律保护。没有作者书面许可不得转载。
推荐链接
|
|
返回顶楼 | |
发表时间:2012-03-31
DCL貌似讨论过好多次了
|
|
返回顶楼 | |
发表时间:2012-04-01
不错,学习了
|
|
返回顶楼 | |
发表时间:2012-04-01
论坛里搜搜,印象里有百十个贴
|
|
返回顶楼 | |
发表时间:2012-04-01
out of order solved?
|
|
返回顶楼 | |
浏览 3459 次