单例模式

一、前言

单例模式是应用最广的设计模式之一,在使用这个模式时,单例对象的类必须保证只有一个实例存在,许多时候整个系统只需要拥有一个全局对象,这样有利于我们协调系统整体的行为。

二、定义

确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。

三、使用场景

确保某个类有且只有一个对象的场景,避免产生多个对象消耗过多的资源,或者某种类型的对象有且只有一个。

四、UML类图

屏幕快照 2019-04-23 上午10.43.29.png

五、使用

单例模式有很多写法,下面就一一来看。

1、懒汉式

懒汉式,一开始不会创建该类的实例,而是等到真正用的时候才会去创建实例,即延迟加载。
屏幕快照 2019-04-19 上午11.26.13.png

但是这种写法是线程不安全的。假设现在有两个线程:线程A和线程B,当线程A执行到上面代码中19行的时候,但是还没有给mInstance赋值,线程B执行到了18行,因为mInstance还没有复制,所以这个判断条件是true,线程B也可以走到19行,因此mInstance这个对象就被创建了两次,同时会返回最后执行的mInstance。下面我们来验证一下这个说法。

除了本身的主线程之外,我们再创建两个线程,先写一个线程类T,
屏幕快照 2019-04-19 上午11.56.34.png

然后在MainActivity中创建两个线程,
屏幕快照 2019-04-19 下午1.53.39.png
直接运行可以看到,
屏幕快照 2019-04-19 下午12.03.27.png

这两个线程拿到的对象是一个,这是直接run的情况下,接下来我们看一下debug干扰线程的情况。

这里先介绍一下线程debug的使用,首先打上断点,然后在断点上右键,弹出一个弹出,选中Thread。
屏幕快照 2019-04-19 下午1.48.15.png
设置好之后,我们在T类的第8行代码打上断点,LazySingleton类的18行处打上断点,MainActivity类的23行处打上断点,然后开始debug app,
屏幕快照 2019-04-19 下午1.55.27.png

这个时候断点式主线程的,然后看左下角的Frames,
屏幕快照 2019-04-19 下午1.57.37.png

这个时候可以看到有main、Thread-2、Thread-3这三个线程,并且都是RUNNING状态,然后切到Thread-2线程上,然后看到Thread-2开始调用getInstance,
屏幕快照 2019-04-19 下午2.00.55.png

然后我们单步来到了18行,
屏幕快照 2019-04-19 下午2.01.27.png
这个时候通过debug方式调整多线程运行节奏,来触发现在这种写法在多线程的问题,接着单步,Thread-2来到19行,mInstance为null,赋值过程还没完成。
屏幕快照 2019-04-19 下午2.07.01.png

然后我们把线程切到Thread-3上,单步执行,来到18行
屏幕快照 2019-04-19 下午2.09.56.png
这个时候注意看Thread-3中mInstance为null,因为Thread-2没有赋值还没有完成,Thread-3继续单步,来到19行,这个时候切换回Thread-2,单步执行,
屏幕快照 2019-04-19 下午2.16.28.png
这个时候mInstance已经赋值上了,是5219,再切到Thread-3,
屏幕快照 2019-04-19 下午2.17.57.png
发现mInstance已经有值了,并且是5219,接着单步
屏幕快照 2019-04-19 下午2.19.08.png
这个时候mInstance变成了5220,也就是说这种懒汉式的单例写法在多线程下生成了不止一个实例,这仅仅是两个线程,如果是多个线程情况下,有可能会生成更多的实例,所以可能在初始化单例的时候会创建很多的对象,如果这个单例类的对象特别消耗资源,那很有可能造成系统故障,这是很危险的。这个时候我们再切回到Thread-2上,发现mInstance已经被Thread-3的值重新赋值了
屏幕快照 2019-04-19 下午2.24.36.png
这种情况下最后打印出来的lazySingleton还是一个对象。
我们换一种debug干扰方式,打印出来的lazySingleton就不是同一个对象了。还是上面的方式,只是在Thread-2走到19行的时候,切换到Thread-3,然后直接单步执行完输出结果,然后再去单步执行完Thread-3输出结果,可以发现最后输出的两个对象是不一样的。这个过程就不展示执行过程了,大家可以自己动手看看。所以我们不能被表面所迷惑。
接下来看看懒汉式的改进方案,
屏幕快照 2019-04-19 下午2.46.06.png
总结:通过同步锁我们解决了懒汉式单例的在多线程的一些问题,但是我们都知道同步锁会消耗资源,这里会有加锁和解锁的一些开销,会影响性能。

