先说一下我自己对单例模式的理解:
单例模式:在整个程序运行周期内,某个类被设计为其所有实例都归属于一个副本,以保证含义上的唯一性和行为上的总控性。这种类的设计方式被称为单例模式。
如果某个类从现实世界角度来看,确实应该只存在一个实例副本,或者该类的行为是作为整个系统中某个功能的总控统筹,将它通过单例模式来实现,能够提供良好的可维护性和准确性,也更节省占用的内存和新生成实例的开销。例如管理一个JDBC连接的类,或者一个Canvas中的画笔类等。
为了实现单例,首先不能让类被随意地实例化。我们可以通过创建private构造函数来屏蔽new关键字的调用。
在网上随便搜一下,能够看到五花八门的单例模式说明,各种名词层出不穷,饿汉、懒汉、饱汉等等...但在这篇文章中我想层层递进地说一下各种实现方式的进化关系。
基本的单例
在我刚开始写代码的时候,曾经也遇到过需要只存在一个实例的场景。当时还很懵懂,就写出了如下的代码:
// “懒汉” - 延迟初始化,非线程安全
// “懒”表现为: instance要等到真正使用时(getInstance)才会被实例化
public class Singleton1 {
private static Singleton1 instance;
private Singleton1 (){}
public static Singleton1 getInstance() {
if (instance == null) {
instance = new Singleton1();
}
return instance;
}
}
以上代码是非线程安全的。
试想当在多线程场景中,许多线程几乎同时调用getInstance方法,并判断instance == null为true,这个时候便会有多个现成去执行实例化代码。这样一来便无法保证类的单例了。
而另外一种比较基本的单例写法是:
//“饿汉”(其实我想称它为“勤男”) - 立即初始化,线程安全
//"饿"体现在: 饿汉很饿,希望尽早吃到内存
//"勤"体现在:人家一上来就把内存区开好了,相比于上面的“懒汉”,当然是“勤”了
public class Singleton2 {
private static Singleton2 instance = new Singleton2();
private Singleton2 (){}
public static Singleton2 getInstance() {
return instance;
}
}
这种写法使用Java的语法糖,能够满足多线程的并发需求。但是从延迟初始化的角度来说,是有欠缺的。尤其是当Singleton是一个比较复杂的类,无法简单地通过new关键字进行实例化,或者需要获得某些参数才能完成实例化时,延迟初始化就成了我们的必选项。
从上面我们可以看到,两种基本的单例实现方式,都存在各自的缺点。根据上面的分析,其实我们想要的是一种既能延迟初始化,又保证了多线程安全的单例实现模式。
延迟初始化+线程安全的单例
为了要保证二者兼得,我们在第一种代码的基础上,增加能够保证线程安全的代码即可。为此,我们引入synchronized关键字。
synchronized关键字
synchronized关键字,可用来给对象和方法或者代码块加锁,当它锁定一个方法或者一个代码块的时候,同一时刻最多只有一个线程执行这段代码。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。
由于是说设计模式的文章,这里就不展开讨论synchronized了。按照上面的描述,如果没研究过它的朋友应该也能基本了解它的作用。我们使用它来实现一个满足两个条件的单例:
//"懒汉"变种
//用 synchronized 修饰 getInstance方法
public class Singleton3 {
private static Singleton3 instance;
private Singleton3 (){}
public static synchronized Singleton3 getInstance() {
if (instance == null) {
instance = new Singleton3();
}
return instance;
}
}
这种写法满足了线程安全,但是安全过头了。在多线程场景中,同一时刻只能有一个线程访问getInstance方法,换言之就是多线程在这个方法上变成了单线程。大家可以想到,即使后续的线程中instance已经不为null了,但还是要等待前序线程执行完该方法。这无疑是对效率的一大阻碍。
Double Check Lock (DCL)
为了提升多线程效率,我们将synchronized换了个位置。但是为了确保单例,我们又在synchronized内部增加了一次if判断,这样便有了两次null检查,即DCL:
//"懒汉"变种
//用 synchronized 修饰 getInstance方法
public class Singleton4 {
private static Singleton4 instance;
private Singleton4 (){}
public static synchronized Singleton4 getInstance() {
if (instance == null) {
synchronized(Singleton4.class){
if(instance == null) {
instance = new Singleton4();
}
}
}
return instance;
}
}
由于synchronized作用域已经从一个方法缩小到了一段代码块,多个线程可以同时访问第一个if判断,如果instance不为null便可以直接返回,不用等待。这种写法虽然奇怪,但是看起来确实实现了延时初始化和线程安全,并且提升了多线程的效率。
但是实际上,这种方式并没有保证完全的线程安全,罪魁祸首便是指令重排序。
指令重排序
instance = new Singleton4();
这行代码,可以被编译器编成以下三行指令:
- rawMemory = allocateMemory(); //分配内存
- preparedMemory = initMemory(rawMemory); //初始化内存
- instance = preparedMemory; //内存与字段绑定
试问如果编译器将结果优化为以下序列,将后两个指令调换顺序,多线程情况下会出现什么结果?
- 分配内存
- 内存与字段绑定
- 初始化内存
有的线程可能在刚进入该方法时,刚好上述指令执行到了第二步,因此instance不为null。但实际上此时instance还没有被初始化完成,线程拿到的是一个残缺的非null实例。
volatile关键字
volatile关键字保证了对应的字段能够含有一下特性:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改变量值,这新值对其他线程来说是立即可见的。
- 禁止对操作该变量的指令重排序。
具体关于volatile关键字的说明可以参见网上其他文章,这里也不再赘述,大家只要先了解上述两个特性。下面我们来利用volatile对我们的单例模式进行进一步的加工:
//"懒汉"变种
//用 volatile 修饰 instance
public class Singleton5 {
private static volatile Singleton5 instance = null;
private Singleton5 (){}
public static synchronized Singleton5 getInstance() {
if (instance == null) {
synchronized(Singleton5.class){
if(instance == null) {
instance = new Singleton4();
}
}
}
return instance;
}
}
通过使用volatile修饰instance,保证了实例化时指令的正确顺序,也确保了多线程安全。这种写法基本上实现了一个单例的基本要求。
套路的单例
Holder模式
除了上面组合使用synchronized和volatile进行多线程安全保护外,我们还可以按照Holder方式将基本的实例中第二种“勤汉”模式进行修改,从而再实现一套即延迟初始化,又保证线程安全的代码:
//Holder模式
//引入静态类,该类在首次实际使用时进行内存分配,即return SingletonHolder.INSTANCE时
public class Singleton6 {
private static class SingletonHolder {
private static final Singleton6 INSTANCE = new Singleton6();
}
private Singleton6 (){}
public static final Singleton6 getInstance() {
return SingletonHolder.INSTANCE;
}
}
枚举模式(Since jdk1.5)
由于枚举的特性,它实际上是一个天然的单例,确保了实例副本的唯一性:
public enum Singleton7{
INSTANCE;
}
枚举型的单例能够支持线程安全,且能够实现延迟加载,并且还能防止反序列化问题和反射攻击问题,不愧为Effective Java中提出的完美的单例解决方案。
反序列化问题和反射问题
这一块内容完全是为了对单例模式进行补充,如果只是为了了解设计模式的话,可以不再往下阅读了。
由于单例模式最重要的一点就是保证该类的实例副本的唯一性,而如果这个类支持序列化,那么在反序列化的时候我便可以产生多个实例,即反序列化是一个隐藏很深的构造函数。如果不对这种情况进行封锁,势必会破坏单例。
private Singleton readResolve(){
return getInstance();
}
通过实现readResolve方法,在反序列化时,跳过默认逻辑,而使用已经写好的getInstance方法,能够规避这种问题。
而反射问题则是利用Java的反射机制,调用到private访问权限的构造函数,从而生成了多个实例。针对这个问题,目前可以看到的是枚举模式能够完美地进行处理。