双检锁实现单例模式
将上锁粒度降低到了仅仅是初始化实例的那部分,保证线程安全,提高执行效率。
双检锁的机制出现确实解决了多线程并行中不会出现重复的new对象,实现了懒加载,但是,因为jvm存在一个无序写的问题,原因在于:
instance=new DoubleCheckedLock()这行代码在不同编译器上的行为是无法预知的。
编译器可能会有如下实现:
1. 给新的实体instance分配内存;
2. 调用DoubleCheckedLock的构造函数来初始化instance。
3. 将instance引用指向内存地址。
其中2,3的顺序是无法保证的,可能会变成1-3-2
现在想象一下有线程A和B在调用DoubleCheckedLock,线程A先进入,在执行到new DoubleCheckedLock的时候被踢出了cpu。然后线程B进入,B看到的是instance已经不是null了(内存已经分配),于是它开始放心地使用instance,但这个是错误的,因为A还没有来得及完成instance的初始化,而线程B就返回了未被初始化的instance实例。
这个时候需要结合java虚拟机的类加载机制来理解这一过程。
jvm加载类的过程为:
1、当在程序中要使用某个类的时候,JVM 会先在当前的方法区中找有没有这个class文件信息,如果没有这时JVM会先去加载这个class文件
2、加载的时候,如果配置了classpath环境变量,那么JVM会到classpath所指的目录下去找对应的class文件,如果没有配置classpath环境变量,那么就在当前目录下找对应的class文件。
3、当JVM找了对应的class文件之后,这时开始加载这个class文件。
4、加载的过程中,把类中的所有非静态的成员,会加载到内存中的方法区的非静态区中。类中所有的静态成员(静态变量,静态代码块,静态方法)加载到方法区中的静态区中。
以上过程为类的加载,将class文件加载到内存后,将其放到运行期数据的方法区,在堆区创建一个java.lang.Class对象,用来封装在方法区内的数据结构。
5、给静态区中的所有静态成员变量开始默认初始化。
这一步为类的连接,步骤一:验证,当然是验证这个class文件里面的二进制数据是否符合java规范;步骤二:准备,为该类的静态变量分配内存空间,并将变量赋一个默认值,比如int的默认值为0;步骤三:解析,这个阶段就不好解释了,将符号引用转化为直接引用,涉及到指针;
6、当所有的静态成员变量默认初始化完成之后,开始给所有的静态成员变量进行显示初始化(在源代码中使用赋值号赋的数据)。
这一步为类的初始化阶段,为静态成员变量赋予实质的值。
7、所有静态成员变量显示初始化完成之后,开始执行类中的静态代码块。(静态代码块只执行一次)
8、类中所有的静态代码块执行完成之后,当前这个类加载结束。
对象的创建过程:
1、当类加载完成之后,就可以创建这个类的对象了。
2、在创建对象的时候,首先在堆中给这个对象分配内存空间,分配内存地址。
3、把当前类中的所有非静态的成员变量在堆中的对象中开辟空间并且给它们进行默认初始化
4、所有的非静态成员变量默认初始化完成之后,开始调用和当期创建对象对应的构造方法。
5、在调用对应的构造方法的时候,在构造方法中有隐式的三步:
5.1、先执行构造方法中的隐式的super()。找父类的构造方法去
5.2、给当前对象中的所有非静态成员变量进行显示的初始化。
5.3、构造代码块运行。
6、隐式三步执行完成之后,开始执行构造方法中书写的代码。
7、对象创建完成。把对象的内存地址赋值给当前的那个引用变量。
因此,双检锁对于基础类型(比如int)适用。因为基础类型没有调用构造函数这一步。那么对于双检锁中因编译器的优化无法保证执行顺序的问题。在java中可以通过volatile字段来解决,该字段在保证变量在线程间的可见性的同时,也起到了静止指令重排序的作用。
修改后的双检锁实现为:
静态内部类实现单例模式
实现为:
这里借助了静态类只在第一次调用时才加载的特性实现单例模式,具体为外部类在调用getInstance()方法时才加载内部类(延时加载),且只加载一次,而加载过程是线程安全的,从而保证了创建对象时的线程安全问题。