封面来源:单例设计模式 (refactoringguru.cn),如有侵权,请联系删除。

1. 什么是单例模式

百度百科是这么说的:

单例模式,属于创建类型的一种常用的软件设计模式。通过单例模式的方法创建的类在当前进程中只有一个实例(根据需要,也有可能一个线程中属于单例,如:仅线程上下文内使用同一个实例)。

菜鸟教程关于单例模式的介绍如下:

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

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

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

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

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

简单来说:单例模式保证一个类仅有一个实例,并提供一个访问它的全局访问点,其关键是构造函数私有的。

2. 饿汉式与懒汉式

2.1 饿汉式

饿汉式的特点:类加载的时候就实例化,并且创建单例对象。

饿汉式是最简单的单例模式,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author 默烦 2020/10/29
* <p>
* 饿汉式单例
*/
public class Hungry {
private Hungry() {

}

private final static Hungry HUNGRY = new Hungry();

public static Hungry getInstance(){
return HUNGRY;
}
}

但是饿汉式也有问题,最主要一点就是可能会浪费资源。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Hungry {

private final byte[] data1 = new byte[1024 * 1024];
private final byte[] data2 = new byte[1024 * 1024];
private final byte[] data3 = new byte[1024 * 1024];
private final byte[] data4 = new byte[1024 * 1024];

private Hungry() {

}

private final static Hungry HUNGRY = new Hungry();

public static Hungry getInstance() {
return HUNGRY;
}
}

上述代码一运行,byte 数组就会被初始化,并放入内存中。但如果我一直没有使用 getInstance() 方法,一直不用 Hungry 类,这不就浪费内存了吗?

有没有一种方法让类加载时不进行实例化,只有用的时候才实例化呢?

2.2 懒汉式

懒汉式的特点:类加载时没有生成单例,只有当第一次调用 getlnstance() 方法时才去创建这个单例。

初始代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @author 默烦 2020/10/29
* <p>
* 懒汉式单例
*/
public class LazyMan {
private LazyMan() {

}

private static LazyMan lazyMan;

public static LazyMan getInstance() {
if (lazyMan == null){
lazyMan = new LazyMan();
}
return lazyMan;
}
}

但是这样的懒汉式有问题,在并发多线程环境下并不是单例的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class LazyMan {
private LazyMan() {
System.out.println(Thread.currentThread().getName() + " ok");
}

private static LazyMan lazyMan;

public static LazyMan getInstance() {
if (lazyMan == null){
lazyMan = new LazyMan();
}
return lazyMan;
}

public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(() -> {
LazyMan.getInstance();
}).start();
}
}
}

运行上述代码后,控制台可能会出现:

多线程下的懒汉式

可以看到出现了两个实例,这显然就不是单例的了。

为了避免这种情况,我们可以对代码加锁,修改 getInstance() 方法:

1
2
3
4
5
6
7
8
9
10
11
// 双重锁定检查
public static LazyMan getInstance() {
if (lazyMan == null) {
synchronized (LazyMan.class) {
if (lazyMan == null){
lazyMan = new LazyMan(); // 不是一个原子性操作
}
}
}
return lazyMan;
}

这就是双重锁定模式(DCL,Double Check Lock)。

早期 JVM 中因为同步的开销巨大,为了降低实现单例模式中同步带来的开销,人们想出了很多技巧,DCL 便是其中一种。

但是 DCL 懒汉式还是有问题,因为:

1
lazyMan = new LazyMan();

不是一个原子性操作。它会进行三个步骤:

1、分配对象内存空间

2、执行构造方法初始化对象

3、把对象指向这个空间,此时 lazyMan != null

由于指令重排,本来是按 123 顺序的执行可能会变成 132。假设这时有一个线程 A 运行了 lazyMan = new LazyMan();,按照 132 的步骤进行执行,又恰好在第三步执行完,第二步还没执行时线程 B 来了,由于这时候 lazyMan != null 就会直接返回 lazyMan。由于线程 A 没有执行第二步,lazyMan 对象还未初始化,内部的属性极有可能都是 null,将增加出现空指针的概率。

