本文根据http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html 来翻译,纯粹为了自己学习做记录,有生硬不通的地方还请海涵,也欢迎各位朋友指正。
在多线程环境下实现延迟加载时 Double-Checked Locking是通常使用的而且效率比较高的方法。不幸的是,如果没有其他同步机制的话,他也许不能在java平台可靠的运行。当使用其他语言实现时,比如c++,这取决于处理器的内存模型,编译器引起的reordering 和编译器与synchronization 库之间的相互作用。因为这些不是针对特定的语言,比如c++,几乎可以说会在其中一种情况工作。显示的内存屏障(memory barriers)可以在c++中使用,但是不能用在java中。
首先解释期望的动作,思考下面的代码:
如果这个代码用于多线程环境,很多事都会出错。最明显的,两个或者更多Helper对象会被创建(我们后续会讲述其他问题)。简单解决这个问题是对getHelper() 方法使用synchronize 关键字。
上面这段代码每次访问getHelper()方法都要同步进行,双重检查锁定(double-checked locking )试图避免在helpser对象创建后的同步化。
不幸的是,这个段代码将不能按期望工作在优化编译器或共享内存的多处理器。
他不能工作
不能工作有很多原因,下面描述的第一个原因很明显的。理解了这些,你也许会被诱惑着去设计修正双重检查锁定的方法。但是你的解决方案不能工作:有很多微妙的原因。理解这些原因之后想出更好的解决方案,但是这个也不能工作,因为还有更多微妙的原因。
很多聪明的人花了很多时间关注这个,但是除了让每个线程同步访问helpser对象外没有办法解决这个问题。
不能工作的第一个原因
不能工作的最明显原因是初始化Helper对象和helpser字段的赋值可能按顺序进行也可能颠倒。因此一个线程调用getHelper()能得到非空的helper对象引用,但是看到是helper里的字段是初始值,而不是构造函数里设置的值。
如果编译器内联到构造函数的调用且能保证构造函数不会抛出异常或执行同步。,那么初始化对象的操作和对helper 里字段的写入可以自由重排。
即使编译器没有重排这些操作,在一个多线程处理器或内存系统可能会重排这些写操作,当感知到另一个线程在另一个处理器上执行时。
Doug Lea 写了一篇 more detailed description of compiler-based reorderings.
测试用例,说明他不能工作
Paul Jakubik 发现一个使用双重检查锁定但是不能正确工作的例子. A slightly cleaned up version of that code is available here.
在使用Symantec JIT编译器的系统上不能工作。特别是Symantec JIT 编译
如下所示(注意Symantec JIT 使用基于句柄的对象分配系统)
就像你看到的,分配给singletons[i]的应用是在Singleton 的构造函数被执行前。这在java的内存模型中是完全合法的,而且在c和c++中也同样。
一个不能工作的修正方案
根据上述的解释,一些人可能会给出下面的代码:
这段代码将Helper对象的构造函数放入synchronized 块内,直观的想法是在同步块释放的点有内存屏障(memory barrier ),这样能够避免颠倒初始化Helper和对helper字段赋值的顺序。
不幸的是,这个是完全错误的。这个同步规则将不会这么工作。对于监视器退出规则(比如:释放同步)是指在监视退出前的操作必须在监视器释放前执行。然而没有规则说那些在监视器退出之后的操作不能在监视器释放前完成。编译器将变量的分配helper = h放入同步块是完全合法的,在这种情况下我们又回到了之前的地方。许多处理器提供执行这种单向内存屏障的指令。但是变更语义要求释放锁是一个完整的内存屏障将有性能损失。
更多的不能工作的修补程序
有些操作可以强制写进程执行完全的双向内存屏障。但这是粗劣,低效的,而且一旦java内存模型改变基本上就不能工作。不要使用这些技术。
但是即使在helper对像初始化时,一个完整的内存屏障被线程执行,他仍然无法正常工作。
问题是在某些系统中,那些看到helper字段非空值的线程也需要执行内存屏障。
为什么?因为处理器有他们自己本地的内存副本。某些处理器,如果没有执行缓存一致性指令(例如,内存屏障),读线程可能读到过期的本地缓存副本,即使其他处理器使用内存屏障强制写进程写到主内存。这里专门讨论这种情况怎样发生在Alpha 处理器上 a separate web page 。
是否值得这么麻烦?
对大多数应用来说,简单的将getHelper() 方法同步化的成本并不高。只有在你知道这会造成应用程序重大开销而且可以接受的情况下才考虑这种优化方案。
很多时候,更聪明的方法是使用内建的归并排序而不是处理交换排序(见 JVM DB基准说明)会更有效。
在静态单例的情况下工作
如果你建立的单例是静态的(比如,只有一个Helper对象会被创建),相对于另一种对象属性(比如,每一个Foo对象有一个Helper对象)有一种简单优雅的解决方法。
只要在一个单独的类中定义一个静态字段。Java的语义保证字段不会被初始化直到字段被引用,而且所有使用该字段的线程将看到所有在初始化字段时的写入结果。
在32位原始值上工作
虽然双重检查锁定不能用于引用类型对象,但是他可以用于32位原始类型(比如,int或float)。注意他不能工作于long或者double,因为非同步的64位原始类型的读写不保证是原子的。
事实上假设方法computeHashCode()总是返回相同的结果而且没有副作用(比如,幂等--一个操作不会修改状态信息,并且每次操作的时候都返回同样的结果。即:做多次和做一次的效果是一样 的。)你甚至可以去掉所有的同步。
使用显示的内存屏障
如果你有明确的内存屏障指令他可能会让双重检查锁定模式工作。比如,如果你使用c++,你可以使用Doug Schmidt 等人书中的代码:
使用ThreadLocal 存储
Alexander Terekhov 提出了一个聪明的建议:使用thread local存储来实现双重检查锁定。每个线程保留一个线程本地标志来确定该线程是否已完成所需的同步。
这种技术的性能相当程度上取决于你使用的JDK,在JDK1.2上很慢,但是在JDK1.3之后的版本就快很多。
新的java内存模型
JDK5, a new Java Memory Model and Thread specification.
是用Volatile关键字
JDK5及以后的版本扩展了volatile的语义,这样系统将不允许对一个volatile的写操作与之前的读写操作进行重排。而且对volatile的读操作也不会与后续的读写进行重排。详细请见: this entry in Jeremy Manson's blog
根据这个双重检查锁定可以在helper字段声明为volatile时正常工作,但是在JDK4及之前不行。
双重检查锁定不变对象
如果Helper是不可变对象,比如Helper所有字段都是final,那么不是用volatile关键字双重检查锁定也能正常工作。这个思路是一个不可变对象(比如,String 或 Integer)在很大程度上就像int或float;读写不可变对象是原子的。