设计模式系列一之单例模式(Singleton)

概述

学习设计模式的时候,经常会以单例模式作为第一个案例来分析,因为这个模式比较简单,容易理解。但其实一旦牵涉到并发的时候,单例模式也往往很多问题。所以需要系统的梳理一下。

单例模式是一种创建型模式,在Java应用中,单例对象必须保证在一个Java虚拟机中,该对象有且只能有一个实例。这个模式有一下几种应用场景(或者说有这么些优点):

  • 某些类的实例对象创建,开销比较大,消耗很多的系统资源;
  • 对于某些频繁使用的对象,减少new操作,避免在Java堆中产生过多实例,降低GC压力;
  • 在某些场景中,多个实例对象会导致系统错乱。类似于一个Company有多个CEO,听谁的。

以上这几种情况就非常适合使用单例模式来解决。

单例模式有一下几个特点:

  • 必须保持只有一个实例;
  • 必须自己创建一个自己的实例(因为构造方式是private的);
  • 必须为其他对象提供唯一的实例(需要提供一个getInstance()方法);

单例模式主要有以下5种方式,逐一分析下。

饿汉式(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
class Singleton {
// 类加载的时候就会初始化,这个过程有且仅有一次
private static final Singleton instance = new Singleton();
// 私有的构造方法
private Singleton() {}
// 对外提供唯一的Singleton实例
public static Singleton getInstance() throws InterruptedException {
return instance;
}
}

这种方式非常简单,因为单例的实例被声明为staticfinal,在JVM第一次加载该单例类到内存中的时候就会初始化【1】,所以创建实例本身是线程安全的。

然而这种方式的缺点也是显而易见的:

  • 这个方式不是懒加载模式(lazy initialization),单例会在第一次加载的时候就被初始化,即使客户端没有调用getInstance()方法。这个时候如果单例类的开销比较大或者时间花费较多,就会降低系统效率。
  • 在一些场景中是无法使用这种方式的:比如Singleton的创建是依赖参数或配置文件的,在getInstance之前必须调用某个方法设置参数给它,那么这种单例就无法使用了。

懒汉式(线程不安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() throws InterruptedException {
if (instance == null) { // a
// 为了加大出现线程问题的概率,我们在这里休眠1秒
Thread.sleep(1000);
instance = new Singleton(); // b
}
return instance;
}
}

上面这段代码简单明了,而且使用了懒加载模式,但是却存在着致命伤。当有多个线程同时运行这段代码的时候,就非常可能创建出多个实例,也就是说这个单例类在并发环境中是无法正确运行的。

我们来分析下出现错误的原因:假设有2个线程同时来到了a处,此时的instance为null。假设线程1先进入if语句块中,但是还没执行b,就交出了CPU使用权。接着线程2获取CPU使用权,进入if语句块,执行了b,生成一个对象,这时候交出了CPU使用权。这时候,线程1接着执行b,又生成了一个对象。而在这2个对象的创建期间,其他类可能调用了getInstance()方法,极大可能调用了第1个对象,然后第2个对象创建后,其他类又调用的是第2个对象。这就严重违反了单例模式的核心功能,所以这种写法是完全不正确的

懒汉式(synchronized)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() throws InterruptedException {
if (instance == null) { // a
// 为了出现线程问题,我们在这里休眠1000毫秒。(当然这里并不出现线程安全问题)
Thread.sleep(1000);
instance = new Singleton(); // b:这里不会释放掉对象锁,所以其他线程无法进入
}
return instance;
}
}

为了解决线程安全问题,我们需要使用Java提供的同步机制。为此我们用synchronized关键字来修饰getInstance()方法。

这种方式虽然做到了线程安全,但是却也并不高效。因为每次只能有一个线程调用getInstance()方法,但是明显同步操作只需要在第一次初始化的时候才被需要。为此我们引入了双重校验锁。

懒汉式(双重校验锁Double Checked Locking)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Singleton {
private static Singleton instance;// private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() throws InterruptedException {
if(instance == null){// 第一次校验
synchronized (Singleton.class){// 锁
if(instance == null){// 第二次校验
Thread.sleep(1000);
instance = new Singleton();// a:可加赋值的内存屏障
}
}
}
return instance;
}
}

其实这种方式本质上就是在同步方式的基础上再加一层校验(也就是if语句判断),只不过把同步的方法换成了同步块。

但是这段代码还是可能出现问题,问题出现在a处instance = new Singleton();这句代码并非是一个原子操作【2】,JVM大概做了3件事:

  1. 【分配引用内存】给instance分配内存;
  2. 【分配实例内存】调用Singleton的构造函数来初始化成员变量;
  3. 【引用指向实例地址】将instance引用指向分配的实例内存,这时候instance才不为null;