同时需要注意:synchronized虽然保证了线程的原子性(即 synchronized 块中的语句要么全部执行,要么一条也不执行),但单条语句编译后形成的指令并不是一个原子操作(即可能该条语句的部分指令未得到执行,就被切换到另一个线程了)。

因此,需要禁止指令重排,即使用 volatile 修饰的变量。对此,优化后的代码为:

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 class LazyMan {
private LazyMan() {
System.out.println(Thread.currentThread().getName() + " ok");
}

// 添加 volatile 关键字
private volatile static LazyMan lazyMan;

// 双重锁定检查
public static LazyMan getInstance() {
if (lazyMan == null) {
synchronized (LazyMan.class) {
if (lazyMan == null){
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}

public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(() -> {
LazyMan.getInstance();
}).start();
}
}
}

两次判断 null 的作用

第一次判断 null 的作用:减少进入同步代码块的次数,提高效率。去掉这次判断并不会影响单例对象的创建,但每次获取对象都需要进入同步代码块中,这会降低代码执行效率。

第二次判断 null 的作用:控制对象实例是单例的。假设线程 A、B 同时执行到同步代码块前,线程 A 先抢到锁,完成对象的创建,然后线程 B 进入同步代码块。如果没有这次 null 值判断,线程 B 又会将一个新的实例赋值给成员变量,这就违背了单例的原则。

3. 使用静态嵌套类实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* @author 默烦 2020/10/29
* <p>
* 静态嵌套类
*/
public class Holder {
private Holder() {

}

public static Holder getInstance() {
return InnerClass.HOLDER;
}

public static class InnerClass {
private static final Holder HOLDER = new Holder();
}
}

使用这种方式也可以保证创建的对象是懒汉单例的,而且还是线程安全的。

静态嵌套类的优点是:外部类加载时并不需要立即加载静态嵌套类,静态嵌套类不被加载则不去初始化 HOLDER,故而不占内存。即当 Holder 第一次被加载时,并不需要去加载 InnerClass,只有当 getInstance() 方法第一次被调用时,才会去初始化 HOLDER,第一次调用 getInstance() 方法会导致虚拟机加载 InnerClass 类,这种方法不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

至于是怎么保证线程安全的,可以参考这篇文章:深入理解单例模式:静态内部类单例原理

4. 强大的反射

反射那是一个相当流弊的东西,它可以无视 private 关键字,也就是说它会破坏封装性。说远了,使用反射就将破坏我们编写的单例模式。

修改懒汉式最后给出的示例代码,对 main() 方法进行修改,利用反射创建两个 LazyMan 对象:

1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) throws Exception {
LazyMan instance = LazyMan.getInstance();
Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor(null);
declaredConstructor.setAccessible(true);

LazyMan lazyMan = declaredConstructor.newInstance();

System.out.println(instance);
System.out.println(lazyMan);
System.out.println(instance == lazyMan); // false
}

运行截图如下:

反射破坏单例模式

哦豁,完犊子。创建的两个对象不是同一个,单例模式失效!

道高一尺,魔高一丈,我们可以在构造方法中加锁并判断,判断成功就抛出异常。修改私有的构造方法:

1
2
3
4
5
6
7
8
private LazyMan() {
synchronized (LazyMan.class) {
if (lazyMan != null) {
throw new RuntimeException("不要试图使用反射破坏单例");
}
}
System.out.println(Thread.currentThread().getName() + " ok");
}

然后再运行:

懒汉式单例模式优化

芜湖,✈️

别得意,看我修改一下 main() 方法中的测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) throws Exception {
// LazyMan instance = LazyMan.getInstance();
Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor(null);
declaredConstructor.setAccessible(true);

LazyMan instance = declaredConstructor.newInstance();
LazyMan lazyMan = declaredConstructor.newInstance();

System.out.println(instance);
System.out.println(lazyMan);
System.out.println(instance == lazyMan); // false
}

再次运行:

反射破坏单例模式

玩完儿咯,还是创建出两个对象。

小事情,添加一个标志位进行判断即可,再次修改私有的构造方法:

