错误的双重检查锁
public static Test4 test4 = null;
public static void main(String[] args) {
if (test4 != null) {
synchronized (Thread.class) {
if (test4 != null) {
test4 = new Test4();
}
}
}
按照期望的,就是通过外层判断减少持锁判断的次数,以增加性能.
预期中最差的执行情况是,由于test4没有被volatile修饰,处理器缓存依旧可用,导致对于运行在其它处理器的线程来说,
如果在test4被赋值之前就读取了变量作为缓存,那么互相的test4引用变量的修改情况不可见且为空,所有线程都需要进入synchronized持锁再判断.
使用synchronized持有monitor lock,保证内层判断每次只有一个线程可以访问.
这样,当一个线程赋值之后,这些持有缓存的线程再进入就会通过内层判断知道已经存在test4然后直接退出.
但是实际情况会更糟糕,因为存在指令重排序的问题,test4 = new Test4();编译出的字节码可能存在乱序执行的情况,导致test4变量出现引用不为null但是构造方法还未执行的情况.
public static Test4 test4 = null;
public static void main(String[] args) {
synchronized (Test4.class) {
test4 = new Test4();
}
}
//javap -c Test4.class
4: monitorenter
5: new #2 // class com/example/threadCoreKnowledge/ThreadStateShow/Test4
8: dup
9: invokespecial #3 // Method "<init>":()V
12: putstatic #4 // Field test4:Lcom/example/threadCoreKnowledge/ThreadStateShow/Test4;
15: aload_1
16: monitorexit
可以看到test4 = new Test4();这行代码编译为字节码指令包含new,dup,invokespecial,putstatic,
当重排序后,由于test4不是volatile变量语义上没有禁止重排序,且invokespecial和putstatic之间不存在happen-before关系,也就是单线程中两个操作谁先执行都不影响后面代码的运行结果,所以执行顺序可能被打乱.
也就存在// Method "<init>":()V的构造函数还没执行完成,
就已经将引用赋值(putstatic)给了静态变量test4,
这使得其它线程有可能访问到一个错误的对象(构造函数未执行/未执行完).
往往导致程序的执行不符合预期且可能产生错误.
为什么会发生重排序:
经过如下因素处理,可以提升程序的执行速度.
①在编译器中生成的指令顺序,可以与源代码中的顺序不同,此外编译器还会把变量保存在寄存器而不是内存中;
②处理器可以采用乱序或并行等方式来执行指令;
③缓存可能会改变将写入变量提交到主内存的次序;
④保存在处理器本地缓存中的值,对于其它处理器来说是不可见的;
如果没有使用正确的同步,这些因素都会导致一个线程无法看到变量的最新值,并且导致其它线程中的内存操作似乎在乱序执行.
为什么已经通过synchronized确保操作有序性,操作还是会出错?
因为synchronized的有序性是通过monitor锁的机制
确保代码块在同一时刻只有一个线程访问
而JVM根据Java语言规范对于"一个代码块在同一时刻只有一个线程访问"的情况,会维护一种类似串行的语义:即代码块程序执行结果与在严格串行环境中执行结果相同的情况下,上面指令重排序的因素[①②③④]就都是允许的.
正确的双重检查锁
这种模式避免了在域被初始化之后访问这个域时的锁定开销
public static volatile Test4 test4 = null;
//添加局部变量,确保变量在被初始化后,每次方法执行时只被读取一次(Test4 temp=test4)
//因为字段添加了volatile关键字,确保每次读写都去内存中进行,这
//抛弃了CPU自带的高速多级缓存,使字段可以被在其它cpu中执行的线程
//察觉到变化.通过减少读取的次数,更多的利用到了高速多级缓存,使程序
//获得硬件上的速度提升
public static void main(String[] args) {
Test4 temp=test4;
if (temp != null) {
synchronized (Thread.class) {
if (test4 != null) {
test4 = temp = new Test4();
}
}
}
双重检查锁的起因和不再推荐使用的原因
由于早期的JVM在性能上存在一些有待优化的地方,因此延迟初始化经常被用来避免不必要的高开销操作,或者降低程序的启动时间.在编写正确的延迟初始化方法中需要使用同步.但在JDK5以及之前,同步操作是很慢的.
DCL(double check lock)则能两全其美,在常见代码路径上的延迟初始化中不存在同步开销.
然而,DCL这种使用方法已经被广泛地废弃了
促使该模式出现的驱动力(同步竞争执行速度很慢,以及JVM启动速度很慢)已经不复存在,因而它不时一种高效的优化措施.
延迟初始化站位模式能带来同样的优势,且更容易理解
/**
* ResourceFactory
* <p/>
* Lazy initialization holder class idiom
*
* @author Brian Goetz and Tim Peierls
*/
@ThreadSafe
public class ResourceFactory {
private static class ResourceHolder {
public static Resource resource = new Resource();
}
public static Resource getResource() {
return ResourceFactory.ResourceHolder.resource;
}
static class Resource {
}
}
//使用了一个专门的类来初始化Resource.JVM将推迟ResourceHolder的初始化操作,直到开始使用这个类才初始化,并且因为是通过一个静态初始化来初始化Resource的,JVM保证全局只在类初始化时执行一次所以不需要额外的同步操作.
//当任何一个线程第一次调用getResource时,都会使ResourceHolder被加载并被初始化,此时静态初始化器将执行Resource的初始化操作.
参考文献
Java并发编程实战:16.1什么是内存模型,为什么需要它
Java并发编程实战:16.2.4双重检查加锁
Effective Java 3:Item83,慎用延迟初始化
深入理解Java虚拟机第三版:12.3.5原子性、可见性与有序性-3.有序性