设计模式-单例模式

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。

这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。

主要解决:一个全局使用的类频繁地创建与销毁。

何时使用:当您想控制实例数目,节省系统资源的时候。

如何解决:判断系统是否已经有这个单例,如果有则返回,如果没有则创建。

关键代码:构造函数是私有的

单例模式的 UML 图

实现

实现单例模式时,一定要注意因为多线程同时构造可能出现的实例多次初始化,最简单的方式是每次加锁,但是效率低。

1.使用静态变量

1
2
3
4
5
6
7
public class Singleton {  
private static Singleton instance = new Singleton();
private Singleton (){}
public static Singleton getInstance() {
return instance;
}
}

特点: 在类加载时就初始化了,保证后续不会有线程安全问题,但是是非Lazy加载模式。即使getInstance不被调用,这个类也可能也会被加载。

2.使用DCL(double check locking)做lazy初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {  
private volatile static Singleton singleton;
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}

3.使用静态内部类做lazy初始化

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

跟第一种不同的是,加载Singleton的时候,并不一定会调用初始化方法,只有调用getInstance的时候,加载SingletonHolder,才会初始化,而类加载机制是线程安全的,所以这个也是lazy模式,跟DCL效果是一样的。

4.使用枚举

单例的枚举实现在《Effective Java》中有提到,因为其功能完整、使用简洁、无偿地提供了序列化机制、在面对复杂的序列化或者反射攻击时仍然可以绝对防止多次实例化等优点,单元素的枚举类型被作者认为是实现Singleton的最佳方法。

其实现非常简单,如下:

1
2
3
4
public enum Singleton {
INSTANCE;
private Singleton() {}
}

下面我们用一个枚举实现单个数据源例子来简单验证一下:
声明一个枚举,用于获取数据库连接。

1
2
3
4
5
6
7
8
9
10
public enum DataSourceEnum {
DATASOURCE;
private DBConnection connection = null;
private DataSourceEnum() {
connection = new DBConnection();
}
public DBConnection getConnection() {
return connection;
}
}

看一下是否是真的单例

1
2
3
4
5
6
7
public class Main {
public static void main(String[] args) {
DBConnection con1 = DataSourceEnum.DATASOURCE.getConnection();
DBConnection con2 = DataSourceEnum.DATASOURCE.getConnection();
System.out.println(con1 == con2);
}
}

两个是一样的。

在JDK5 中提供了大量的语法糖,枚举就是其中一种。
所谓 语法糖(Syntactic Sugar),也称糖衣语法,是由英国计算机学家 Peter.J.Landin 发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是但是更方便程序员使用。只是在编译器上做了手脚,却没有提供对应的指令集来处理它。

就拿枚举来说,其实Enum就是一个普通的类,它继承自java.lang.Enum类。

1
2
3
public enum DataSourceEnum {
DATASOURCE;
}

把上面枚举编译后的字节码反编译,得到的代码如下:

1
2
3
4
5
6
7

public final class DataSourceEnum extends Enum<DataSourceEnum> {
public static final DataSourceEnum DATASOURCE;
public static DataSourceEnum[] values();
public static DataSourceEnum valueOf(String s);
static {};
}

由反编译后的代码可知,DATASOURCE 被声明为 static 的,根据在【单例深思】饿汉式与类加载 中所描述的类加载过程,可以知道虚拟机会保证一个类的() 方法在多线程环境中被正确的加锁、同步。所以,枚举实现是在实例化时是线程安全。

接下来看看序列化问题:

Java规范中规定,每一个枚举类型极其定义的枚举变量在JVM中都是唯一的,因此在枚举类型的序列化和反序列化上,Java做了特殊的规定。
在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过 java.lang.Enum 的 valueOf() 方法来根据名字查找枚举对象。
也就是说,以下面枚举为例,序列化的时候只将 DATASOURCE 这个名称输出,反序列化的时候再通过这个名称,查找对于的枚举类型,因此反序列化后的实例也会和之前被序列化的对象实例相同。

由此可知,枚举天生保证序列化单例。

DCL方式存在的问题以及解决方案

我们看前面的DCL模式,静态变量前面加了一个volatile, 我们来分析一下为什么要加volatile。

singleton = new Singleton();

这个语句其实可以分成三段:

1
2
3
memory = allocate(); //分配内存用于初始化
init(memory) ; //构建对象
singleton = memory; //把memory赋值给singleton

实际上,JVM会对指令做重排。我们看看上面语句对应的ByteCode,

1
2
3
4
5
6
7
8
9
10: monitorenter
11: getstatic #31 // Field instance:Lcom/cmbc/tools/DCL_Singleton;
14: ifnonnull 27
17: new #1 // class com/cmbc/tools/DCL_Singleton
20: dup
21: invokespecial #33 // Method "<init>":()V
24: putstatic #31 // Field instance:Lcom/cmbc/tools/DCL_Singleton;
27: aload_0
28: monitorexit

首先,是monitorenter,进入同步块,然后拿到static字段,编号为31的,也就是instance,接下下来判断是否是为null,如果不是就跳到27行,否则继续往下走。

接下来就new了一个对象,然后这个dup是复制了一份对象的this指针,供下面的init使用。接着就调用init方法初始化这个对象。然后再用putstatic赋值。如果发生了重排,可能在init方法执行完成之前,putstatic就执行了,导致其他线程看到instance不为null,这样它就直接使用了,但是可能会出问题。

img

这些语句是在单个线程里执行的,重排的意义在哪呢? 实际上单个CPU执行指令的时候,也并不是完全串行的,而是流水线式的。

img

一条指令由多个更小的阶段组成,这些小阶段是可以并行的。 但是如果两个指令有依赖关系,那么就必须等前一个所有的阶段都执行结束再来执行。所以假设abc三条指令在代码中是串行的,而c依赖a,c不依赖b,那么a不必等b执行完就能执行,甚至可以早于b执行。假设一条指令分4个阶段,也就是4个时钟周期,那么完全串行需要12个时钟周期,但是b和c可以有重叠的时钟周期,那只需要9个时钟周期就能执行完。

如何防止重排呢? 那就是在变量前面加上volatile进行修饰,对于volatile修饰的变量,对其的操作指令都不会被重排,具体原理不再赘述。 但是,访问一个volatile的对象,每次都要去主内存取,所以性能会差一点。 因此单例模式最好使用静态内部类或者枚举方式实现。

实际上,这种问题在99%的情况下是没问题的,也很难复现,只是说研究细节的话可能会出现这个问题。

JDK中单例模式的例子

Jdk中最常见的一个例子就是Runtime,它用了饿汉式初始化方法,加载类的时候就初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Runtime {
private static Runtime currentRuntime = new Runtime();

/**
* Returns the runtime object associated with the current Java application.
* Most of the methods of class <code>Runtime</code> are instance
* methods and must be invoked with respect to the current runtime object.
*
* @return the <code>Runtime</code> object associated with the current
* Java application.
*/
public static Runtime getRuntime() {
return currentRuntime;
}

/** Don't let anyone else instantiate this class */
private Runtime() {}