但是JVM可能存在指令重排序,也就是说上面的2、3顺序是不一定的,最终执行顺序可能是1-2-3,也可能是1-3-2,如果是后者,则在执行完3、2未执行的时候,被线程2夺取了CPU,那么这个时候instance不为null,但是却没有初始化,所以线程2直接就返回了instance,使用的时候就报错了。

为此我们需要把intance声明为volatile,有部分人认为volatile是保证了可见性,也就是保证线程在本地内存中不会存有instance的副本,每次都是去主内存中获取。但其实是不对的,即使是保证了可见性,也还是会出现上面的问题。使用volatile的主要原因是其另外一个特性:禁止指令重排序在volatile变量的赋值操作后,会生成一个内存屏障(在汇编代码上),读操作不会被重排序到内存屏障前。比如上面的例子,取操作必须在执行完1-2-3或1-3-2后,不存在执行到1-3后读取instance的情况。从“先行发生原则”的角度来理解的话,就是对于一个volatile变量的写操作是先行发生与后面对这个变量的读操作(这里的后面指的是时间概念)。

但是特别注意的是,在JDK1.5之前,使用volatile的双重校验锁还是有问题的。因为JDK1.5之前的内存模型是存在缺陷的,即使将变量声明为volatile也不能完全避免重排序,主要是volatile变量前后的代码仍然存在重排序问题。在JDK1.5及之后这个问题得到了修复,可以放心使用。

静态内部类方式

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

静态内部类是最被提倡使用的方式,在《effective java》上也是被推荐的。这种写法仍然使用了JVM本身机制保证了线程安全问题。由于SingletonHolder是私有的,除了getInstance()没有其他方法能够访问它,因此它是懒汉式的。同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本。这种方式其实是懒汉式和饿汉式的结合。

这种方式使用一个私有静态内部类来维护单例的实现,JVM内部机制保证当一个类被加载时,这个类的加载过程是线程互斥的。这样,当我们第一次调用getInstance()方法的时候,JVM能够帮助我们保证INSTANCE只被创建一次,并且会保证把赋值给INSTANCE的内存初始化完毕,这样我们就不用担心上面的问题了。同时该方法也只会在第一次使用的时候使用互斥机制,这样就解决了低性能的问题。

枚举法

在《Effective Java》最后提供了枚举法来实现单例模式,不仅简单,而且保证了线程安全。此外,这个方法还提供了防止序列化和反射创建多个实例的问题,即使面对复杂的序列化和反射攻击(不清楚序列化和反射攻击的,可以看这里 单例模式的破坏)。

单元素的枚举类型已经成为实现Singleton的最佳方法。《Effective Java》

1
2
3
public enum Singleton {
INSTANCE;
}

首先,在枚举类,构造方法自动声明为private,同时每个元素都自动声明为static final,表明元素只能被实例化一次。也就是说,enum里的实例都只会被实例化一次,所以我们INSTANCE也被保证只实例化一次。此外,我们看一下enum的定义:

1
2
public abstract class Enum<E extends Enum<E>>
implements Comparable<E>, Serializable

可以看出,Enum实现了序列化接口,即提供了序列化机制。

枚举是如何防止序列化生成多个实例的

我们知道,反序列化实例化对象是通过ObjectInputStreamreadObject方法实现的,其中枚举的调用链是:

readObject -> readObject0 -> readEnum -> checkResolve

我们重点来看readEnum方法,部分实现如下:

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
private Enum<?> readEnum(boolean unshared) throws IOException {
...
String name = readString(false);
Enum<?> result = null;
Class<?> cl = desc.forClass();
if (cl != null) {
try {
@SuppressWarnings("unchecked")
Enum<?> en = Enum.valueOf((Class)cl, name);
result = en;
} catch (IllegalArgumentException ex) {
throw (IOException) new InvalidObjectException(
"enum constant " + name + " does not exist in " +
cl).initCause(ex);
}
if (!unshared) {
handles.setObject(enumHandle, result);
}
}
handles.finish(enumHandle);
passHandle = enumHandle;
return result;
}

看第10行代码Enum<?> en = Enum.valueOf((Class)cl, name);,可以看出是通过Enum.valeOf来获取枚举类对象的,那我们去看一下这个方法。

1
2
3
4
5
6
7
8
9
10
public static <T extends Enum<T>> T valueOf(Class<T> enumType,
String name) {
T result = enumType.enumConstantDirectory().get(name);
if (result != null)
return result;
if (name == null)
throw new NullPointerException("Name is null");
throw new IllegalArgumentException(
"No enum constant " + enumType.getCanonicalName() + "." + name);
}

实际上是通过调用enumType(Class对象的引用)的enumConstantDirectory方法获取到一个Map集合,在该集合内存储了以枚举类name为key,枚举实例变量值为value的键值对数据,因此通过name就可以获取到枚举实例。我们再来看下enumConstantDirectory方法的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
Map<String, T> enumConstantDirectory() {
if (enumConstantDirectory == null) {
T[] universe = getEnumConstantsShared();
if (universe == null)
throw new IllegalArgumentException(
getName() + " is not an enum type");
Map<String, T> m = new HashMap<>(2 * universe.length);
for (T constant : universe)
m.put(((Enum<?>)constant).name(), constant);
enumConstantDirectory = m;
}
return enumConstantDirectory;
}

