单例模式的实现

单例模式确保一个类只有一个实例,并且为该实例提供全局的访问方式。其实现方式有多种,每种实现方式均有自己的特点。

一个简单的实现

根据单例模式的定义,可以很容易写出如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton {
/**
* 设置为static
*/
private static Singleton instance;

private Singleton() {
}

public static Singleton getInstance() {
// 如果为空则new一个
if (instance == null) {
instance = new Singleton();
}

return instance;
}
}

思路

这段代码比较简单,其思路是使用类的私有静态属性保存类的唯一实例,通过一个静态的方法对 getInstance() 对外暴露该实例。

程序第一次调用 getInstance() 时 instance 为null,此时需要 new 一个实例对象出来,后续都直接返回已有实例,目的是使得每次都获取到同一个实例。

注意代码中将构造函数设置为 private,其目的是使得 Singleton 类只能在类内部初始化,防止创建新的实例。

测试

来段代码测试下看看

1
2
3
4
5
6
7
8
public class TestSingleton {
public static void main(String[] args) {
Singleton singleton1 = Singleton.getInstance();
Singleton singleton2 = Singleton.getInstance();

System.out.println(singleton1 == singleton2);
}
}

输出结果:

1
true

看起来运行的很好。然而这只是单线程的情况,在多线程环境下情况就不那么乐观了。

看下一个并发场景:

  1. 假如说第一个线程 1 执行完第 12 行 if (instance == null) 后让出了cpu,那么它恢复后会执行第 13 行代码

  2. 此时另外一个线程 2 执行到第 12 行,判断结果为 true 后执行了第 13 行创建了一个 Singleton 实例

  3. 线程 1 恢复执行后仍会执行第 13 行去创建一个新的 Singleton 实例

以上的结果会导致创建不止一个 Singleton 实例,这与我们的目标不一致。针对这种情况,可以考虑在存在并发问题的代码处加锁。

并发问题修复

使用 synchronized 锁保证并发的安全性,修改后的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton {
/**
* 设置为static
*/
private static Singleton instance;

private Singleton() {
}

public static synchronized Singleton getInstance() {
// 如果为空则new一个
if (instance == null) {
instance = new Singleton();
}

return instance;
}
}

注意第 10 行加上了 synchronized 修饰符,这相当于使 getInstance() 内的代码变成了临界代码,在并发环境下,多个线程按顺序进入该方法,也就不存在创建多个 Singleton 实例的情况了。

并发的问题解决了,然而又引起了性能的问题。

按照单例模式的定义,单例类仅有一个实例,该类最多初始化一次,也就是说第 13 行只会执行一次,其余情况下都是直接返回 instance。如上加了 synchronized 锁后,所有的请求都必须先获取到锁,然后才能拿到 instance 实例,这势必影响系统性能。

分析导致创建多个实例的原因,关键在于 instance = new Singleton() 这句代码执行了多次,因此可以仅对这个关键位置加锁。

性能问题修复

synchronized 移到 getInstance() 方法内,在 instance = new Singleton() 前加锁。

考虑一种情况:一个线程判断 instance 为 null,并且获取到 synchronized 锁,此时就可以创建 Singleton 实例吗?

答案是不一定。

为什么呢?假如说在该线程获取到 synchronized 锁之前,另外一个线程已经获取过锁并且执行过 instance = new Singleton()了,此时 instance 已经不为 null。因此在 instance = new Singleton() 前需要再次判断下 instance 是否为 null。

这就是著名的双重校验锁(DCL),代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Singleton {
/**
* 设置为static
*/
private static Singleton instance;

private Singleton() {
}

public static Singleton getInstance() {
// 如果为空则new一个
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}

return instance;
}
}

问题似乎解决了,一起看起来很美好!

然而现实是残酷的。为了提高执行性能,JVM和处理器层面均支持指令的重排序,在这里会引起另外一个问题:获取到未初始化的实例。

为了分析问题,先反编译下字节码文件

1
javap -v Singleton.class

如下是节选的部分输出内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public static Singleton getInstance();
descriptor: ()LSingleton;
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=0
0: getstatic #2 // Field instance:LSingleton;
3: ifnonnull 37
6: ldc #3 // class Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field instance:LSingleton;
14: ifnonnull 27
17: new #3 // class Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field instance:LSingleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field instance:LSingleton;
40: areturn
Exception table:

重点关注下如下几行内容,这正是 instance = new Singleton() 对应的JVM指令

