单例模式的若干种写法

设计一个类,我们只能生成该类的一个实例。

这是一道很简单也很基础的设计模式题,对不对?但是要真的在各种条件下完美的实现Singleton模式,却是需要一点思考的。

以前我在实习的时候,每次遇到需要用到单例来写一个处理器线程的时候,使用的都是最简单的单例实现模式,由我自己在代码中人为保证代码只会被调用一次。今天正好有机会,系统地学习一下如何正确地实现单例模式

不好的解法一:只适用于单线程环境

由于 要求只能生成一个实例,因此我们必须把构造函数设置为私有函数以防止他人创建实例。我们可以定义一个静态的实例,在需要的时候创建该实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton1 {
private static Singleton1 instance = null;

private Singleton1() {

}

public static Singleton1 getInstance() {
if (instance == null)
instance = new Singleton1();

return instance;
}
}

上面的代码在Singleton的静态属性getInstance中,只有在instancenull的时候,才会创建一个实例以避免重复。同时我们把构造函数定义为一个私有函数,这样就能确保只会创建一个实例

不好的解法二:多线程中能工作但效率不高

解法一中的代码在单线程的时候工作正常,但是在多线程的情况下就有问题了。设想如果两个线程同时运行到判断instance是否为nullif语句,并且instance的确没有创建,那么两个线程都会创建一个实例,此时Singleton1就不再满足单例模式的要求了。

为了保证在多线程环境下我们还是只能得到类型的一个实例,需要加上一个同步锁。稍微修改Singleton1的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Singleton2 {
private static Singleton2 instance = null;
private static Lock lock = new ReentrantLock();

private Singleton2() {
}

public static Singleton2 getInstance() {
lock.lock();
if (instance == null) {
instance = new Singleton2();
}
lock.unlock();
return instance;
}
}

我们假设还是有两个线程同时想创建一个实例,由于在同一时刻只有一个线程能够得到同步锁,当第一个线程加上锁时,第二个线程只能等待。当第一个线程发现实例还没有创建时,它创建出一个实例。接着第一个线程释放同步锁,此时第二个线程可以加上同步锁,并运行接下来的代码。这个时候由于实例已经被第一个线程创建出来了,第二个线程就不会重复创建实例了,这样就保证了我们在多线程环境中也只有一个实例。

但是,Singleton2还是不是很完美。我们每次通过属性getInstance来获取实例时,都会试图加上一个同步锁,而加锁是一个非常耗时的操作,在没有必要的时候应该尽量避免。

可行的解法:加同步锁前后两次判断实例是否已存在

我们只是在实例还没有创建之前需要加锁操作,以保证只有一个线程创建出实例。而当实例已经创建之后,我们已经不需要再做加锁操作了。我们可以把解法二中的代码再做进一步的改进

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton3 {
private volatile static Singleton3 instance = null; // 注意 volatile 关键字
private static Lock lock = new ReentrantLock();

private Singleton3() {
}

public static Singleton3 getInstance() {
if (instance == null) {
lock.lock();
if (instance == null) {
instance = new Singleton3();
}
lock.unlock();
}
return instance;
}
}

Singleton3中只有当instancenull即没有创建时,需要加锁操作。当instance已经创建出来之后,则无需加锁。因为只有在第一次的时候instancenull,因此只有在第一次试图创建实例的时候需要加锁。这样Singleton3的时间效率比Singleton2好很多。

Singleton3这种实现机制又比称为“双重检查加锁”,它的实现需要依赖于volatile关键字,它的意思是:被volatile修饰的变量的值,将不会被本地线程缓存,所以对该变量的读写都是直接操作共享内存,从而确保多个线程能正确处理该变量。

注意:在Java1.4及以前版本中,很多JVM对于volatile关键字的实现的问题,会导致“双重检查加锁”的失败,因此“双重检查加锁”机制只能用在Java5及以上的版本

  • 挖个坑,关于volatile关键字的具体知识,另开博文解释。

