单例模式-静态内部类实现及原理剖析
以我的经验为例(如有不对欢迎指正),在生产过程中,经常会遇到下面两种情况:
1.封装的某个类不包含具有具体业务含义的类成员变量,是对业务动作的封装,如MVC中的各层(HTTPRequest对象以Threadlocal方式传递进来的)。
2.某个类具有全局意义,一旦实例化为对象则对象可被全局使用。如某个类封装了全球的地理位置信息及获取某位置信息的方法(不考虑地球爆炸,板块移动),信息不会变动且可被全局使用。
3.许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。如用来封装全局配置信息的类。
上述三种情况下如果每次使用都创建一个新的对象,且使用频率较高或类对象体积较大时,对象频繁的创建和GC会造成极大的资源浪费,同时不利于对系统整体行为的协调。此时便需要考虑使用单例模式来达到对象复用的目的。
在看单例模式的实现前我们先来看一下使用单例模式需要注意的四大原则:
1.构造私有。(阻止类被通过常规方法实例化)
2.以静态方法或者枚举返回实例。(保证实例的唯一性)
3.确保实例只有一个,尤其是多线程环境。(确保在创建实例时的线程安全)
4.确保反序列化时不会重新构建对象。(在有序列化反序列化的场景下防止单例被莫名破坏,造成未考虑到的后果)
目前单例模式的实现方式有很多种,我们仅讨论接受度最为广泛的DCL方式与静态内部类方式(本篇讨论静态内部类方式)。
静态内部类方式
要理解静态内部类方式,首先要理解类加载机制。
虚拟机把Class文件加载到内存,然后进行校验,解析和初始化,最终形成java类型,这就是虚拟机的类加载机制。加载,验证,准备,解析、初始化这5个阶段的顺序是确定的,类的加载过程,必须按照这种顺序开始。这些阶段通常是相互交叉和混合进行的。解析阶段在某些情况下,可以在初始化阶段之后再开始---为了支持java语言的运行时绑定(动态绑定,多态的原理)。
在Java虚拟机规范中,没有强制约束什么时候要开始加载,但是,却严格规定了几种情况必须进行初始化(加载,验证,准备则需要在初始化之前开始):
1. 遇到 new、getstatic、putstatic、或者invokestatic 这4条字节码指令,如果没有类没有进行过初始化,则触发初始化
2. 使用java.lang.reflect包的方法,进行反射调用的时候,如果没有初始化,则先触发初始化
3. 初始化一个类时候,如果发现父类没有初始化,则先触发父类的初始化
我们仅说与本期主题相关的初始化阶段:
类初始化阶段是类加载过程的最后阶段。在这个阶段,java虚拟机才真正开始执行类定义中的java程序代码。在编译的时候,编译器会自动收集类中的所有静态变量(类变量)和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是根据语句在java代码中的顺序决定的。收集完成之后,会编译成java类的 static{} 方法,java虚拟机则会保证一个类的static{} 方法在多线程或者单线程环境中正确的执行,并且只执行一次。在执行的过程中,便完成了类变量的初始化。如果我们的java类中,没有显式声明static{}块,如果类中有静态变量,编译器会默认给我们生成一个static{}方法。
对于静态变量来说,虚拟机会保证在子类的static{}方法执行之前,父类的static{}方法已经执行完毕(即如果父类没有加载则先加载父类)。由于父类的static{}方法先执行,也就意味着父类的静态变量要优先于子类的静态变量赋值操作。
对于实例变量来说,在实例化对象时,JVM会在堆中为对象分配足够的空间,然后将空间清零(即所有类型赋默认值,引用类型为null)。JVM会收集类中的复制语句放于构造函数中执行,如果没有显式声明构造函数则会默认生成一个构造函数。子类默认生成的构造函数第一行默认为super();即如果父类有无参的构造方法,子类会先调用父类的构造方法再调用本身的构造方法。因为它继承父类成员的使用,必须先初始化这些成员。如果父类没有无参的构造方法则子类继承会报错,需要子类通过super显式调用父类的有参构造方法。如果类中显式定义一个或多个构造方法,则不再生成默认构造方法。
对于静态变量,上面的描述还不太准确。类初始化阶段,JVM保证同一个类的static{}方法只被执行一次,这是静态内部类单例模式的核心。JVM靠类的全限定类名以及加载它的类加载器来唯一确定一个类。(这个很重要,经常会有这方面的坑!比如反序列化时,被序列化的对象使用java默认的类加载器加载,而使用了反序列化的一方使用的框架(如springBoot就有自己的类加载器)强制使用自己的类加载器加载某个类,则会因为JVM判定不是一个类而报ClassNotFoundException!)
所以修正一下的说法便是,静态内部类单例模式的核心原理为对于一个类,JVM在仅用一个类加载器加载它时,静态变量的赋值在全局只会执行一次!
使用静态内部类的优点是:因为外部类对内部类的引用属于被动引用,不属于前面提到的三种必须进行初始化的情况,所以加载类本身并不需要同时加载内部类。在需要实例化该类是才触发内部类的加载以及本类的实例化,做到了延时加载(懒加载),节约内存。同时因为JVM会保证一个类的<cinit>()方法(初始化方法)执行时的线程安全,从而保证了实例在全局的唯一性。
下面我们来实现一下静态内部类的单例模式:
/** * @Author Nyr * @Date 2019/11/19 20:48 * @Description 单例模式-静态内部类方式 */ public class Car2 { private Car2(){} private static class innerCar2{ private static Car2 car2=new Car2(); } public Car2 getCar2(){ return innerCar2.car2; } }
为什么使用内部类而不是直接使用静态变量,我觉着有两个原因(求指正,第二条并不是很确定,后续会写代码测试):
1. 使用内部类可以延时加载。如果直接使用静态变量,因为加载子类等其它原因对实例进行了初始化,而此时并不需要该类的实例,造成了资源的浪费。
2. 原类因为带有业务含义,在使用上会有各种可能,比如使用了特定的类加载器进行加载,这样就对单例造成了破坏。
说完了优点我们再来说说缺点,那就是内部类的传参不是很灵活,需要将参数定义为final。当然我们也可以将其写入final的Object数组或者在内部类定义一个接受参数的init()方法来接收参数,但总的来说传参确实不方便。
而对上一篇所说的DCL来说,同步块的使用明显的降低了效率。两种方法可以说各有优缺,我们应视实际情况酌情选择。