2、DoubleCheck双重检查

双重检查模式的写法:
屏幕快照 2019-04-19 下午4.47.09.png

这种写法也是存在隐患的,其中隐患出现在18行和24行,18行,虽然判断了它是否为null,但是有可能它不为null,对象却没有初始化,也就是24行代码还没有执行完成。看一下24行,这一行代码经历了3个步骤,1、分配内存给这个对象,2、初始化对象,3、设置mInstance指向刚分配的内存地址。其中步骤2和3可能会重排序。

接下来为了更好理解,看一张图,
屏幕快照 2019-04-19 下午5.05.26.png
首先从上到下是时间,其中但2和3是没有顺序的,并且这个重排序不是百分百命中的,是有一定规律的,这个重排序在单线程是没有问题的。
看一下多线程情况,
屏幕快照 2019-04-19 下午5.08.12.png

首先从上到下还是时间,左侧是线程0,右侧是线程1,其中线程1访问的对象并没有初始化完成,所以这个时候就有问题了,系统就会报异常了。那现在知道了问题所在,我们怎么解决呢?我们可以不允许线程0中2和3重排序,或者允许线程0中2和3重排序,但是不允许线程1看到这个重排序。

下面我们先不允许线程0中2和3重排序,我们使用volatile关键字来声明这个mInstance,只进行这个小小的修改就可以禁止2和3的重排序。在多线程的时候,CPU也有共享内存,在加了volatile关键字之后,所有线程都能看见共享内存的最新状态,保证了内存的可见性,这里面就和多线程有关了,用volatile修饰的共享变量在写操作的时候会多出一些汇编代码,起到两个作用,第一,将当前处理器缓存行的代码写回到系统内存,这个操作会使在其他CPU内缓存了的数据无效,无效之后又会从共享内存同步数据,这样就保证了内存的可见性。
屏幕快照 2019-04-19 下午5.17.55.png

3、静态内部类

静态内部类模式代码:


屏幕快照 2019-04-19 下午6.56.16.png

我们来分析一下原理:先看一张图
屏幕快照 2019-04-19 下午6.01.06.png
JVM在类的初始化阶段也就是class被加载后,并且被线程使用前都是类的初始化阶段,在这个阶段会执行类的初始化,在执行类的初始化期间,JVM会获取一个锁,这个锁会同步多个线程对一个类的初始化,基于这个特性,我们可以实现基于静态内部类的并且是线程安全的延迟初始化方案。那么看一下这个图还是线程0和线程1,在这种实现模式中,右侧的2和3的重排序对于前面讲的线程1并不会被看到,也就是非构造线程是不允许看到这个重排序的,因为之前讲的是由线程0来构造这个单例对象,初始化一个类,包括执行这个类的静态初始化,还有初始化在这个类中声明的成员变量,根据java语言规范,分为5种情况,首次发生的时候一个类即将立刻被初始化,这里说的类是泛指包括接口interface,假设这个类是A,现在说一下这几种情况都会导致A类被立刻初始化,首先第一种情况,有一个A类型的实例被创建,第二种A类中声明的一个静态方法被调用,第三种是A类中声明的一个静态成员被赋值,第四种情况,A类中声明的一个静态成员被使用,并且这个成员不是一个常量成员,这四种工作中使用的比较多,第五种,如果A类是一个顶级类,并且在这个类中有嵌套的断言语句,A类也会被立刻初始化。
看一下这个图,当线程0和线程1试图获取这个锁的时候,也就是获得Class对象的初始化锁,这个时候肯定只能一个线程获得这个锁,假设线程0获得了这个锁,线程0 执行静态内部类的 一个初始化,对于静态内部类,即使步骤2和3之间存在重排序,线程1也是无法看到这个重排序的,因为这个里面有一个Class对象的初始化锁。

回到代码,静态内部类这种核心方式在于InnerClass这个对象的初始化锁,看哪个线程先拿到,该线程就去初始化。

4、饿汉式

屏幕快照 2019-04-19 下午7.07.28.png

5、序列化破坏单例模式原理解析及解决方案

用HungrySingleton作为实例,首先让HungrySingleton实现Serializable接口,然后修改MainActivity的代码,
屏幕快照 2019-04-22 上午10.38.31.png

