Java设计范式23式,单例模式(singleton)是第一式,但是也是最复杂的一式。它的复杂性体现在多方面,首先我们先要了解为什么存在单例。其次,在产生单例的过程中,我们要避免哪些风险。最后,我们还要对 JVM 如何载入一个类有一定的认识。可以说一个单例模式综合考验了我们对 Java 在各方面的理解。
首先,为什么需要单例?这个问题比较简单,在任何可以用一个实例解决问题的地方,我们就应该考虑使用单例,前提是它是否同样保持高并发下执行效率不变?也就是说如果系统共享这一个实例,那么是否会出现资源竞争而产生瓶颈?如果不会,那么就应该使用单例。比如所有的只读量、静态量、纯函数对象等,都应该是单例。这种对象在系统中可以有很多,但是我们很少注意到它们,是因为在使用面向对象编程时,我们几乎会为所有的对象都产生一个实例,而不管是否真的需要这么做。但是在网络编程中,“对象“一般是来自用户的请求(值对象),在这种情况下,controller 作为存粹的处理节点(函数),它本身不包含也不寄存对象特例,那么将它单例化就很合理。另外,对于排它性资源的使用,也应该是单例,比如日志文件。我们要保证任何时候只能有一个任务在写入日志,那么单例也是比较合适的。
确定了单例的使用后,就是如何生成一个单例。首先我们必须知道生成单例有两种基本模式,在这两种模式上又演化出多种形式。这两种模式分别是“饥饿”模式和“懒加载”模式。
先来看一个错误的单例模式例子:
class Singleton {
private Singleton(){}
private static Singleton INSTANCE = null;
public static Singleton getInstance(){
if(INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
这个例子在单线程下不会有问题,但是如果运行在多线程下就不能保证是单例,因为 getInstance() 不能防止多线程重复进入,当第一个线程判断 INSTANCE 为空后,进而准备生成实例之前,如果恰好此时第二个线程也进入了 if 块,它同样会认为实例是空的,然后越过判断保护,执行到和第一个线程相同的地方,于是,你有可能执行了多余一次的构建。我们只需要放大这个执行过程不难看出错误:
class Singleton {
private Singleton(){}
private static Singleton INSTANCE = null;
static Random r = new Random();
public static Singleton getInstance() throws InterruptedException {
if(INSTANCE == null) {
Thread.sleep(r.nextInt(100)); // 加入随机等待
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
现在让我们在 if 代码块的开头加入一段随机的停顿,然后用两个线程分别去获取实例之后比较它们是否相同:
Singleton[] s = new Singleton[2];
new Thread(() -> {
try {
s[0] = Singleton.getInstance();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
try {
s[1] = Singleton.getInstance();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
Thread.sleep(200);
System.out.println(s[0].equals(s[1]));
这几乎一定会得到 false 的结果。所以我们必须考虑对 getInstance() 方法加装同步锁,以防止多线程共享:
class DCLSingleton {
private DCLSingleton(){}
private volatile static DCLSingleton INSTANCE = null;
public static synchronized DCLSingleton getInstance1(){
if(INSTANCE == null) {
INSTANCE = new DCLSingleton();
}
return INSTANCE;
}
}
但是这种将 synchronized 关键词直接加在方法上的做法未免使得加锁的范围太大,synchronized 在大并发条件下可能导致重度加锁,对性能产生较大影响,因此应该尽可能缩小加锁的范围,因此诞生了以下优化的写法:
class DCLSingleton {
private DCLSingleton(){}
private volatile static DCLSingleton INSTANCE = null;
public static DCLSingleton getInstance(){
if(INSTANCE == null) { // 第一次检查
synchronized(DCLSingleton.class){
if(INSTANCE == null) // 再次检查
INSTANCE = new DCLSingleton();
}
}
return INSTANCE;
}
}
以上这种模式在大并发条件下效率比前一种要高很多,尤其在Java6之前。它的第一次 if 判断的作用是尽可能避免触发锁机制,只是用来避免由此产生的效率问题的,并不保证此时访问单例一定是安全的。第二次比较才真正地确保单例的安全性。其中的逻辑相信大家不难看出来。因为它在进入临界区之前和之后需要反复确认单例对象是否为空,因此有个名称叫 Double check lock (DCL)模式。
以上模式最大的坑其实是在单例变量的声明上,它必须被声明成“volatile static”。这是因为现代 CPU 大多数有乱序执行的能力。什么是“乱序执行”呢?我们以“将大象装入冰箱”为例,如果我们要将一只大象装进冰箱,那么需要分三个步骤:1)打开冰箱门。2)将大象推进去。3)关上冰箱门。这三个步骤必须先后执行,如果我们“乱”了这个顺序,在执行第二步之前就先关上冰箱的门,那么大象就无法被装进冰箱里。同理,虚拟机生成一个对象也分为三个步骤:1)开辟内存空间。2)初始化对象。3)将变量指向对象。但是 CPU 不保证后两个步骤按顺序执行,如果第三步先于第二部执行,那么这时候我们拿到的将是一个未初始化的空指针(请参考《Java虚拟机规范》中有关对象加载与初始化机制的章节)。而关键词 volatile 的作用之一就是保证指令执行的有序性,它告诉CPU在对该对象的任何写入或读取之前,必须先完成之前的动作,所以必须在声明对象的时候加上该关键词,才能避免其它线程拿到空指针的风险。
以上介绍的这种模式的特点是,当且仅当我们调用 getInstance() 方法的时候,单例才会被建立,因此这种模式被称为“懒加载”模式。而这种懒加载只有最后一个才是完全正确且高效的,但是它需要我们对锁机制和 CPU 指令执行顺序有一定的了解。下一章将介绍一种不需要(显式)加锁的单例生成机制:静态初始化模式。