双重检查锁和延迟初始化
有幸听一位学长讲过一次关于Java中单例模式与静态成员初始化对比的一个知识点。第一个知识点是在完成一个final域的初始化之前,存在着能够读取值的可能性;第二个知识点是在并发中,对于单例模式的一种可以称作为优化设计的知识。恰巧学到了这些内容的知识点,重新读了一下内容,整理了一下。
final初始化的一种读值可能性
在《同步语义-synchronized+final》一文中,我描述过一种在并发过程中读取未正常初始化的final域的现象,也就是在构造函数中this引用逸出,导致指令重排序之后第二个线程在final域写入值前读取到了这个对象。当然,这是一种完全可以避免的操作,只要没有this逸出,那么JMM就会保证这个错误不会发生。
final在另外一种情况下可能存在读取未初始化值的情况,是这样的:
1 | public class Elvis { |
如果初始化是正常的,那么这个程序的答案应该是2018-1930,但是在输出中,第一步的输出的数字是-1930,而INSTANCE.CURRENT_YEAR的值却是2018。
问题出现在,构造器会用一个涉及静态域的CURRENT_YEAR来初始化beltSize,在JAVA中,初始化静态域会造成调用类的构造器来初始化,但是这个过程已经处在初始化过程中了,那么CURRENT_YEAR的值将设置为默认值,然后用来初始化beltSize,在之后才将CURRENT_YEAR进行初始化,当然已经不再起作用了。
所以:
在final类型的静态域被初始化之前,存在着读取其值的可能性
final类型的域只有在其初始化表达式是常量表达式才是常量
对于这个问题,书中给出了建议:
想改正一个类初始化循环,需要重新对静态域的初始器进行排序,使得任何一个初始器都出现在任何依赖于它的初始器之前。
延迟初始化
在涉及双重检查锁和单例模式之前,先对延迟初始化的概念进行一下总结。
在Java中,有的时候需要采用延迟初始化来降低初始化类和创建对象的开销,也就是说,只有在需要某个对象的时候才对这个对象进行初始化。
1 | public class UnsafeLazyInitialization { |
双重检查锁
在延迟初始化部分列举的例子,如果是在单线程环境下运行,那么这个不会出现问题。但是如果环境是在多线程环境下,当有两个线程A、B的时候,当A执行操作1,但B执行操作2的时候,因为指令重排序的关系,对象的内存地址已经被分配,但是并没有进行初始化。所以有可能存在A线程看到instance引用的对象还没有完成初始化的现象。
对于这种现象,采用加锁的方法,来进行同步处理实现线程安全的延迟初始化。
1 | public class SafeLazyInitialization { |
当有一个线程进行初始化过程的时候,另外的线程访问都会被阻塞,从而保证不会存在错误的初始化访问现象。但是因为每一次有线程访问这段同步代码的时候,都会进行加锁解锁操作,将会导致大量的系统开销。所以,在这个基础上,设计了“双重检查否定”的方法,来降低同步的开销。
1 | public class DoubleCheckedLocking { //1 |
在上述实现里,如果第一次检查instance部位null,那么就不需要执行加锁和初始化操作,从而减少系统开销。
但是,同样的,建立了双重检查锁的方法,同样会造成不存在锁的时候的错误,也就是说,仍然有可能读取未正确初始化的对象。对于这种现象,采用volatile来防止指令重排序,从而保证线程安全。
单例模式
单例模式常用的有三种:饿汗式、懒汉式、嵌套类。
在饿汉式模式中,可能会存在上述内容讲到的初始化依赖的现象:
1 | public class Singleton{ |
在懒汉式模式里,采用了双重检查锁否定的方法,同时将私有对象设置为volatile的,来保证线程安全性:
1 | public class Singleton{ |
嵌套类的模式,是三种方法里比较好的一种,通过这种方法,能够提供类似于饿汉式的线程安全性而不需要加锁增大系统开销,同时因为类加载器的初始化顺序,保证了不会存在静态final域的初始化问题。
1 | public final class Singleton { |