看一下打印结果:
屏幕快照 2019-04-22 上午10.39.04.png
发现这两个对象不相等,这就违背了单例模式的初衷,通过序列化和反序列化拿到了不同的对象,我们只希望拿到同一个对象。那么怎么解决这个问题呢?很简单,只需要在HungrySingleton类中添加一个readResolve()方法即可。
屏幕快照 2019-04-22 上午10.55.21.png
再看一下打印结果:
屏幕快照 2019-04-22 上午10.56.16.png

发现两个对象果然是相等的,这个问题就解决了。接下来我们看看为什么加一个这个方法就解决了这个问题,

看一下MainActivity中使用的类ObjectOutputStream和ObjectInputStream,我们重点看一下ois这个对象的readObject()方法,
屏幕快照 2019-04-22 上午11.08.33.png
看一下373这一行代码,它又调用了readObject0()方法,进去看一下,
屏幕快照 2019-04-22 上午11.11.27.png
主要看switch方法,因为是Object类型,所以看1353这一行代码,先看一下readOrdinaryObject()方法,
屏幕快照 2019-04-22 上午11.33.45.png
因为最后1821行返回的是obj,所以我们看一下1787行,这里面做了一个判断,如果isInstantiable()返回的是true,就会生成一个新的对象,否则返回null。接着看一下isInstantiable()方法,
屏幕快照 2019-04-22 上午11.20.38.png
如果一个类可以在运行时被实例化,这个方法就会返回true,其中cons是构造器
屏幕快照 2019-04-22 上午11.21.45.png
这就好理解了,我们回来,现在的HungrySingleton实现了Serializable接口,那么isInstantiable方法就会返回true,那就会newInstance,并把obj返回,到这就比较清晰了,obj这个对象是通过反射创建出来的对象,是一个ObjectStreamClass类型的,那既然是反射创建的新的对象,那肯定和之前的不是一个对象,这也就解释了,为什么一开始反序列化把单例模式破坏了。接着看readOrdinaryObject()这个方法,1810行,判断有没有readResolve这个方法,
屏幕快照 2019-04-22 上午11.47.16.png
这个方法很简单,在一个序列化的类中,如果定义了readResolve方法就返回true,否则返回false。其中readResolveMethod这个对象,他就是一个method。
屏幕快照 2019-04-22 上午11.49.14.png

因为我们定义了readResolve()方法,所以返回true。看一下readOrdinaryObject()方法1812行,
屏幕快照 2019-04-22 上午11.54.57.png
通过675行返回,其中readResolveMethod就是通过反射readResolve得到的。
屏幕快照 2019-04-22 下午1.55.32.png

6、反射攻击解决方案及原理分析

还是以饿汉式写法为例
屏幕快照 2019-04-22 下午2.28.09.png

看一下打印结果,发现两个对象不一样。
屏幕快照 2019-04-22 下午2.13.54.png
怎么防御反射呢?在HungrySingleton构造器中加入一段判空代码,
屏幕快照 2019-04-22 下午2.17.56.png
看一下这时的打印结果,抛出了异常,
屏幕快照 2019-04-22 下午2.19.48.png

这种防御模式除了对饿汉式有用,还对静态内部类模式有效,因为他们都是在类加载的时候就会创建好对象,但是对其他写法没有效果,可以自己去试一下。那其他模式怎么防御呢?

以懒汉模式为例,在构造器中加入flag标志,
屏幕快照 2019-04-22 下午2.36.44.png
在MainActivity中打印结果,
屏幕快照 2019-04-22 下午2.37.17.png
发现崩溃,报了异常,和预期结果一样,说明加的flag是生效了,
屏幕快照 2019-04-22 下午2.37.53.png
但是这个flag也是可以通过反射修改的,所以添加flag是没有多大作用。

7、Enum枚举单例

这种模式既可以防御反射也可以防御序列化,看一下代码,


屏幕快照 2019-04-22 下午3.02.53.png

我们主要关注枚举模式的序列化机制和反射攻击,枚举类天然可序列化机制能够强有力保证不会出现多次实例化的情况,即使是在复杂的序列化情况下反射攻击下,枚举类型的单例模式都没有问题。

六、单例模式在Android中的应用

LayoutInflater、WindowManager、ActivityManager、PowerManager。

七、总结:

单例模式的写法多种多样,每一种写法都有自己本身的优点与缺点,主要结合自己的需求去合理地使用。

你可能感兴趣的:(单例模式)