设计模式是一套被反复使用、多数人知晓的、经过分类的、代码设计经验的总结。在许多类以及优秀开源框架中都有被大量使用到,假如你需要阅读一些开源框架的源码,那么设计模式是你应该要去学习的!
1.定义
单例模式保证一个类在内存中只有一个对象,属于创建型模式,它提供了一种创建对象的最佳方式。
这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。
在Java中,对象的创建是比较消耗资源的,当某个对象需要被频繁,而这个对象又属于无状态对象或持有全局状态的话,就可以考虑使用单例模式来实现对象的创建,这样可以节省了频繁创建对象的开销 在这里稍微提一下这两种场景
- 无状态:无状态是指这个对象不会发生变化。例如DAO层、SERVICE层、工具bean
- 全局状态:当某个对象需要保存持有全局状态时,也可以做成单例,例如ApplicationContext,SessionFactory等
2.实现以及代码编写
单例模式的实现方式有很多中,总的来说可以分为
- 饿汉型:在启动时就将实例初始化好,优点是实现简单,缺点是占用内存
- 懒汉型:在使用时进行初始化
大体思路:
1.私有化构造器防止其他地方创建实例
2.定义类静态变量存放实例对象
3.开放统一入口获取实例对象
~ 下面介绍的都是线程安全的单例模式
2.1 饿汉型
饿汉型的思路就是在启动时就进行初始化,同时将构造器私有化,以此保证全局只有一个实例。
/** * 饿汉型单例模式 * @author qiudao */public class Eager { //私有化构造器 private Eager() { } // 启动时直接初始化 private static Eager instance = new Eager(); // 获取实例接口 public static Eager getInstance() { return instance; }}复制代码
它主要利用了JVM在进行类加载时,会对该过程进行加锁,以此来保证线程安全性。
它的优点是实现非常简单,缺点是在jvm启动时就完成了初始化,如果后续没有使用到该对象的话,就会造成内存浪费
2.2 懒汉型
相对比饿汉型,懒汉型在启动时不会进行实例化对象,只有在需要时才进行初始化,这样可以很好的避免内存的浪费,但同时也增加了复杂度。
懒汉型单例模式的实现方式有很多,主要有普通懒汉、双检锁、静态内部类、枚举
2.21 普通懒汉
普通饿汉实现的方式非常简单,通过在入口方法标准synchronized来对方法进行加锁,以此防止多线程安全问题
/** * 普通懒汉型 * @author qiudao */public class NormalLazy { //私有化构造器 private NormalLazy() { } //定义实例 private static NormalLazy instance; // 直接加锁,保证线程安全 public static synchronized NormalLazy getInstance() { if (instance == null) { instance = new NormalLazy(); } return instance; }}复制代码
这样就能把实例初始化的操作延迟到使用时,避免了内存的浪费。但是由于synchronized 关键字的存在,这样会导致性能问题,所以这种实现方法是非常不推荐使用的
2.22 双检锁
针对上面普通懒汉型的实现,双检锁通过使用双重检查+缩小synchronized的作用域,以此改进性能问题
/** * 双检锁实现懒汉单例 * @author qiudao */public class DoubleCheck { //私有化构造器 private DoubleCheck() { } private static DoubleCheck instance; public static DoubleCheck getInstance() { //第一重检查,检查是否已经初始化 if (instance == null) { // 加锁,保证线程安全问题 synchronized (DoubleCheck.class) { // 二重检查,防止该实例被二次初始化 if (instance == null) { instance = new DoubleCheck(); } } } return instance; }}复制代码
上述双检锁代码看着没问题,其实存在一个安全隐患。这个隐患就是
- 指令重排序
instance = new DoubleCheck();这句代码粗略的可以分为三个操作
- 1.给 instance 分配内存空间
- 2.初始化DoubleCheck实例
- 3.将分配的内存空间指向instance变量
而JVM在运行时会对一些指令进行重新排序,比如说在上述步骤中,instance = new DoubleCheck()可能并不是严格地按1 2 3 的顺序执行,2 与 3 可能会颠倒顺序。这就是JVM的指令重排序
假设线程一 以1-3-2的顺序进行初始化实例。当这个线程刚好执行完第二步时,此时,instance变量已经不为null了。这时,线程二进来了,这样它在第一个if的时候就直接返回了。而这时步骤2还未执行,所以线程二拿到的是一个还未进行初始化的变量,这样就会导致报错
如何解决这个隐患呢?
很简单,只要为instance变量添加volatile关键字
private static volatile DoubleCheck instance;复制代码
volatile关键字能够禁止指令重排序优化,在 volatile 变量的赋值操作后面会有一个内存屏1障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。
这样就可以很好的避免这个隐患了
2.23 静态内部类
静态内部类方式是利用类加载器在加载类时的锁机制来避免多线程安全问题
/** * 静态内部类实现单例 * * @author qiudao */public class InnerClass { // 私有化构造器 private InnerClass() { } // 定义静态内类 private static class Holder { // 利用类加载的锁机制来避免线程安全问题 private static InnerClass INSTANCE = new InnerClass(); } public static InnerClass getInstance() { return Holder.INSTANCE; }}复制代码
由于这个内部类是私有的,在JVM初始化的时候,这个内部类并不会进行初始化,当调用getInstance方法的时候,类加载器就会加载这个内部类,并进行初始化这个实例的操作,由于JVM在类加载时有锁机制,所以这种方式也是线程安全的,同时也达到了延迟加载的效果。
2.24 CAS实现单例
上面介绍的单例实现都是基于synchronized锁或者jvm加载类时使用的锁来实现多线程安全问题的,这里提供一种基于CAS操作来实现单例的方法
/** * CAS实现单例模式 * * @author qiudao */class CasSingleton { //利用AtomicReference private static final AtomicReferenceINSTANCE = new AtomicReference<>(); //私有化构造器 private CasSingleton() { } /** * 用CAS确保线程安全 */ public static CasSingleton getInstance() { // 自旋获取 while (true) { CasSingleton current = INSTANCE.get(); // 已初始化则直接返回 if (current != null) { return current; } current = new CasSingleton(); // 使用cas操作设值 if (INSTANCE.compareAndSet(null, current)) { return current; } } }}复制代码
其原理是内部通过一段自旋操作(while循环)来获取对象,在多线程场景下, INSTANCE.compareAndSet(null, current) 将INSTANCE中的value值设置为current时,会拿当前的内存值与之前的内存值null进行比较,当二者相等时才会进行赋值操作。假如此时线程二已经更改了INSTANCE中的value值,那么线程一的这次操作将会返回false,线程一将会继续下一次循环,然后获取到线程二设置的值后返回。
这种单例实现就是依据这种方式来实现线程安全的
2.25 枚举
枚举是java1.5之后引进的特性,他的实例对象都是天然的单例对象,它的实现也非常简单
/** * 枚举实现单例 * * @author qiudao */public enum EnumSingleton { //实例对象 INSTANCE; // 实例方法 public void test() { System.out.println("方法"); } // 实例属性 private int a = 0;}复制代码
在该示例中,INSTANCE可以看成是一个实例对象,而里面的方法跟属性可以看成是实例属性,除了定义实例之外,它的使用与正常类是没有区别的。
在JVM的保证下,他是线程安全的,同时还能避免序列化攻击和反射攻击破坏单例。在Think in Java中也强烈推荐开发者使用枚举实现单例
3.单例破坏
什么是单例破坏呢?正常情况下,上面列举的单例(除了枚举)实现在整个jvm生命周期内只会有一个实例对象存在,但是通过java的一些特性,就能使超过两个的实例对象存在与JVM中。 这两种模式分别是反射攻击和序列化破坏
3.1 反射破坏单例
JAVA反射机制是在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为java语言的反射机制。
通过反射,我们可以将指定类的私有构造器打开,并通过构造器创建对象
流程:获取类构造器 --> 打开构造器权限 --> 创建实例对象
3.11反射破坏单例代码
/** * 通过反射破坏单例对象 * @author qiudao */public class ReflectDestroySinleton { public static void main(String[] args) throws IllegalAccessException, InvocationTargetException, InstantiationException { //通过反射获取单例类私有构造器 Constructor constructor = Eager.class.getDeclaredConstructors()[0]; // 开放构造器权限 constructor.setAccessible(true); // 通过构造器新建一个对象 Eager instance1 = (Eager) constructor.newInstance(); Eager instance2 = Eager.getInstance(); Eager instance3 = Eager.getInstance(); // 反射创建的对象与getInstance方法获取的对象比较 System.out.println("instance1 == instance2:"+ (instance1 == instance2)); // 比较getInstance方法返回的对象是否是同一个 System.out.println("instance2 == instance3:"+ (instance3 == instance2)); }}复制代码
结果
3.12 如何避免?
1.添加校验(仅限饿汉)
对于饿汉型单例,我们可以在类的私有构造器中加入判断
private Eager() { if (instance != null) { throw new IllegalArgumentException("无法通过反射创建单例"); } }复制代码
因为懒汉型的单例模式是延迟加载的,在通过反射调用其构造器之前,假如instance实例没有被初始化的话,那么还是能够通过反射成功创建实例。同理,想用一个状态变量来标识实例是否已经被初始化过也是行不通的,因为反射能够自由的改变类的字段值。
2.选用枚举实现
同样的,我们首先用反射来攻击一下使用枚举实现的单例,由于反射创建实例的代码是一样的,在这里就不重复贴了 直接放结果
这里直接抛了异常,这就意味着,反射无法破坏枚举类型的单例
让我们跟进Constructor类中看一下它是如何实现的
可以看到,在执行newInstance方法的时候,有对类型做判断,如果是枚举类型,则会抛出异常,以此避免反射攻击
3.13 反射攻击总结
只有饿汉型单例模式才能防范反射攻击,而懒汉型单例模式中只有枚举类才能防御反射攻击
3.2 序列化攻击
对象序列化是指把对象转换成二进制字节流后存储到文件当中,让我们序列化看一下(先使单例类实现序列化接口)
/** * 序列化攻击 * @author qiudao */public class SerializableAttach { public static void main(String[] args) throws Exception { Eager eager = Eager.getInstance(); // 把对象序列化写进文件中 File file = new File("D:/object"); OutputStream outputStream = new FileOutputStream(file); ObjectOutputStream oopt = new ObjectOutputStream(outputStream); oopt.writeObject(eager); //从文件中把对象反序列化读出来 InputStream inputStream = new FileInputStream(file); ObjectInputStream objectInputStream = new ObjectInputStream(inputStream); Eager eager1 = (Eager) objectInputStream.readObject(); // 比较 System.out.println(eager == eager1);//false }}复制代码
在这里可以看到,序列化再反序列化之后,对象已经变了,那么我们跟一下源码看看,为什么会变
3.21 代码跟踪
我们首先看一下
objectInputStream.readObject()复制代码
的这个方法
如果这个类属于serializable/externalizable,那么它将返回true
到这里就已经很清晰了,objectInputStream读取序列化后的对象时,会使用反射创建一个新的对象出来,所以单例模式自然就被破坏了 那么有解决方法吗?
3.22 解决方法
3.221 添加readResolve方法
让我们继续跟踪代码,回到 readOrdinaryObject(unshared) 方法中
在这里我们主要关注第三个条件,从名字可以看出,这个方法做的事情是检查这个类是否含有readResolve() 这个方法,如果有的话,就执行这个方法,并将这个方法返回的结果赋给obj,最终返回
所以我们需要做的就是
- 在单例类中添加一个ReadResolve()方法,同时返回这个类的单例对象
public Object readResolve(){ return instance; }复制代码
再次执行序列化攻击代码,可以看到已经成功抵御住序列化攻击了
3.222 使用枚举
老规矩,依旧是从源码入手,回到前面的readObject0 方法中,如果是枚举类,很显然,它会走另外一个case
这里的Enum.valueOf返回的是枚举实例,并没有生成新的对象,所以使用枚举也是能够防御序列化攻击的
3.3 序列化总结
序列化攻击是可以抵御的,只要在单例类中定义成员方法 readResolve() 并把单例对象返回回去就行了。
枚举类型的单例则无需定义该方法,因为在源码中已经有针对枚举单例的防御操作了。
序列化与反序列化的源码比较复杂,上面只是把一些关键的点列举出来了,有兴趣的同学可以自行打断点进行追踪!
4. 总结
1.推荐使用枚举方式实现单例
2.单例模式虽然定义很简单,但是却是设计模式中比较复杂的一种,如果上面哪个地方写错了,望大家不吝赐教!
上面的代码我都上传到github上面了