有些对象我们只需要一个,比如线程池、ServletContext、ApplicationContext、 Windows中的回收站,此时我们便可以用到单例模式。
单例模式就是确保一个类在任何情况下都只有一个实例,并提供一个全局访问点。
饿汉式单例
优点:
缺点:
某些情况下,造成内存浪费,因为对象未被使用的情况下就会被初始化,如果一个项目中的类多达上千个,在项目启动的时候便开始初始化可能并不是我们想要的。
想解决饿汉式单例一开始就会进行对象的初始化的问题,一个很自然的想法就是当用户调用getInstance
方法的时候再进行实例的创建,修改代码如下:
简单的懒汉式单例
上述代码在单线程下能够完美运行,但是在多线程下存在安全隐患。大家可以使用IDEA进行手动控制线程执行顺序来跟踪内存变化,下面我用图解的形式进行多线程下3种情形的说明。
情形1:
情形1
每个线程依次执行getInstance方法,得到的结果正是我们所期望的
情形2:
情形2
此种情形下,该种写法的单例模式会出现多线程安全问题,得到两个完全不同的对象
情形3:
情形3
该种情形下,虽然表面上最终得到的对象是同一个,但是在底层上其实是生成了2个对象,只不过是后者覆盖了前者,不符合单例模式绝对只有一个实例的要求。
带有同步锁的懒汉式单例
升级之后的程序能完美地解决线程安全问题。
但是用synchronized
加锁时,在线程数量较多的情况下,会导致大批线程阻塞,从而导致程序性能大幅下降
有没有一种形式,既能兼顾线程安全又能提升程序性能呢?有,这就是双重检查锁。
双重检查锁
第一重检查是为了确认instance是否已经被实例化,如果是,则无需再进入同步代码块,直接返回实例化对象,否则进入同步代码块进行创建,避免每次都排队进入同步代码块影响效率;
第二重检查是真正与实例的创建相关,如果instance
未被实例化,则在此过程中被实例化。
双重检查锁版本的单例模式需要使用到
volatile
关键字,本文不对volatile
关键字进行深入分析,之后会单独开一篇文章进行解释
但是,使用synchronized
关键总归是要上锁的,对程序性能还是存在影响,下面介绍一种利用Java本身语法特性来实现的一种单例写法。
静态内部类实现单例
用静态内部类实现的单例本质上是一种懒汉式,因为在执行getInstance
中的LazyHolder.LAZY
语句之前,静态内部类并不会被加载。
这种方式既避免了饿汉式单例的内存浪费问题,又摆脱了synchronized
关键字的性能问题,同时也不存在线程安全问题。
到此为止,我们介绍了5种单例写法(除去简单的懒汉式单例由于多线程问题无法用于生产中,其实只有4种),我们发现上述单例模式本质上都是将构造方法私有化,避免外部程序直接进行实例化来达到单例的目的。
那如果我们能够想办法获取到类的构造方法,或者将创建好的对象写入磁盘,然后多次加载到内存,是不是可以破坏上述所有的单例呢?
答案是肯定的,下面我们用反射
和序列化
两种方法亲自毁灭我们一手搭建的单例。
反射破坏单例
如此,我们便使用反射破坏了单例。现在我们以静态内部类单例为例,解决这个问题。
我们在构造方法中添加一些限制,一旦检测到对象已经被实例化,但是构造方法仍然被调用时直接抛出异常。
防止反射破坏单例
单例对象创建好之后,有时需要将对象序列化然后写入磁盘,在需要时从磁盘中读取对象并加载至内存,反序列化后的对象会重新分配内存,如果序列化的目标对象恰好是单例对象,就会破坏单例模式。
序列化破坏单例
从运行结果上看,反序列化和手动创建出来的对象是不一致的,违反了单例模式的初衷。
那到底如何保证在序列化的情况下也能够实现单例模式呢,其实很简单,只需要增加一个readResolve方法即可。
解决序列化破坏单例的问题
实现的原理涉及到ObjectInputStream
的源码,不属于本文的研究重点,如果读者需要,我可以另开一篇来进行讲解。
很多博客和文章的实现方式如下(文件名:EnumSingleObject.java)
枚举式单例版本1
枚举式的写法为什么可以实现我们的单例模式呢,我们首先使用javac EnumSingleObject.java
生成EnumSingleObject.class
文件,用反编译工具Jad
在.class所在的目录下执行 jad EnumSingleObject.class
命令,得到EnumSingleObject.jad
文件,代码如下
枚举的反编译结果
其实,枚举式单例在静态代码块中就为INSTANCE
进行了赋值,是一种饿汉式单例模式的体现,只不过这种饿汉式是JDK底层为我们做的操作,我们只是利用了JDK语法的特性罢了。
序列化能否破坏枚举式单例
序列化破坏版本1的枚举式单例
很遗憾,序列化依然会破坏枚举式单例EnumSingleObject
What???不是说枚举式单例非常的优雅吗?连Effective Java都推荐使用吗?
别急,接下来我们观察另一种写法
枚举式单例版本2
我们再来进行序列化测试
序列化测试版本2的枚举式单例
打印结果为true
,说明枚举式单例2的写法可以防止序列化破坏。
而很多文章和博客用的往往是第1种写法,下面我们解释这两种写法的区别.
我们进入ObjectInputStream
类的readObject()
方法
readObject源码
在readObject()
方法中又调用了readObject0()
方法
readObject0源码
我们先看一下readEnum()
方法
readEnum源码
到这里我们发现,枚举类型其实通过类名和类对象找到唯一一个枚举对象,因此,枚举对象不会被类加载器加载多次。
而readClass()
并无此功能。
反射能否破坏枚举式单例
反射无法破坏枚举式单例
运行结果如下
image-20211024210253823
结果报了java.lang.NoSuchMethodException
异常,原因是java.lang.Enum
中没有无参的构造方法,我们查看java.lang.Enum
的源码,只有下面一个构造函数
image-20211025094928402
我们改变一下反射构建的方式
反射无法破坏枚举式单例
运行结果如下
image-20211024212215573
Cannot reflectively create enum objects
,即不能用反射来创建枚举对象,这是Constructor
的newInstance()
方法在源码上决定的,继续看
反射源码
从源码中可以看出,newInstance()
方法中做了强制性的判断,如果修饰符是Modifier.ENUM
类型,则直接抛出异常。
最后介绍一种注册时单例的另一种写法:容器式单例
容器式单例
容器式单例适合用于实例非常多的情况,Spring中就使用了该种单例模式。
单例模式可以保证内存中任何情况下只有一个实例,是最简单的一种设计模式,实现起来也很简单,但是实现方式比较多,涉及到的小细节也比较多,在面试中是一个高频面试点。
我是蝉沐风,那些看似微不足道的坚持,会突然在某一天让你看到它的意义,欢迎大家留言!