1
2
3
17: new           #3                  // class Singleton
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field instance:LSingleton;

这 3 行的含义为

  1. 创建类的实例(未初始化)
  2. 执行实例的初始化方法 <init>
  3. 赋值给静态变量 instance

JVM 重排序后执行顺序可能变为 1 > 3 > 2,这在多线程环境下可能会导致一个线程获取到未初始化的实例。

为了解决这个问题,可以使用 volatile 修饰 instance 以禁止该行代码重排序。

JDK5 and later extends the semantics for volatile so that the system will not allow a write of a volatile to be reordered with respect to any previous read or write, and a read of a volatile cannot be reordered with respect to any following read or write.

JDK5 以后增强了 volatile 的语义:禁止 volatile 写和它之前的任何读/写重排序,禁止 volatile 读和它之后的任何读/写重排序。

未初始化问题修复

修复后的 DCL 代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Singleton {
/**
* 设置为volatile + static
*/
private volatile static Singleton instance;

private Singleton() {
}

public static Singleton getInstance() {
// 如果为空则new一个
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}

return instance;
}
}

这个就是比较常见的 DCL 实现,解决了前面提到的几个问题

另外一种思路

既然初始化实例这里这么麻烦,干脆在加载类的时候直接初始化,利用虚拟机的初始化机制解决并发的问题

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

private Singleton() {
}

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

这种方案是实现简单,不存在并发的问题,缺点可能会导致资源利用率不高的问题:比如说系统未使用到该单例,但是由于某种原因加载了 Singleton 类,那么会导致实例初始化,占用系统资源。

这个就是传说中的饿汉式单例,提前建立单例对象。

静态内部类实现

针对饿汉式的缺点,可以使用内部类来持有实例,在第一次获取实例时加载内部类并且实例化单例对象,以达到延迟初始化的目的,具体代码如下:

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

private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}

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

枚举实现

前面讲到 DCL (也就是懒汉式)、饿汉式、静态内部类共 3 种线程安全的单例实现方式,在实际开发中也比较常见,但这并不代表它们是完美的。

我们知道通过反射可以修改 private 属性值,同样也可以达到访问 private 方法的目的。按照这个思路可以通过反射调用 Singleton 的构造函数创建新的实例,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class TestSingleton {
public static void main(String[] args) throws Exception {
// 通过正常途径获取单例
Singleton singleton1 = Singleton.getInstance();

// 通过反射获取单例对象
Singleton singleton2 = getInstanceByReflect();

System.out.println(singleton1 instanceof Singleton);
System.out.println(singleton2 instanceof Singleton);
System.out.println(singleton1 == singleton2);
}

/**
* 通过反射创建对象
*
* @return
* @throws Exception
*/
private static Singleton getInstanceByReflect() throws Exception {
// 获取 Singleton 的构造函数
Constructor<Singleton> declaredConstructor = Singleton.class.getDeclaredConstructor();

// 设置访问标志
declaredConstructor.setAccessible(true);

// 创建新的对象
Singleton singleton = declaredConstructor.newInstance();

return singleton;
}
}

输出结果:

1
2
3
true
true
false

可见两个对象均是 Singleton 类型,但是却不相等,是独立的两个对象。

使用枚举实现单例,JVM 可以保证不会被反射创建新的实例,并且提供了安全的序列化机制(支持序列化和反序列化,并且反序列化不会导致创建多个实例)。

枚举实现单例的代码如下:

1
2
3
4
5
6
7
public enum  Singleton {
INSTANCE;

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

验证下反序列化是否会存在问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class TestSingleton {
public static void main(String[] args) throws Exception {
// 通过正常途径获取单例
Singleton singleton1 = Singleton.getInstance();

// 输出到文件
ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream("singleton.txt")));
oos.writeObject(singleton1);
oos.flush();

// 从文件读取
ObjectInputStream ois = new ObjectInputStream(new BufferedInputStream(new FileInputStream("singleton.txt")));
Singleton singleton2 = (Singleton) ois.readObject();

System.out.println(singleton1 == singleton2);
}
}

输出结果为:

1
true

这说明反序列化后得到的还是同一个实例

总结

有多种方式可以实现单例模式,常见的有:双重校验锁(DCL)、饿汉式、静态内部类、枚举式等,其中枚举式实现简单、多线程安全、可防止序列化或者反射攻击,是值得推荐的单例实现方法。

参考

秦小波. 设计模式之禅
Joshua Bloch. Effective Java

The “Double-Checked Locking is Broken” Declaration

0%