深入了解DCL
1.什么是DCL
DCL:double-checked locking 中文一般译为“双重检查锁”
通常其代码形式如下:
class SomeClass {
private Resource resource = null;
public Resource getResource() {
if (resource == null) { //检查点1
synchronized {
if (resource == null) //检查点2
resource = new Resource();
}
}
return resource;
}
}
上述代码注释中的两个检查点构成了所谓“双重检查”。
它的初衷是:
惰性加载resource。
规避synchronized 同步块被频繁访问以提高并发性能。
2.DCL过去不安全的原因
2.1 JMM的缺陷
在JVM1.4推出前,JVM对内存的访问基于原JMM(Java Memory Model)规范,它尝试定义一个一致的、跨平台的内存模型,描述了包括多线程情况下的内存访问规则,可惜它是有缺陷的。在原JMM的约束下,仍然允许发生一些奇怪而混乱的状况。典型的例子如在构造函数中为final字段赋值,另一个线程看到的可能是final字段的默认值,而不是对象在构造方法提供的值。
这些问题,原因如下:
2.2reorder导致的constructor escape问题
reoder,我的理解是指在编译时和运行时,编译器或JVM能打乱最终输出的汇编代码的执行顺序,从而优化程序的执行性能。这个顺序与开发人员在java源码中“看到的”程序执行顺序可能完全不同。例如简单的一句
resource = new Resource();
被转换成汇编后至少有如下3个步骤:
为 Resource对象在堆中分配空间
执行 Resource的构造函数
将新生成的Resource对象的引用赋值给resource 字段
而被reorder后,上述3项的执行步骤极有可能会变成1,3,2。
于是,在多线程情况下,别的线程可能会访问到一个resource 不为null却没有执行完构造函数的无效引用。这使得DCL的检查点1不再可靠。
3尝试修复DCL的方法
3.1用volatile修饰字段 resource
class SomeClass {
privatevolatileResource resource = null;
public Resource getResource() {
if (resource == null) {
synchronized {
if (resource == null)
resource = new Resource();
}
}
return resource;
}
}
在JVM1.4之前,volatile只保证对所修饰的对象执行写操作时,该写操作本身不会被重排序,且写入的值将被立即更新到主存中,以保证所有线程见到的volatile对象是一致的(所有线程会被强制去主存而非线程缓存或多核cpu的寄存器/高速缓存中获取数据);但其仍然允许对volatile对象的写操作与non-volatile对象的写操作被混在一起重排序。于是悲剧照旧,除非Resource类中所有字段都用volatile修饰。
3.2骗过编译器
代码如下:
public Resource getResource() {
if (resource == null) {
synchronized {
if (resource == null) {
Resource temp = new Resource();//步骤1
resource = temp;//步骤2
}
}
return resource;
}
使用一个临时变量,看起来结果似乎会是,任由你构造过程如何被重排序,等执行到步骤2的时候,构造过程必然已经完成了。可惜实际上,编译器的优化选项很可能会毫不留情地把这个临时变量从字节码中剔除,它显然是多余的。即使绕过了编译器优化,仍然行不通,因为编译器只是潜在的reorder推手之一,处理器和缓存仍然会在运行时起作用。自从现代的处理器能推断出两个指令之间有没有依赖,便会自行决定是并行处理还是乱序处理这些指令。同样地,各线程的回写缓存也会在多条数据即将被更新到主存的过程中改变数据的回写顺序。
因此,reorder的行为是不可预知的,且无法在java源码级别上绕过去。
结论是:无法修复DCL
4.为什么现在一部分DCL是安全的
在最近一次关于DCL的邮件讨论中,有人提到,线上环境使用DCL没有发现任何问题,这又是为什么呢?首先,reorder的发生是依赖于具体的JVM实现,哪怕在jvm1.4之前,也是有小部分JVM是可以在某些硬件上正常使用DCL的;其次,随着JSR-133的推出,jdk1.4已经部分修正了JMM,jvm1.5则完全修正了JMM,而我们线上目前使用的是jdk1.6。在修正后的JMM中,只要配合使用volatile,DCL是安全的。
4.1从JSR-133说起:修正后的JMM
JSR-133中定义的JMM保证了内存操作跨线程的可见性,保证了 volatile 、 synchronized 和 final 的语义。
4.1.1【final】
“JMM的缺陷 ”一节中曾提到,在不使用同步时,另一个线程会首先看到 final 字段的默认值,然后才看到正确的值。这与final的语义不符。在新的JMM中,通过实现如下几点来保证final的语义:
对final字段的写必然先于其它线程装载该final字段的共享引用。(final字段必须被正确地赋值后其它线程才能读取到它)
构造函数执行完毕后,对 final 字段的所有写以及通过这些 final 字段间接可及的变量变为“冻结”,所有在冻结之后获得对这个对象的引用的线程都会保证看到所有冻结字段的冻结值。(所有线程对final及其间接字段有一致的可见性)
初始化 final 字段的写将不会与构造函数关联的冻结后面的操作一起重排序。(构造函数内部,对某个final字段而言,它的冻结点之前的操作必然先于冻结点之后的操作)
4.1.2【volatile】
在新的内存模型下,上文“尝试1、用volatile修饰字段resource”中描述的问题已经发生了变化,现在对volatile字段的读写不能与其他内存操作一起重排序了,而且当线程 A 写入 volatile 变量 V, 同时线程 B 读取 V 时,A 可见的所有变量值都可以保证对 B 是可见的。结果就是作用更大的 volatile 语义(原来只保证读写volatile 字段本身时所有线程的可见性,现在保证读写时volatile字段时,它本身及所有相关字段的可见性),这促成了原来一些有潜在隐患,却被大多数开发人员想当然认为可行的代码,代价是访问
volatile 字段的性能开销已今非昔比。
因此,在新的内存模型下,使用带有volatile 修饰的DCL是安全可行的。
4.1.3【happens-before】
除此之外,JSR-133还修正了原有JMM中其它一些问题,这些修正体现在下述所谓“happens-before”的法则中,有兴趣的同学可以通过参考资料继续深入了解。
Each action in a thread happens-before every action in that thread that comes later in the program order
An unlock on a monitor happens-before every subsequent lock on that same monitor
A write to a volatile field happens-before every subsequent read of that same volatile
A call to Thread.start() on a thread happens-before any actions in the started thread
All actions in a thread happen-before any other thread successfully returns from a Thread.join() on that thread
A happens-before B意即A的执行必然先于B,前文中对于final语义的几点保证就可以理解为上述happens-before法则的具体体现。
5.不推荐使用DCL的原因
文章开头便提到使用DCL的目的是惰性加载和提高性能。若不使用volatile ,DCL的可靠性无从保证;反之由于在新的JMM中调整并加强了volatile 的语义,其带来的副作用使得volatile 的性能开销变得与同步块相差无几。因此,虽然现在带有volatile 的DCL是可靠的,仍然不推荐。
6.DCL的完美替代
通过内部类实现惰性加载:
public class Singleton{
private Singleton(){