前言
单例模式(singleton
)下的类最多只能生成一个实例。单例模式有很多实际用途,比如很多工厂类,生成多个实例是没有意义的。在Spring IOC
容器下管理的Bean
,很多时候也只有一个实例。单例模式的写法有很多,但这些写法中很多并不是完美的。(这里完美这个概念取决于具体的应用场景,合适的才是的完美的)。本文介绍几种常见的单例模式实现以及它们对应的优缺点。
一、朴素版
Singleton01.java
public class Singleton01 {
private static final Singleton01 INSTANCE = new Singleton01();
private Singleton01(){}
public static Singleton01 getInstance() {
return INSTANCE;
}
}
上面的 Singleton01.java
给出了一个最朴素的单列模式实现,简单好理解。
- 通过将构造方法私有化,使得除了这个类自身以外的其他类都无法调用构造方法来生成该类的实例对象。
- 设置一个私有的类变量,当类加载到内存中时创建一个该类的唯一实例,其他类要是想获取该类的实例可以通过
getInstance()
方法来获取这个类变量。- 由于一个类只会被加载的内存中一次,因此这个方法虽然简单,但却是线程安全的。
实验验证一下该单例模式是否起作用。
public class SingletonTest {
public static void main(String[] args) {
Singleton01 s1 = Singleton01.getInstance();
Singleton01 s2 = Singleton01.getInstance();
System.out.println(s1 == s2);
}
}
实验结果可以发现每次get
到的确实是同一个实例。
true
Process finished with exit code 0
这个方法可以满足绝大多数的应用场景,但缺点是这个类的实例化的过程是在该类被加载到内存中时自动完成的,因此就有人提出了能否当真正需要用到这个单例的时候再实例化呢?于是提出了懒加载的单例模式。(虽然这个理由可以勉强算做缺点,但是Singleton01
仍然是一个容易记忆的好方法,推荐使用。)
二、 懒加载版单例模式
Singleton02.java
public class Singleton02 {
private static Singleton02 INSTANCE;
private Singleton02(){}
public static Singleton02 getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton02();
}
return INSTANCE;
}
}
懒加载版本的核心是在获取单例的getInstance()
方法中,只有指向单例的私有类变量INSTANCE
为空时(即第一次调用getInstance()
方法),才会调用私有的构造方法创建实例,之后再次调用getInstance()
方法时则会直接返回已经创建好的实例。但是这个方法有一个很严重的问题,它不是线程安全的,也就是说在多线程的环境下即有可能构造出这个类的多个实例,而达不到单例模式的效果,为什么会这样呢?让我们考虑如下线程A
和线程B
的工作时序图。

