程序员要透彻理解单例模式
前言
本系列文章带你温习常见的设计模式。主要内容有:
该模式的介绍,包括:
引子、意图(大白话解释)
类图、时序图(理论规范)
该模式的代码示例:熟悉该模式的代码长什么样子
该模式的优缺点:模式不是万金油,不可以滥用模式
该模式的实际使用案例:了解它在哪些重要的源码中被使用
创建型——单例模式
引子
《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类,默认都是单例的。
数据库连接池对象:你从代码的任何地方都需要拿到连接池里的资源。
参考
http://blog.jobbole.com/109449/
https://github.com/CyC2018/CS-Notes/blob/master/notes/%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%20%20-%20%E5%8D%95%E4%BE%8B.md
《HEAD FIRST 设计模式》
《剑指offer》
最近面试BAT,整理一份面试资料《Java面试BAT通关手册》,覆盖了Java核心技术、JVM、Java并发、SSM、微服务、数据库、数据结构等等。 获取方式:点“在看”,关注公众号并回复 Java 领取,更多内容陆续奉上。