getEnumConstantsShared方法最终利用反射调用枚举类的values方法来获取的枚举类实例数组,然后再以键值对的方式存储到Map集合中去。

到这里,我们的确可以看出来,枚举类反序列化的确不会重新创建实例,JVM保证了每个枚举类实例变量的唯一性。

枚举是如何防止反射生成多个实例的

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) throws Exception {
// 常规方法获取实例
Singleton commonSingleton = Singleton.getInstance();
// 通过反射获取实例
Constructor constructor = Singleton.class.getDeclaredConstructor(String.class, int.class);
constructor.setAccessible(true);
Singleton reflectionSingleton = (Singleton) constructor.newInstance();
// 这里会输出: false,证明两个实例不一样,单例模式被破坏了
System.out.println(reflectionSingleton == commonSingleton);
}

我们知道,反射破坏单例主要是通过利用调用私有构造器来实现的,当用反射尝试实例化枚举类的时候,会抛出下面的异常:

1
2
3
Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
at com.github.archerda.designpattern.singleton.destory.reflection.BreakingSingleton.main(BreakingSingleton.java:23)

显然,异常告诉我们,不能通过反射实例化枚举类,为什么呢?我们来看下newInstance的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@CallerSensitive
public T newInstance(Object ... initargs)
throws InstantiationException, IllegalAccessException,
IllegalArgumentException, InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, null, modifiers);
}
}
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");
ConstructorAccessor ca = constructorAccessor; // read volatile
if (ca == null) {
ca = acquireConstructorAccessor();
}
@SuppressWarnings("unchecked")
T inst = (T) ca.newInstance(initargs);
return inst;
}

看第12行,很显然了。

总结

  • 常见的5种单例模式实现方式:

    • 主要:
      • 饿汉式:线程安全,调用效率高,不能延迟加载;
      • 懒汉式:线程安全,调用效率低,但是可以延迟加载;
    • 其他:
      • 双重检验锁:JDK1.5之前底层内部模型有问题,不建议使用;
      • 静态内部类式:线程安全,调用效率高,可以延迟加载;
      • 枚举式:线程安全,调用效率高,不能延迟加载,但是可以天然防止反射和序列化漏洞;
  • 如何选用:

    • 单例对象占用资源少,不需要延迟加载:枚举式 > 饿汉式
    • 单例对象占用资源大,需要延迟加载:静态内部式 > 懒汉式

在JDK中的应用

java.lang.Runtime

Runtime类封装了Java运行时的环境。因为Java程序实际是启动了一个JVM进程,而每个JVM进程都对应一个Runtime实例,此实例是由JVM来实例化的。每个Java程序都有一个Runtime实例,使应用程序能够与其运行的环境交互。

由于Java进程是单进程的,所以在一个JVM中,Runtime实例应该只要一个。所以应该用单例模式来实现。

1
2
3
4
5
6
7
8
9
10
11
public class Runtime {
private static Runtime currentRuntime = new Runtime();
public static Runtime getRuntime() {
return currentRuntime;
}
private Runtime() {}
....
}

以上代码为JDK中Runtime代码的部分实现,明显,这是饿汉式的实现方式。在该类被ClassLoader第一次加载的时候被创建。

在Spring中的应用

Spring单例Bean与单例模式的区别在于它们关联的环境不一样,单例模式是指在一个JVM进程中仅有一个实例,而Spring单例是指一个SpringBean容器(ApplicationContext)中仅有一个实例,所以如果一个JVM进程中有多个IoC容器,即使是单例Bean,也会有多个实例,比如:

1
2
3
4
5
6
<!-- 即使声明了为单例,只要有多个容器,也一定会创建多个实例 -->
<bean id="person" class="com.github.archerda.Person" scope="singleton">
<constructor-arg name="username">
<value>archerda</value>
</constructor-arg>
</bean>

1
2
3
4
5
6
7
8
// 第一个Spring Bean容器
ApplicationContext context1 = new FileSystemXmlApplicationContext("classpath:/ApplicationContext.xml");
Person person1 = context1.getBean("person", Person.class);
// 第二个Spring Bean容器
ApplicationContext context2 = new FileSystemXmlApplicationContext("classpath:/ApplicationContext.xml");
Person person2 = context2.getBean("person", Person.class);
// 这里绝对不会相等,因为创建了多个实例
System.out.println(person1 == person2);

疑问

【1】如果是final的话,应该是静态绑定的,在编译期间就可以确定,参考使用父类常量不会触发定义常量的类的初始化
【2】这与Java 内存模型中提到的synchroized保证了原子性有冲突?


参考文档