Singleton3用加锁机制来确保在多线程环境下只创建一个实例,并且用两个if判断来提高效率。这样的代码实现起来比较复杂,容易出错,我们还有更加优秀的解法。

强烈推荐的解法一:静态内部类延迟加载

由于volatile关键字可能会屏蔽掉虚拟机中一些必要的代码优化,所以运行效率并不是很高。因此一般建议,没有特别的需要,不要使用。也就是说,虽然可以使用“双重检查加锁”机制来实现线程安全的单例,但并不建议大量采用,可以根据情况来选用。

下面介绍一种静态内部类延迟加载的方式。这种方式综合使用了Java的类级内部类和多线程缺省同步锁的知识,很巧妙的实现了延迟加载和多线程安全。

在看具体实现前,我们先来回顾一下基础知识。

1. 什么是类级内部类

简单点说,类级内部类指的是,有static修饰的成员式内部类。如果没有static修饰的成员式内部类被称为对象级内部类。

类级内部类相当于其外部类的static成分,它的对象与外部类对象间不存在依赖关系,因此可直接创建。而对象级内部类的实例,是绑定在外部对象实例中的。

类级内部类中,可以定义静态的方法。在静态方法中只能够引用外部类中的静态成员方法或者静态成员变量。

类级内部类相当于其外部类的成员,只有在第一次被使用的时候才被会装载。

2. 多线程缺省同步锁的知识

大家都知道,在多线程开发中,为了解决并发问题,主要是通过使用synchronized来加互斥锁进行同步控制。但是在某些情况中,JVM已经隐含地执行了同步,这些情况下就不用自己再来进行同步控制了。这些情况包括:

  • 由静态初始化器(在静态字段上或static{}块中的初始化器)初始化数据时
  • 访问final字段时
  • 在创建线程之前创建对象时
  • 线程可以看见它将要处理的对象时

要想很简单地实现线程安全,可以采用静态初始化器,它可以由JVM来保证线程安全。但是这样一来会浪费一定的空间,因为在类装载的时候就会初始化对象,不管你需不需要。

如果有一种方式能让类装载的时候不要初始化对象,就可以解决空间浪费的问题了。一种可行的解决方式就是采用类级内部类,在这个类级内部类里面去创建对象实例。这样一来,只要不使用到这个类级内部类,那就不会创建对象实例,从而同时实现延迟加载和线程安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Singleton4 {
private Singleton4() {
}

/**
* 类级的内部类,也就是静态的成员式内部类,该内部类的实例与外部类的实例
* 没有绑定关系,而且只有被调用到时才会装载,从而实现了延迟加载。
*/
private static class SingletonHolder {
/**
* 静态初始化器,由JVM来保证线程安全
*/
private static Singleton4 instance = new Singleton4();
}

public static Singleton4 getInstance() {
return SingletonHolder.instance;
}
}

getInstance方法第一次被调用的时候,它第一次读取SingletonHolder.instance,导致SingletonHolder类得到初始化;而这个类在装载并被初始化的时候,会初始化它的静态域,从而创建Singleton4的实例,由于是静态的域,因此只会在虚拟机装载类的时候初始化一次,并由虚拟机来保证它的线程安全性。

这个模式的优势在于,getInstance方法并没有被同步,并且只是执行一个域的访问,因此延迟初始化并没有增加任何访问成本。

强烈推荐的解法二:枚举

单元素的枚举类型已经成为实现Singleton的最佳方法。用枚举来实现单例非常简单,只需要编写一个包含单个元素的枚举类型即可。

1
2
3
4
5
6
7
8
9
10
11
12
public enum Singleton5 {
/**
* 定义一个枚举的元素,它就代表了Singleton的一个实例。
*/
INSTANCE;

/**
* 单例可以有自己的操作
*/
public void operation() {
}
}

使用枚举来实现单实例控制会更加简洁,而且无偿地提供了序列化机制,并由JVM从根本上提供保障,绝对防止多次实例化,是更简洁、高效、安全的实现单例的方式。