单例模式是常见的一种设计模式,本文就来总结一下单例模式的几种写法。
一、饿汉式
所谓饿汉式单例设计模式,就是将类的静态实例作为该类的一个成员变量,也就是说在JVM 加载它的时候就已经创建了该类的实例,因此它不会存在多线程的安全问题。
/**
* 饿汉式版单例模式的实现
*/
public class Singleton {
public static final Singleton instance = new Singleton();
public Singleton() {
//empty
}
public static Singleton getInstance() {
return instance;
}
}
此种写法唯一的缺点就是:不支持懒加载(lazy initialization),存在两个缺点。
- 缺点一:饿汉式提前对实例进行了初始化或者说构造,假设构造该类需要很多的性能消耗,如果代码写成这个样子将会提前完成构造,又假设我们在系统运行过程中压根就没有对该实例进行使用,那岂不是很浪费系统的资源呢?
- 缺点二:譬如 Singleton 实例的创建是依赖参数或者配置文件的,在 getInstance() 之前必须调用某个方法设置参数给它,那样这种单例写法就无法使用了。
二、懒汉式
所谓懒汉式单例模式的意思就是,实例虽然作为该类的一个实例变量,但是它不主动进行创建,如果你不使用它那么它将会永远不被创建,只有你在第一次使用它的时候才会被创建,并且得到保持。
/**
* 懒汉式版单例模式的实现
*/
public class Singleton {
public static Singleton instance;
public Singleton() {
//empty
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return Singleton.instance;
}
}
上述代码就是传统懒汉式实现的方式,如果我们对于多线程有一点了解,会发现上述代码存在着线程安全问题。在多线程的情况下,instance
实例存在着可能被创建多次的情况。下面就来分析一下出现线程安全问题的情况:
懒汉式出现线程安全问题
假设有①、②两个线程同时在获取这个instance
,则有可能出现如上图中所示的这种情况。也就是当①线程执行完了null == instance
但未创建Instance
实例时,恰巧cpu执行时间到了,①线程让出了cpu的执行权。于是②线程也进入到了null == instance
中,判断到了instance
没有被创建,因此分别实例化了一个以上的Instance
。
这样的单例类是很危险的,那么我们应该如何避免多线程引起的问题呢?请看下面的单例模式的实现代码。
三、同步方法版懒汉式
为了解决上面线程同步的问题, 最简单的方法是将整个getInstance()
方法设为同步(synchronized)。
/**
* 同步方法版懒汉式,使用synchronized在getInstance方法上加锁
*/
public class Singleton {
public static Singleton instance;
public Singleton() {
//empty
}
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return Singleton.instance;
}
}
但是该方法的效率将是相当低下的,因为每一次调用都要获取锁,判断锁的状态,使得getInstance()
方法完全变成了一个串行化的方法。因此就会出现解决了安全问题,带来了很大的效率问题。我们怎么能在解决线程安全问题的同时,尽可能的提高程序的效率呢?请看下面的代码。
四、双重校验锁
双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为双重检查锁,因为会有两次检查 instance == null,一次是在同步块外,一次是在同步块内。代码如下所示:
public class Singleton {
private static Singleton instance;
private Singleton() {
//---
}
//double check
public static Singleton getInstance() {
if (null == instance) {
synchronized (Singleton.class) {
if (null == instance)
instance = new Singleton();
}
}
return Singleton.instance;
}
}
让我们分析一下,上述双重校验锁模式是如何将效率的损耗降到最低的。
如上图所示,当①线程进入到如图所示位置,判断Instance
为null
,并且初始化了Instance
;②线程进入到如图所示位置,判断null==instance
条件不成立,直接退出;当③线程进入到了如图所示位置发现null==instance
不成立,则直接返回。
通过上述代码的分析,我们可以发现,锁的等待或者争抢最多发生两次,也就是同步代码块中的代码最多被执行两次,如此一来,安全问题解决了,效率问题也被解决掉了。
这段代码看起来很完美,很可惜,它是有问题, 可能会出现空指针异常。主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情(具体可以参考:JVM之对象的创建)。
- 给 instance 分配内存
- 调用 Singleton 的构造函数来初始化成员变量
- 将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)
但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance。如果我们在这个时候使用instead,就会顺理成章地报错。
那么怎么解决这种情况呢,我们只需要将 instance 变量声明成 volatile 就可以了!
4.1 volatile版双重校验锁
public class Singleton {
private static volatile Singleton instance;
private Singleton() {
//
}
//double check add volatile
public static Singleton getInstance() {
if (null == instance) {
synchronized (SingletonObject4.class) {
if (null == instance)
instance = new Singleton();
}
}
return Singleton.instance;
}
}
有些人认为使用 volatile 的原因是 可见性,也就是可以保证线程在本地不会存有 instance 的副本,每次都是去主内存中读取。但其实是不对的。使用 volatile 的主要原因是其另一个特性:禁止指令重排序优化。也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的“后面”是时间上的先后顺序)。
五、静态内部类版单例模式
public class Singleton {
private Singleton() {
}
private static class InstanceHolder {
private final static Singleton instance = new Singleton();
}
public static Singleton getInstance() {
return InstanceHolder.instance;
}
}
这种方式比较完美,它具有以下的优点:
- 使用JVM本身机制保证了线程安全的问题;
- 由于InstanceHolder是私有的,除了
getInstance()
之外没有办法访问它,因此它是懒汉式; - 读取实例的时候不会进行同步,可以保证线程安全。不需要加锁,没有性能缺陷。也不依赖 JDK 版本。
六、枚举Enum版单例模式
public class Singleton {
private Singleton() {
}
private enum Singleton {
INSTANCE;
private final Singleton instance;
Singleton() {
instance = new Singleton();
}
public Singleton getInstance() {
return instance;
}
}
public static Singleton getInstance() {
return Singleton.INSTANCE.getInstance();
}
public static void main(String[] args) {
IntStream.rangeClosed(1, 100)
.forEach(i -> new Thread(String.valueOf(i)) {
@Override
public void run() {
System.out.println(Singleton.getInstance());
}
}.start());
}
}
我们可以通过Singleton.INSTANCE.getInstance()
来访问实例,这比调用getInstance()方法简单多了。创建枚举默认就是线程安全的,所以不需要担心double checked locking,而且还能防止反序列化导致重新创建新的对象。但是还是很少看到有人这样写,可能是因为不太熟悉吧。
总结
一般来说,单例模式有六种写法:懒汉、饿汉、同步方法、双重检验锁、静态内部类、枚举。上述所说都是线程安全的实现,文章开头给出的第一种方法不算正确的写法。
就我个人而言,一般情况下直接使用饿汉式就好了,如果明确要求要懒加载(lazy initialization)会倾向于使用静态内部类,如果涉及到反序列化创建对象时会试着使用枚举的方式来实现单例。
参考文章:
如何正确地写出单例模式