设计模式 - 单例模式的几种实现方式

定义

什么是单例模式?
在整个系统中,一个类永远只有一个实例化的对象,被称为单例

作用

单例模式可以减少一个频繁被创建的重量级对象在多次实例化的时候所花费的时间,当一个类实例化所需要的时间比较长的时候,就可以考虑使用单例了。一来节约了对象多次创建所花费的时间,二来可以节约资源。

实现

单例模式是最常见、最容易理解、应用最广泛的模式了,其有多重实现方式。但是各个实现方式的优劣都不一样,有挺多的坑。

饿汉式

1
2
3
4
5
6
7
8
9
10
11
public class Singleton {
private static final Singleton INSTANCE = new Singleton();

// Private constructor suppresses
// default public constructor
private Singleton() {};

public static Singleton getInstance() {
return INSTANCE;
}
}

这种实现是最为简单的单例的实现,因为将INSTANCE对象设为了static final,保证这个对象在类加载的时候就会被实例化,因此它一定是单例的。
当然,这种实现也有缺点,那就是不支持懒加载。无论是否有调用,都会在系统初始化的时候进行实例化,这样在某些场景下是无法使用的,比如该类的实例化依赖参数或者配置文件。

懒汉式

1
2
3
4
5
6
7
8
9
10
11
public class Singleton {
private static Singleton instance;
private Singleton (){}

public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

懒汉式的实现也比较简单,而且容易理解,并且还支持懒加载。但是这种实现方式有一个重大的缺陷,那就是不支持多线程。
当有多个线程同时调用getInstance方法时,就会实例化多个对象,因此在多线程的情况下,这种实现是不适用的(线程不安全)。

懒汉式 - 线程安全

为了解决懒汉式不能适用多线程的情况,最简单的方式就是给getInstance加锁,使它变成同步(synchronized)方法。

1
2
3
4
5
6
7
8
9
10
11
public class Singleton {
private static Singleton instance;
private Singleton (){}

public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

这种实现方式解决了实例化多个对象的问题,做到了线程安全,但是实例化的同步操作应该只有第一次才需要同步,而这个实现使得整个方法变成了同步,导致这个实现存在效率问题。为了解决效率问题,减少同步的开销,就有了下面的双重检验锁的实现方式。

双重校验锁

维基百科上有对这种模式的解释:双重检查锁定模式

双重检查锁定模式(也被称为”双重检查加锁优化”,”锁暗示”(Lock hint)[1]) 是一种软件设计模式用来减少并发系统中竞争和同步的开销。双重检查锁定模式首先验证锁定条件(第一次检查),只有通过锁定条件验证才真正的进行加锁逻辑并再次验证条件(第二次检查)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton {
// 声明成volatile
private volatile static Singleton instance;
private Singleton (){}

public static Singleton getInstance() {
// 第一次校验
if (instance == null) {
// 第二次加锁校验
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance ;
}
}

第一次检查发现还没有实例化的时候,在同步的代码块里面去做实例化。这种方式比较好的实现了线程安全,当多个线程同时调用getInstance方法时,都会判断instance是否为空,然后因为实例化的代码加了锁做了二次校验,因此当线程完成实例化释放锁之后,其他线程在二次校验对象的时候都已经存在了,也就避免了多次实例化的问题。
不过用Java实现双重校验会存在一些问题。实例化的这行代码,它并不是一个原子操作。这行代码大概可以分三步:

  1. 给instance对象分配内存
  2. 调用构造器实例化对象
  3. 将instance对象指向分配好的内存空间

在JVM中有存在指令重排优化的,所以这行代码的调用顺序可能并不是1-2-3。如果调用顺序是1-3-2,那在实例化对象之前,先将instance指向了分配好的内存空间,就会导致instance在真正被实例化之前,已经是非null的了,那在其他线程调用时,就可能会出现一些错误。
还有,如果在实例化的线程中实例化好了对象,但是由于可见性的原因,另一个线程并不知道这个对象已经被实例化了,那同样也会有多次实例化对象的问题。

所以,必须将instance对象声明成volatile。

静态内部类

1
2
3
4
5
6
7
8
9
public class Singleton {  
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton (){}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}

本质上是使用了static final修饰内部类变量,所以只有在类加载的时候才会去实例化对象,除了 getInstance() 之外没有办法访问它,因此可以懒加载。并且只会在类加载的时候实例化一次,天生的线程安全,也没有性能问题。

枚举

1
2
3
4
5
6
public enum SingletonEnum {
INSTANCE;

public void anyMethod() {
}
}

创建枚举默认就是线程安全的,因此不需要一些其他的机制来保证线程安全,可以直接使用SingletonEnum.INSTANCE来访问实例。除此以外,枚举还能防止反序列化导致创建新的对象。

总结

正确的单例模式的写法大概是以上几种形式(不包括线程不安全的懒汉实现),一般而言其实饿汉式就已经可以满足大多数情况了,如果需要看加载的形式可以使用静态内部类,再进一步需要考虑反序列化的情况,就可以使用枚举了。