- 线程A在发现
INSTANCE
为null
时准备创建一个对象实例赋值给INSTANCE
- 在线程A创建好实例赋值给
INSTANCE
之前,线程B也发现INSTACNE = null
,于是也开始创建一个对象实例。- 当线程
A
和B
运行结束,一共创建了2
个实例对象,破坏了单例模式。
让我们用代码模拟一下这个过程,修改Singleton2.java
的代码,使其在创建实例前稍作停顿。
public class Singleton02 {
private static Singleton02 INSTANCE;
private Singleton02(){}
public static Singleton02 getInstance() {
if (INSTANCE == null) {
try {
Thread.sleep(2);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
INSTANCE = new Singleton02();
}
return INSTANCE;
}
}
测试代码
public class SingletonTest {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(()->{
System.out.println(Singleton02.getInstance());
}).start();
}
}
}
输出结果
designpattern.Singleton02@24d66a78
designpattern.Singleton02@46ca3f1f
designpattern.Singleton02@36c7f27
designpattern.Singleton02@579ade96
designpattern.Singleton02@619d7c8
designpattern.Singleton02@10bfa816
designpattern.Singleton02@36c7f27
designpattern.Singleton02@6447497b
designpattern.Singleton02@6bce6bde
designpattern.Singleton02@7443edb9
可以发现,10
个线程并发的获取单例,但是显然返回的不是同一个实例。
那么既然是一个并发错误,我们可以通过加锁的方式来解决,比如给getInstance
方法加上synchronized
关键字修饰。
- 利用加锁的方式解决上述并发错误
public class Singleton02 {
private static Singleton02 INSTANCE;
private Singleton02(){}
public synchronized static Singleton02 getInstance() {
if (INSTANCE == null) {
try {
Thread.sleep(2);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
INSTANCE = new Singleton02();
}
return INSTANCE;
}
}
- 实验结果
designpattern.Singleton02@36c7f27
designpattern.Singleton02@36c7f27
designpattern.Singleton02@36c7f27
designpattern.Singleton02@36c7f27
designpattern.Singleton02@36c7f27
designpattern.Singleton02@36c7f27
designpattern.Singleton02@36c7f27
designpattern.Singleton02@36c7f27
designpattern.Singleton02@36c7f27
designpattern.Singleton02@36c7f27
现在同一个时刻只有一个线程能拿到getInstance()
方法上的锁,从而不会创建处多个实例。这里解决了并发错误,却引入了一个可用性问题。创建实例的过程需要加锁,但是获取实例的时候是不需要加锁的,采用这种方式的话,多个线程需要排队等待获取一个实例,效率很低。 为了解决这个问题,我们要去试着缩小同步代码块的范围以进行优化。
- 修改代码缩小同步代码块的范围(错误)
public class Singleton02 {
private static Singleton02 INSTANCE;
private Singleton02(){}
public static Singleton02 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton02.class) {
try {
Thread.sleep(2);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
INSTANCE = new Singleton02();
}
}
return INSTANCE;
}
}
这样子只有在创建实例的过程需要同步,获取已创建实例的过程是不需要同步的。但是这个方法却还是错误,让我们再回头看看之前的时序图,当一个线程获取了同步代码块上的锁的时候,它仍然认为INSTANCE == null
是成立的,即便另一个线程已经创建好实例并赋值给INSTANCE
了,所以这里我们需要再进行一次校验,也就是我们常说的DCL
(双重校验锁)。
三、DCL
实现线程安全的单例模式
基于上面的分析,我们可以给出DCL
实现单例模式的代码
Singleton03.java
public class Singleton03 {
private static Singleton03 INSTANCE;
private Singleton03(){}
public static Singleton03 getInstance() {
// 第一次校验
if (INSTANCE == null) {
synchronized (Singleton03.class) {
// 第二次校验
if (INSTANCE == null) {
INSTANCE = new Singleton03();
}
}
}
return INSTANCE;
}
}
但事实是以上的代码还是存在瑕疵,因为涉及到指令重排问题,指令重排其实是jvm
进行的一种优化,例如我们有三条指令A
、B
、C
顺序执行,如果A
和B
的执行顺序对最终的结果不产生影响,并且颠倒次序后某种程度上可以优化程序执行速度,那么jvm
会在底层将其优化成B
、A
、C
的顺序执行。但是有时候我们却不希望发生指令重排,因为这种指令重排在多线程下往往会引起一些意向不到的错误。比如我们上面实现的这个DCL
的单例模式、虽然INSTANCE = new Singleton03();
在我们看来只有一行代码,在jvm
的角度看来却是多条指令组合而成,假设指令如下:
指令1. 首先在堆上划出一分空间
指令2. 对这片内存空间进行初始化,赋予一些初始值
指令3. 把指向这片内存空间的地址赋给INSTANCE
变量。
这样顺序执行是没问题的,在第三条指令执行前,INSTANCE
都等于NULL
。
其实仔细想想,我们先把内存空间地址赋值给INSTANCE
,再对其进行初始化,本质上最终结果都是一样的。那么jvm
就有可能通过指令重排把指令3
和指令2
的顺序颠倒。但是这时候就会出问题了,如果指令3
执行结束,INSTANCE
已经不为null
了,但是由于指令2
还未执行,所以没有进行初始化,若此时正好有另一个线程调用getInstance()
方法,就会获得一个还未初始化完成的实例,从而发生错误。为了解决这个问题,我们需要给INSTANCE
这个变量加上volatile
关键字,防止指令重排。
- 正确的写法
public class Singleton03 {
private static volatile Singleton03 INSTANCE;
private Singleton03(){}
public static Singleton03 getInstance() {
// 第一次校验
if (INSTANCE == null) {
synchronized (Singleton03.class) {
// 第二次校验
if (INSTANCE == null) {
INSTANCE = new Singleton03();
}
}
}
return INSTANCE;
}
}
网友评论