前言
主要内容有:
- 该模式的介绍,包括:
- 引子、意图(大白话解释)
- 类图、时序图(理论规范)
- 该模式的代码示例:熟悉该模式的代码长什么样子
- 该模式的优缺点:模式不是万金油,不可以滥用模式
- 该模式的实际使用案例:了解它在哪些重要的源码中被使用
创建型——单例模式
引子
《HEAD FIRST设计模式》中“单例模式”又称为“单件模式”
对于系统中的某些类来说,只有一个实例很重要。比如大家熟悉的Spring框架中,Controller和Service都默认是单例模式。
如果用生活中的例子举例,一个系统中可以存在多个打印任务,但是只能有一个正在工作的任务;一个系统只能有一个窗口管理器或文件系统;一个系统只能有一个计时工具或ID(序号)生成器。
如何保证一个类只有一个实例并且这个实例易于被访问呢?
答:定义一个全局变量可以确保对象随时都可以被访问,但不能防止我们实例化多个对象。一个更好的解决办法是让类自身负责保存它的唯一实例。这个类可以保证没有其他实例被创建,并且它可以提供一个访问该实例的方法。这就是单例模式的模式动机。
意图
确保一个类只有一个实例,并提供该实例的全局访问点。
单例模式的要点有三个:
- 一是某个类只能有一个实例;
- 二是它必须自行创建这个实例;
- 三是它必须自行向整个系统提供这个实例。
使用一个私有构造函数、一个私有静态变量以及一个公有静态函数来实现。
私有构造函数保证了不能通过构造函数来创建对象实例,只能通过公有静态函数返回唯一的私有静态变量。
类图
如果看不懂UML类图,可以先粗略浏览下该图,想深入了解的话,可以继续谷歌,深入学习:
单例模式的类图:
时序图
时序图(Sequence Diagram)是显示对象之间交互的图,这些对象是按时间顺序排列的。时序图中显示的是参与交互的对象及其对象之间消息交互的顺序。
我们可以大致浏览下时序图,如果感兴趣的小伙伴可以去深究一下:
实现
单例模式有非常多的实现方式,这里我们从最差的实现方式逐渐过渡到优雅的实现方式(剑指offer的方式),包括:
- 懒汉式-线程不安全
- 饿汉式-线程安全
- 懒汉式-线程安全
- 懒汉式(延迟实例化)—— 线程安全/双重校验 (重要,牢记)
- 静态内部类实现
- 枚举实现 (重要,牢记)
1. 懒汉式-线程不安全
以下实现中,私有静态变量 uniqueInstance 被延迟实例化,这样做的好处是,如果没有用到该类,那么就不会实例化 uniqueInstance,从而节约资源。
这个实现在多线程环境下是不安全的,如果多个线程能够同时进入 if (uniqueInstance == null) ,并且此时 uniqueInstance 为 null,那么会有多个线程执行 uniqueInstance = new Singleton(); 语句,这将导致实例化多次 uniqueInstance。
public class Singleton { private static Singleton uniqueInstance; private Singleton() { } public static Singleton getUniqueInstance() { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } return uniqueInstance; } } 复制代码
2. 饿汉式-线程安全
如此一来,只会实例化一次,作为静态变量
private static Singleton uniqueInstance = new Singleton(); 复制代码
3. 懒汉式(延迟实例化)—— 线程安全
只需要对 getUniqueInstance() 方法加锁,那么在一个时间点只能有一个线程能够进入该方法,从而避免了实例化多次 uniqueInstance。
但是当一个线程进入该方法之后,其它试图进入该方法的线程都必须等待,即使 uniqueInstance 已经被实例化了。这会让线程阻塞时间过长,因此该方法有性能问题,不推荐使用。
public static synchronized Singleton getUniqueInstance() { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } return uniqueInstance; } 复制代码
4. 懒汉式(延迟实例化)—— 线程安全/双重校验
一.私有化构造函数
二.声明静态单例对象
三.构造单例对象之前要加锁(lock一个静态的object对象)或者方法上加synchronized。
四.需要两次检测单例实例是否已经被构造,分别在锁之前和锁之后
使用lock(obj)
public class Singleton { private Singleton() {} //关键点0:构造函数是私有的 private volatile static Singleton single; //关键点1:声明单例对象是静态的 private static object obj= new object(); public static Singleton GetInstance() //通过静态方法来构造对象 { if (single == null) //关键点2:判断单例对象是否已经被构造 { lock(obj) //关键点3:加线程锁 { if(single == null) //关键点4:二次判断单例是否已经被构造 { single = new Singleton(); } } } return single; } } 复制代码
使用synchronized (Singleton.class)
public class Singleton { private Singleton() {} private volatile static Singleton uniqueInstance; public static Singleton getUniqueInstance() { if (uniqueInstance == null) { synchronized (Singleton.class) { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } } } return uniqueInstance; } } 复制代码
面试时可能的提问
0.为何要检测两次?
答:如果两个线程同时执行 if 语句,那么两个线程就会同时进入 if 语句块内。虽然在if语句块内有加锁操作,但是两个线程都会执行 uniqueInstance = new Singleton(); 这条语句,只是先后的问题,也就是说会进行两次实例化,从而产生了两个实例。因此必须使用双重校验锁,也就是需要使用两个 if 语句。
1.构造函数能否公有化?
答:不行,单例类的构造函数必须私有化,单例类不能被实例化,单例实例只能静态调用。
2.lock住的对象为什么要是object对象,可以是int吗?
答:不行,锁住的必须是个引用类型。如果锁值类型,每个不同的线程在声明的时候值类型变量的地址都不一样,那么上个线程锁住的东西下个线程进来会认为根本没锁。
3.uniqueInstance 采用 volatile 关键字修饰
uniqueInstance = new Singleton(); 这段代码其实是分为三步执行。
分配内存空间 初始化对象 将 uniqueInstance 指向分配的内存地址 复制代码
但是由于 JVM 具有指令重排的特性,有可能执行顺序变为了 1-->3-->2
public class Singleton { private volatile static Singleton uniqueInstance; private Singleton(){} public static Singleton getInstance(){ if(uniqueInstance == null){ // B线程检测到uniqueInstance不为空 synchronized(Singleton.class){ if(uniqueInstance == null){ uniqueInstance = new Singleton(); // A线程被指令重排了,刚好先赋值了;但还没执行完构造函数。 } } } return uniqueInstance;// 后面B线程执行时将引发:对象尚未初始化错误。 } } 复制代码
所以B线程检测到不为null后,直接出去调用该单例,而A还没有运行完构造函数,导致该单例还没创建完毕,B调用会报错!所以必须用volatile防止JVM重排指令
5. 静态内部类实现
当 Singleton 类加载时,静态内部类 SingletonHolder 没有被加载进内存。只有当调用 getUniqueInstance()
方法从而触发 SingletonHolder.INSTANCE
时 SingletonHolder 才会被加载,此时初始化 INSTANCE 实例。
这种方式不仅具有延迟初始化的好处,而且由虚拟机提供了对线程安全的支持。
public class Singleton { private Singleton() {} private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getUniqueInstance() { return SingletonHolder.INSTANCE; } } 复制代码
6. 枚举实现
这是单例模式的最佳实践,它实现简单,并且在面对复杂的序列化或者反射攻击的时候,能够防止实例化多次。
public enum Singleton { INSTANCE; private String objName; public String getObjName() { return objName; } public void setObjName(String objName) { this.objName = objName; } public static void main(String[] args) { // 单例测试 Singleton firstSingleton = Singleton.INSTANCE; firstSingleton.setObjName("firstName"); System.out.println(firstSingleton.getObjName()); Singleton secondSingleton = Singleton.INSTANCE; secondSingleton.setObjName("secondName"); System.out.println(firstSingleton.getObjName()); System.out.println(secondSingleton.getObjName()); // 反射获取实例测试 try { Singleton[] enumConstants = Singleton.class.getEnumConstants(); for (Singleton enumConstant : enumConstants) { System.out.println(enumConstant.getObjName()); } } catch (Exception e) { e.printStackTrace(); } } } 复制代码
为什么枚举是单例模式的最好方式?
考虑以下单例模式的实现,该 Singleton 在每次序列化的时候都会创建一个新的实例,为了保证只创建一个实例,必须声明所有字段都是 transient,并且提供一个 readResolve() 方法。
public class Singleton implements Serializable { private static Singleton uniqueInstance; private Singleton() { } public static synchronized Singleton getUniqueInstance() { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } return uniqueInstance; } } 复制代码
如果不使用枚举来实现单例模式,会出现反射攻击,因为通过反射的setAccessible() 方法可以将私有构造函数的访问级别设置为 public,然后调用构造函数从而实例化对象。
枚举实现是由 JVM 保证只会实例化一次,因此不会出现上述的反射攻击。
从上面的讨论可以看出,解决序列化和反射攻击很麻烦,而枚举实现不会出现这两种问题,所以说枚举实现单例模式是最佳实践。
使用场景举例
- Logger类,全局唯一,保证你能在每个类里调用为一个Logger输出日志
- Spring:Spring里很多类都是单例的,也是你理解单例最合适的地方,比如Controller和Service类,默认都是单例的。
- 数据库连接池对象:你从代码的任何地方都需要拿到连接池里的资源。
参考
- blog.jobbole.com/109449/
- github.com/CyC2018/CS-…
- 《HEAD FIRST 设计模式》
- 《剑指offer》