前言 本系列文章带你温习常见的设计模式。主要内容有:
创建型——单例模式 引子 《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类,默认都是单例的。
数据库连接池对象:你从代码的任何地方都需要拿到连接池里的资源。
参考
最近面试BAT,整理一份面试资料 《Java面试BAT通关手册 》 ,覆盖了Java核心技术、JVM、Java并发、SSM、微服务、数据库、数据结构等等。 获取方式:点“ 在看 ”,关注公众号并回复 Java 领取,更多内容陆续奉上。