0%

设计模式专题:单例模式写法缺陷讨论

概述

单例模式可以说是所有设计模式中最简单的,因为它的类图是如此的一目了然。但同时,它可能也是所有设计模式中最为复杂的,因为它的实现方式多种多样,特定的实现方式只能适应特定的场合。

各种实现的异同

虽然该模式实现方式多种多样,但是其实现还是有一些关键共通点的。如下:

  • 构造器私有
  • 提供一个返回实例的类方法

而各种实现不同点主要集中在实例的创建时机和方式上。

实现

最简单的实现

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

public static Singleton getInstance()
{
if(instance == null) // 7
{
instance = new Singleton(); // 9
}

return instance;
}
}

当不涉及多线程时,上面的代码通常是可行的。但在多线程执行时,就可能会出现问题。比如假设场景如下:

有两个线程同时调用 getInstance() 方法,线程一执行完第 7 行代码,即判断完成后,JVM 将 CPU 切换到线程二,由于线程一还没有执行第 9 行代码, 即 instance 还是 null,所以线程二创建了实例。当 CPU 切换回线程一时,线程一会继续第 9 行代码,又创建了一个实例。

这样,问题就出现了——这个单例类就不再是单例了——在多线程中这种实现不适用。

同步方法

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

public synchronized static Singleton getInstance()
{
if(instance == null)
{
instance = new Singleton();
}

return instance;
}
}

获取单例的方法被修改为同步方法了,这样在多线程使用就不再有问题了。

但是,这种实现可能导致性能问题。因为,对于 getInstance() 方法而言,除了第一次创建实例外,其后的调用都仅仅返回已创建的实例(耗时其实很少), 每次调用都进行耗时的方法同步其实是不必要的。

所以,这种写法虽然可以解决多线程的问题,但同时引入了性能问题。

双重检查加锁(DCL)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Singleton
{
private static Singleton instance = null;

public static Singleton getInstance()
{
if(instance == null)
{
synchronized(Singleton.class)
{
if(instance == null)
{
instance = new Singleton(); // 13
}
}
}

return instance;
}
}

双重检查加锁(DCL,Double-Checked Locking),这样写,貌似就同时解决了并发和同步的问题。 方法仅在第一次创建实例时进入同步块,而之后统统在第一个判断时返回了。

上面这段程序,从源代码级别上来看是看不出什么问题的。问题出在第 13 行,看起来 instance = new Singleton(); 只是一句代码, 但实际上它不是一个原子操作(事实上高级语言中非原子操作很多)。查看这句代码在被编译后在 JVM 执行的对应汇编代码会发现, 它被编译成了 8 条汇编指令,大致做了 3 件事:

  1. 给实例分配内存;
  2. 初始化构造器;
  3. instance 对象指向分配的内存空间(注意到这步 instance 就非 null

但是,由于Java编译器允许处理器乱序执行(out-of-order),以及 JDK 1.5 之前 JMM(Java Memory Model)中 Cache、寄存器到主内存回写顺序的规定, 上面第 2 点和第 3 点的顺序是无法保证的,即执行顺序可能是 1-2-3,也可能是 1-3-2。

如果线程一中执行顺序是 1-3-2,当执行完 3 未执行 2 之前,被切换到线程二上,这时 instance 因为已经在线程一内执行过第 3 点,因而是非空了, 所以线程二直接拿走instance,然后使用、报错。

所以,DCL 这种“教科书式的范例”代码实际上是不完全正确的。的确,在一些语言(如 C 语言)上 DCL 是可行的,这取决于是否能保证 2、3 步的顺序。

在 JDK1.5 之后,官方已经注意到这种问题,因此调整了JMM、具体化了 volatile 关键字。因此,如果 JDK 是 1.5 或更高版本, 只需要将 instance 的定义改成 private volatile static Singleton instance = null; 就可以保证每次 instance 都从主内存读取,就可以使用 DCL 的写法来完成单例模式。

当然,volatile 或多或少会影响性能,最重要的是还要考虑老版本的 JDK。

饿汉式

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

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

根据 JLS(Java Language Specification)中的规定,一个类在一个 ClassLoader 中只会被初始化一次,这点是 JVM 本身保证的。所以,这种写法基本上是完美的,不存在并发问题。

但是它是饿汉式的,在 ClassLoader 加载了 Singleton 类后,实例便第一时间被创建。这可能使得这种写法在某些场景中不适用,比如实例创建依赖参数或配置文件; 或者实例会使用比较稀缺的资源,但是真正使用得很少,所以希望“懒加载”。

内部类持有实例

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

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

Lazy initialization holder class,使用 JVM 本身机制保证线程安全问题;并且它还是懒汉式的;同时读取实例时不会进行同步,没有性能问题;也不依赖 JDK 版本。

可以说这种写法基本上算是“完美”、“无可挑剔”的。

确保唯一

以上所有写法我们重点讨论的是它们的线程安全性以及性能问题,而都“故意”忽略了一方面的问题。 仔细观察我们可以发现,以上写法“最多”确保我们通过 getInstance() 方法可以获取到同一个实例,但是并没有阻止其它任何形式来创建实例。

因此,要确保类只能创建单一实例,还要屏蔽外部对类实例的创建。

我们知道,创建实例的方法通常有以下几种:

  1. 直接 new 创建;
  2. 反射创建;
  3. 反序列化创建。

对于第 1 种情况,通常应该把单例类的构造器限制为 privateprotected 的,普遍来说都会限制为最严格的 private。 然而,即使将构造器限制为 private 的,也仅仅只能阻止普通的 new 创建对象。而反射可以使用 setAccessible() 方法突破 private 的限制。 对此,还需要在 ReflectPermission("suppressAccessChecks") 权限下使用安全管理器(SecurityManager)的 checkPermission() 方法来限制这种突破。

另外,如果类实现了 Serializable 接口,则可以通过反序列化来创建对象,则也可能导致实例不唯一。对此,应当同时实现 readResolve() 方法来保证反序列化时也能得到唯一对象。

综上,一个较完善的单例模式应该类似下面这样:

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

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

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

private Object readResolve()
{
return getInstance();
}
}

总结

单例的实现方法很多,不限于上述几种,如使用本地线程(ThreadLocal)来处理并发以及保证一个线程内一个单例的实现、GoF 原始例子中使用注册方式应对单例类需要继承时的实现、 使用指定类加载器去应对多 ClassLoader 环境下的实现等等。

在选择单例实现时,应该考虑应用场景,而非一味追求完美避免过度的复杂设计。