Vary's Blog

浅谈设计模式之单例模式

当我们有这样的需求:某一些类应该只存在一个实例 的时候,我们就可以用单例模式来应对.

单例模式是所有设计模式中最简单的一个,也是大部分人最早知道的一个设计模式.

但是即使是最简单的,也有很多可以推敲的细节,要做得对也不简单.

经典的单例

相信大家一定写过这样类似的单例模式代码:

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 (null == instance) {
instance = new Singleton();
}
return instance;
}
}

简单总结一下这样的写法:

  1. 提供一个全局静态的getInstance方法,使得易于使用.
  2. 延迟Singleton的实例化,节省资源(所谓的懒汉式).
  3. 缺点是线程不安全.当多个线程同时进入if (null == instance) {}的时候就会创建多个实例.

OK,接下来我们看来是要解决多线程不安全的问题了.

多线程安全

这个时候可能有的人就说了,这个太简单了,加一个synchronized不就结了吗?

1
2
3
4
5
6
public static synchronized Singleton getInstance() {
if (null == instance) {
instance = new Singleton();
}
return instance;
}

确实,增加synchronized之后能够迫使每个线程在进入这个方法之前,要先等别的线程离开该方法.也即避免了多个线程同时进入getInstance方法.

诚然这个能解决问题,但是我们知道synchronized是非常耗性能的.

更何况:
我们只需要在第一次执行这个方法的时候同步,也就是说当instance实例化后,我们不再需要同步了
而如果我们加了synchronized,那么实例化后的每次调用getInstance都是一种多余的消耗操作,是累赘

当然,如果哪些额外的负担你能接受(比如用的很少),那么添加synchronized的方法也是可以接受的,毕竟这是最简单的方式.

那么问题来了,如何改善?
如何确保单例,而又不影响性能?

性能进阶

接下去介绍一种更优秀的,线程安全的单例写法—双重检查锁模式(double check locking pattern)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DoubleCheck {
private volatile static DoubleCheck instance;
private DoubleCheck() {}
public static DoubleCheck getInstance() {
if (null==instance){ //检查
synchronized (DoubleCheck.class){
if (null == instance) { //又检查一次
instance = new DoubleCheck();
}
}
}
return instance;
}
}

注意这里的instance用了volatile关键字来修饰,为什么呢?
因为执行instance = new DoubleCheck()做了很多事情:

  1. 给instance分配内存
  2. 调用构造函数来初始化成员变量(可能会很久)
  3. 将ins对象指向分配的内存空间(执行完这步 ins才不为null)

上面的操作并不是原子操作,而jvm也可能重排序指令,导致第二三两步的执行顺序可能会被打乱,当第3步先于第2步完成,那么会导致有线程拿到了初始化未完毕的instance,那么就会错误,而这里利用了volatile的禁止指令重排序优化特性,用来解决这个问题.

注:volatile 在java 5 后才有效,原因是 Java 5 以前的 Java 内存模型是存在缺陷的,当然现在不需要担心这个啦!

小结

双重检查非常适用于高并发,我们熟知的开源库EventBus,Glide等都是用的双重检查锁方式实现单例

不过它,写起来稍微复杂了些,有没有简单点的呢?
答案是:有!

饿汉式

直接上代码吧

1
2
3
4
5
6
public class Early {
private static final Early instance = new Early();
public static Early getInstance() {
return instance;
}
}

饿汉式的原理其实是基于classLoader机制来避免了多线程的同步问题

饿汉式与之前提到的懒汉式不同,它在我们调用getInstance之前就实例化了(在类加载的时候就实例化了),所以不是一个懒加载,这样就有几个缺点:

  1. 延长了类加载时间
  2. 如果没用到这个类,就浪费了资源(加载了但是没用它)
  3. 不能传递参数(很显然适用的场景会减少)

静态内部类

静态内部类原理同上,另外虽然它看上去有点恶汉式,但是与之前的恶汉有点不同,它在类Singleton加载完毕后并没有实例化,而是当调用getInstance去加载Holder的时候才会实例化,静态内部类的方式把实例化延迟到了内部类的加载中去了!所以它比饿汉式更优秀!(偷偷告诉你《Effective Java》中也推荐这个方式)

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

枚举

最后介绍一种,也是我在《Effective Java》中看到的,居然用枚举!!!

1
2
3
public enum Singleton{
INSTANCE;
}

看上去好牛逼,通过Singleton.INSTANCE来访问,这比调用getInstance()方法简单多了。这种方式是《Effective Java》作者Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还能防止反序列化重新创建新的对象,可谓是很坚强的壁垒啊。

默认枚举实例的创建是线程安全的,所以不需要担心线程安全的问题。但是在枚举中的其他任何方法的线程安全由程序员自己负责。还有防止上面的通过反射机制调用私用构造器。

这个版本基本上消除了绝大多数的问题。代码也非常简单,实在无法不用。

总结

单例模式(Singleton)会控制其实例对象的数量,从而确保访问对象的唯一性。

单例模式的优点

  1. 实例控制:单例模式防止其它对象对自己的实例化,确保所有的对象都访问一个实例。
  2. 伸缩性:因为由类自己来控制实例化进程,类就在改变实例化进程上有相应的伸缩性。

单例模式的缺点

  1. 系统开销。虽然这个系统开销看起来很小,但是每次引用这个类实例的时候都要进行实例是否存在的检查。这个问题可以通过静态实例来解决。
  2. 开发混淆。当使用一个单例模式的对象的时候(特别是定义在类库中的),开发人员必须要记住不能使用new关键字来实例化对象。因为开发者看不到在类库中的源代码,所以当他们发现不能实例化一个类的时候会很惊讶。
  3. 对象生命周期。单例模式没有提出对象的销毁。在提供内存管理的开发语言(比如,基于.NetFramework的语言)中,只有单例模式对象自己才能将对象实例销毁,因为只有它拥有对实例的引用。在各种开发语言中,比如C++,其它类可以销毁对象实例,但是这么做将导致单例类内部的指针指向不明。

单例适用性

使用Singleton模式有一个必要条件:在一个系统要求一个类只有一个实例时才应当使用单例模式。反之,如果一个类可以有几个实例共存,就不要使用单例模式。

不要使用单例模式存取全局变量。这违背了单例模式的用意,最好放到对应类的静态成员中。

不要将数据库连接做成单例,因为一个系统可能会与数据库有多个连接,并且在有连接池的情况下,应当尽可能及时释放连接。Singleton模式由于使用静态成员存储类实例,所以可能会造成资源无法及时释放,带来问题。

Vary Zhao wechat
欢迎你扫描上面的二维码,加我微信