1
2
3
4
5
6
7
8
9
10
private LazyMan() {
synchronized (LazyMan.class) {
if (!flag) {
flag = true;
}else {
throw new RuntimeException("不要试图使用反射破坏单例");
}
}
System.out.println(Thread.currentThread().getName() + " ok");
}

再次运行:

懒汉式单例模式优化

诶嘿,成功!💪

不就是引入标志位?照样用反射解决它!修改一下 main() 方法中的测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void main(String[] args) throws Exception {

Field flag = LazyMan.class.getDeclaredField("flag");
flag.setAccessible(true);

Constructor<LazyMan> declaredConstructor = LazyMan.class.getDeclaredConstructor(null);
declaredConstructor.setAccessible(true);

LazyMan instance = declaredConstructor.newInstance();
flag.set(instance, false);

LazyMan lazyMan = declaredConstructor.newInstance();

System.out.println(instance);
System.out.println(lazyMan);
System.out.println(instance == lazyMan); // false
}

运行:

反射破坏标志位

完了,这咋整啊?

点开 newInstance() 的源码,源码中有这样一段判断:

1
2
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");

这段判断就是说不能通过反射创建枚举对象,枚举对象自带单例模式!

5. 枚举

enum 的全称为 enumeration, 是 JDK 1.5 中引入的新特性。

在 Java 中,被 enum 关键字修饰的类型就是枚举类型。形式如下:

1
enum Color { RED, GREEN, BLUE }

明白了基本使用,创建一个枚举测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* @author 默烦 2020/10/29
*/
public enum EnumSingle { // 枚举也是一个类
INSTANCE;

public EnumSingle getInstance() {
return INSTANCE;
}
}


class Test {
public static void main(String[] args) {
EnumSingle instance = EnumSingle.INSTANCE;
EnumSingle instance2 = EnumSingle.INSTANCE;

System.out.println(instance.hashCode());
System.out.println(instance2.hashCode());
System.out.println(instance == instance2);
}
}

运行结果:

枚举的测试

哟嚯?好像还行。

用反射创建枚举试试?修改一下 Test 测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Test {
public static void main(String[] args) throws Exception {
EnumSingle instance = EnumSingle.INSTANCE;
Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(null);
declaredConstructor.setAccessible(true);

EnumSingle enumSingle = declaredConstructor.newInstance();

System.out.println(instance.hashCode());
System.out.println(enumSingle.hashCode());
System.out.println(instance == enumSingle);

}
}

运行后有:

枚举反射NoSuchMethodException异常

出现了一个异常,表示 EnumSingle 类中没有空参构造函数。

但并不是我们在 newInstance() 源码中看到的异常:

1
2
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");

我们反编译看看:

反编译枚举

可以看到,枚举确实是一个类,而且也有空参构造函数。这…

我们需要使用更加专业的反编译工具,比如 JAD。

JAD 下载链接:JAD下载镜像

进入链接后,找到对应的地方进行下载:

JAD镜像下载

下载之后进行解压,找到 jad.exe 并将其放在需要进行反编译的目录:

jad放置在目标文件夹中

在这个目录下打开 cmd,我们需要反编译 EnumSingle.class,因此输入命令:

1
jad -sjava EnumSingle.class

然后会在当前目录下生成反编译后的 Java 文件 EnumSingle.java,打开它:

反编译EnumSingle.java文件

终于看到不再是无参构造函数了,而是有参构造函数,参数类型分别是 Stringint

既然如此,我们修改 Test 测试类中的测试方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Test {
public static void main(String[] args) throws Exception {
EnumSingle instance = EnumSingle.INSTANCE;
// 修改此处
Constructor<EnumSingle> declaredConstructor =
EnumSingle.class.getDeclaredConstructor(String.class, int.class);
declaredConstructor.setAccessible(true);

EnumSingle enumSingle = declaredConstructor.newInstance();

System.out.println(instance.hashCode());
System.out.println(enumSingle.hashCode());
System.out.println(instance == enumSingle);

}
}

再次运行代码:

反射创建枚举异常

所以应该使用枚举来实现真正的单例。