单例设计模式

单例设计模式(Singleton Design Pattern)指的是实现一个类,在应用中只有唯一一个实例。但现在已经成为业界共识,它成为了反模式的代表。这篇文章先讨论实现单例模式的几种方式,然后讨论为什么它是一种反模式。以及它的应用场景。

我们先来讨论单例模式的几种实现方法。

饿汉式(Eager Initialization)

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

//private constructor to avoid client applications to use constructor
private EagerInitializationSingleton(){}

public static EagerInitializationSingleton getInstance(){
return instance;
}
}

第一种写法不算很复杂,因为声明了static和final,在类加载的时候就实例化了,另外将constructor声明为private,也就是不能使用new来创建实例,而只能使用getInstance()来得到唯一的实例,从而保证了只有一个实例。

但是这种方法的坏处显而易见,它在类加载的时候就初始化了,对于从来没有用过但是创建又需要很多资源的对象,这无疑是一种浪费。于是就有了懒汉式(lazy initialization)。

双检锁(Double Checked Locking)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Double-checked Locking Lazy Initialization
public class DoubleCheckedLockSingleton {

private volatile static DoubleCheckedLockSingleton singleton;

private DoubleCheckedLockSingleton() {}

public static DoubleCheckedLockSingleton getInstance() {
if(singleton == null) {
synchronized(DoubleCheckedLockSingleton.class) {
if(singleton == null) {
singleton = new DoubleCheckedLockSingleton();
}
}
}
return singleton;
}
}

第二种双检锁是一种懒汉式——只有调用getInstance()的时候才实例化对象。但逻辑比饿汉式复杂许多,让我们来仔细看一看。
getInstance()方法中使用了双检锁,这么做避免了同步整个getInstance()方法,因为只是在第一次实例化时需要同步,创建好之后,就可以直接返回已经创建好的singleton了。又是懒汉式,又不需要同步整个getInstance()方法影响性能,开销不算太大。
然而这里还有个坑,注意这里的volatile,尽管有了synchronized,但如果没有声明volatile,这个看似完美的方法还是会失效。
话说因为singleton = new DoubleCheckedLockSingleton()这个操作实际上含有三个步骤

(1) 给singleton对象分配内存
(2) 调用构造器来初始化成员变量
(3) 将singleton对象指向分配好的内存空间(执行这一步之后singleton != null)

然而JMM中指令是存在重排序的,也就是不能保证(2)和(3)的次序。
假设两个线程同时进入第一层if(singleton == null),假设线程A首先进入了synchronized block,但是执行完singleton = new DoubleCheckedLockSingleton()之后,仅仅只完成了步骤(3),singleton对象指向分配好的内存空间(并没有完成构造器的初始化工作),然后返回。线程B这时候进入synchronized block,看见singleton已经不等于null了,于是也返回了,然后使用这个没有真正初始化完成的实例,便会有不可预知的问题。
所以这里加上volatile之后禁止指令重排,根据happens-before原则,写操作将会先行发生于后面发生的读操作,也就是线程B一定读到的是线程A实例化完整的实例。
但是请注意,即使是使用了volatile,在Java 5之前仍旧是有问题的,Java 5之前的JMM有bug,并不能保证禁止重排序。
所以对于这么多坑的一种写法,我们明白了原理就算了,能避免就避免吧。

静态内部类(Static Inner Class)

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

private InnerClassSingleton (){}

public static final InnerClassSingleton getInstance() {
return SingletonHolder.INSTANCE;
}
}

这种写法也是懒汉式的,同时读取实例的时候不会进行同步,没有性能缺陷,也不依赖JDK版本。完美!

Enum

1
2
3
4
5
public enum EnumSingleton {

INSTANCE;

}

这种写法太简单了!有没有!它也是懒汉式的,天然的保证了线程安全,天然的不需要同步,不过当然需要Java 5以上的版本啦(Java 5之前也得有enum才行啊!),也是《Effective Java》上推荐的方法。

性能比较

这里有一篇关于几种写法的性能比较:Fastest Thread-safe Singleton in Java

但是没有出乎意料,对于懒汉式的写法中,静态内部类 > 双检锁 > synchronized getInstance方法(本文没有讨论,因为性能实在是堪忧)。这里没有比较enum,留给有兴趣的童鞋吧。

如果一定要实现一个单例设计模式,就我而言,我会采用enum的写法。

为什么单例模式是一个反模式?

那么讨论完单例模式的几种实现方法,我们再来说明为什么它已然已经成为业界共识的反模式了。纳尼?前面铺垫那么多,不是白讨论了吗?!虽然是浪费了很多口舌,但是单例模式依然有他的应用场景,我们后面会说。

首先我们要搞清楚两个概念:单例模式和单例。前者是一种设计模式,它的特征是:1)私有的构造器,2)通过Singleton.getInstance()方法获得该类的唯一实例。而单例指的是一个类有且只有一个实例,可以通过依赖注入来实现。Spring中,我们常用的service或者dao通常就是典型的单例,它们通过BeanFactory来进行依赖注入,来保证应用中引用的都是一个类的同一个实例。

要注意的是,Spring中默认的singleton scope和严格意义上单例还是有区别的,来自Spring的文档中指出:

When a bean is a singleton, only one shared instance of the bean will be managed and all requests for beans with an id or ids matching that bean definition will result in that one specific bean instance being returned.

也就是说你在Spring中定义了singleton的使用范围,并不能保证你的bean在应用中是绝对的唯一实例,因为这个bean通常有公有的构造器,你完全可以绕过Spring而new一个实例。

单例模式之所以是一个反模式,是因为:

  • 它通常是一个复杂的object,维护着一个全局的状态,而全局的状态使得结果不可预测
  • 由于是私有构造器,单元测试变得困难

应用场景

基本的原则就是不要自己实现单例模式,然而依然有场景(为数不多的)适合使用单例模式,譬如logging:

  • 客户代码需要一个全局的logging service来发送请求去写log
  • 多个监听器(listener)可以注册同一个logging service
  • 虽然不同的应用会有不同的输出,它们注册监听器的方法是一样的,它们可以定制监听器来实现不同的配置,客户代码不需要知道log在哪里或如何写log的

参考: