JAVA并发编程的艺术读书笔记
双重检查锁定与延迟初始化
在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。
非线程安全的延迟初始化对象
package 双重检查锁定与延迟初始化; //非线程安全的延迟初始化对象 public class UnsafeLazyInitialization { private static UnsafeLazyInitialization instance; public static UnsafeLazyInitialization getInstance(){ if(instance == null)//代码1 instance = new UnsafeLazyInitialization();//代码2 return instance; } }
在上面的类中,假设线程A执行代码1的同时,B线程执行代码2。此时,线程A可能会看到instance引用的对象还没有完成初始化。
线程安全的延迟初始化
package 双重检查锁定与延迟初始化; //线程安全的延迟初始化,由于对getInstance方法做了同步处理,sychronized将导致性能开销 public class SafeLazyInitialization { private static SafeLazyInitialization instance; public synchronized static SafeLazyInitialization getInstance(){ if(instance == null) instance = new SafeLazyInitialization(); return instance; } }
由于对getInstance方法做了同步处理,synchronized将导致性能开销。getInstance方法如果被多个线程频繁调用,将导致程序执行性能的下降。
双重检查锁定
public class DoubleCheckedLocking { private static DoubleCheckedLocking instance; public static DoubleCheckedLocking getInstance(){ if(instance == null){//1处 synchronized(DoubleCheckedLocking.class){ if(instance == null) instance = new DoubleCheckedLocking();//2处 } } return instance; } }
在A线程位于同步代码块2处,初始化对象(但还未初始化完成的时候),有可能有另外的线程B运行到1处。此时instance检查不为null,但instance返回的却不是一个完整的对象。
为什么instance没有被A真正初始化的时候,其指向不为null呢?原因在于编译器的指令重排序。
创建一个对象的过程可由如下三步伪代码表示:
1.memory = allocate() //分配对象的内存空间
2.ctorInstance(memory) //初始化对象
3.instance = memory //设置instance指向分配的内存地址
上面的创建对象的过程是在一个单线程中完成的, JMM保证了重排序不会改变单线程内的程序执行结果。换句话说,JAVA允许那些在单线程内,不会改变单线程程序执行结果对的指令重排序。上述三个步骤在一些JIT编译器上被单线程执行的时候,为了提高程序的执行性能,有可能会被重排序且不影响单线程下的执行结果:
1.memory = allocate() //分配对象的内存空间
2.instance = memory //设置instance指向分配的内存地址
3.ctorInstance(memory) //初始化对象
这样,instance 在单线程内会先被指向内存分配地址(此时检查instance不为null),而后这个地址内才真正存放初始化完成的对象数据。这样线程A执行到步骤2,将instance指向内存地址以至于对instance作检查不为null的时候(但实际上该地址内没有初始化完成的对象数据),线程B运行到了程序的1处,对instance作了不为null的检查后,直接返回了这个“虚有其表”的instance。后续程序如果用到了这个对象,必然导致程序的错误。
解决方法
在知晓了问题的根源后,我们可以采用两个办法来实现线程安全的延迟初始化:
1. 不允许2和3重排序
2. 允许2和3重排序,但不允许其他线程“看到”这个重排序
基于volatile的解决方案
基于前面的双重检查锁定来实现延迟初始化的方案,只需要做一点小的修改(将instance声明为volatile类型),就可以实现线程安全的延迟初始化
package 双重检查锁定与延迟初始化; public class SafeDoubleCheckedLocking { private volatile static SafeDoubleCheckedLocking instance; public static SafeDoubleCheckedLocking getInstance(){ if(instance==null){ synchronized (SafeDoubleCheckedLocking.class){ if(instance == null) instance = new SafeDoubleCheckedLocking();//instance为volatile,禁止了volatile变量的指令重排序,现在没有问题了。 } } return instance; } }
当声明对象引用为volatile后,前面伪代码的2和3之间的重排序在多线程环境中将被禁止,从而解决了这个问题。
基于类初始化的解决方案
JVM在类的初始化阶段(即在class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会取获取一个锁。这个锁可以同步多个线程对同一个类的初始化。。这个方案的实质是,允许伪代码中2和3的重排序,但不允许非构造线程(指线程B)“看到”这个重排序。
package 双重检查锁定与延迟初始化; /*JVM在类的初始化阶段(即在class被加载后,且被线程使用之前),会执行类的初始化。 * 在执行类的初始化期间,JVM会取获取一个锁。这个锁可以同步多个线程对同一个类的初始化。*/ public class InstanceFactory { private static class InstanceHolder{ public static InstanceFactory instance = new InstanceFactory(); } public static InstanceFactory getInstance(){ return InstanceHolder.instance; } }
JAVA语言规范规定,对于每一个类或者接口C,都有唯一一个的初始化锁LC与之对应。JVM在初始化期间会获取这个初始化锁,并且每个线程至少获取了一次锁来确保这个类已经被初始化过了。
为了更好的说明类初始化过程中的同步处理机制,本书作者人为的把类初始化的处理过程分成了5个阶段:
第1阶段:通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
假设Class对象当前还没有被初始化(初始化状态state被标记为noInitialization),且有两个线程A和B试图同时初始化这个Class对象。
第2阶段:线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。
第3阶段:线程A设置state=initialized,然后唤醒在condition中等待的所有线程。
第4阶段:线程B结束类的初始化处理。
线程A在第2阶段的A1执行类的初始化,并在第3阶段的A4释放初始化锁;线程B在第4阶段的B1获取同一个初始化锁,并在第4阶段的B4之后才开始访问这个类。根据Java内存模型规范的锁规则,这里将存在如下的happens-before关系。这个happens-before关系将保证:线程A执行类的初始化时的写入操作(执行类的静态初始化和初始化类中声明的静态字段),线程B一定能看到
第5阶段:线程C执行类的初始化的处理。
在第3阶段之后,类已经完成了初始化。因此线程C在第5阶段的类初始化处理过程相对简单一些(前面的线程A和B的类初始化处理过程都经历了两次锁获取-锁释放,而线程C的类初始化处理只需要经历一次锁获取-锁释放)。线程A在第2阶段的A1执行类的初始化,并在第3阶段的A4释放锁;线程C在第5阶段的C1获取同一个锁,并在在第5阶段的C4之后才开始访问这个类。根据Java内存模型规范的锁规则,将存在如下的happens-before关系。这个happens-before关系将保证:线程A执行类的初始化时的写入操作,线程C一定能看到 。
这个讲起来有点复杂,见《JAVA并发编程的艺术》p72-p78