1. 定义及特点

单例模式是一种创建型设计模式,它确保一个类只有一个实例,并提供了一个全局访问点来访问该实例。

意图:确保一个类只有一个实例,并提供一个全局访问点来访问该实例。

主要解决:频繁创建和销毁全局使用的类实例的问题。

何时使用:当需要控制实例数目,节省系统资源时。

如何解决:检查系统是否已经存在该单例,如果存在则返回该实例;如果不存在则创建一个新实例。

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

优点

  • 内存中只有一个实例,减少内存开销,尤其是频繁创建和销毁实例时(如管理学院首页页面缓存)。
  • 避免资源的多重占用(如写文件操作)。

缺点

  • 没有接口,不能继承。
  • 与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心实例化方式。

2. 适用场景

  • 生成唯一序列号。
  • WEB 中的计数器,避免每次刷新都在数据库中增加计数,先缓存起来。
  • 创建消耗资源过多的对象,如 I/O 与数据库连接等。

3. 饿汉式与懒汉式

  • 饿汉式:类加载就会导致该单实例对象被创建。
  • 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时被创建。

3.1 饿汉式

饿汉式简单实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* @author 木又枯了
* @since 2024/9/23 10:27
* <p>
* 饿汉式单例
**/
public class Singleton {

// 构造方法 private 化
private Singleton() {
}

//在该类中创建一个该类的对象供外界去使用
private static Singleton instance = new Singleton();

// 得到 Singleton 的实例(唯一途径)
public static Singleton getInstance() {
return instance;
}

}

这种写法比较简单,就是在类装载的时候就完成实例化。但是也有一个显然的缺点:类加载时就初始化,如果没有使用到这个类,就会浪费内存

那有没有一种方法让类加载时不进行实例化,只有用的时候才实例化呢?当然有,这时候就需要使用懒汉式了。

3.2 懒汉式

懒汉式简单实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @author 木又枯了
* @since 2024/9/23 10:32
* <p>
* 懒汉式单例(线程不安全)
**/
public class Singleton {

// 构造方法 private 化
private Singleton() {
}

//在该类中创建一个该类的对象供外界去使用
private static Singleton instance;

// 得到 Singleton 的实例(唯一途径)
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}

}

这种方式是最基本的实现方式,这种实现最大的问题就是不支持多线程,在多线程环境下:

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
29
30
31
32
33
/**
* @author 木又枯了
* @since 2024/9/23 10:32
* <p>
* 懒汉式单例(线程不安全)
**/
public class Singleton {

// 构造方法 private 化
private Singleton() {
System.out.println(Thread.currentThread().getName());
}

//在该类中创建一个该类的对象供外界去使用
private static Singleton instance;

// 得到 Singleton 的实例(唯一途径)
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}

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

}

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

1
2
3
4
Thread-0
Thread-3
Thread-2
Thread-1

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

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

1
2
3
4
5
6
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}

这样就能保证是单例了,这种方式具备很好的 lazy loading,能够在多线程中很好的工作,但是,效率很低。

那有没有一种方式在多线程情况下既能保持是单例,又能保证高性能呢?

我们只需要修改 getInstance() 方法为如下这样即可实现:

1
2
3
4
5
6
7
8
9
10
11
// 双重检查锁定
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}

这就是双重检查锁定(Double-Checked Locking)模式 ,上面的代码已经完美地解决了 **并发安全 **+ 性能低效 问题。

两次判断 null 的作用

  • 第一次:性能优化,避免不必要的同步操作。如果 instance 已经被创建(即instance不为null),那么就没有必要再次进行同步操作来访问它。
  • 第二次:确保线程安全,防止多个线程同时创建实例。如果没有同步块内的第二次判断,就可能存在多个线程同时进入同步块并尝试创建 instance 的情况,从而导致创建了多个实例,违反了单例模式的原则。

3.3 使用volatile防止指令重排

DCL 懒汉式还是存在问题,因为 instance = new Singleton() 创建一个对象不是一个原子性操作,在JVM中会经过三步:

  1. instance 分配内存空间。
  2. 执行构造方法初始化 instance 对象。
  3. instance 指向分配好的内存空间,此时 instance != null

PS:指令重排序(Instruction Reordering)是现代计算机系统中优化性能的一种手段,通过改变语句的执行顺序来提高指令的并行度,从而提高执行效率。简单来说,就是指你在程序中写的代码,在执行时并不一定按照写的顺序。

由于指令重排序,原本是按 1-2-3 顺序的执行可能会变成 1-3-2。线程A在创建单例对象 Singleton 时,如果执行顺序非预期(如1-3-2),即分配内存给 instance(步骤1),然后设置 instance 不为null(步骤3),但在初始化 instance 对象(步骤2)之前,这时候线程B来了,检查到 instance 不为 null 并尝试使用它。由于线程 A 没有执行步骤2, instance 实际上还未完成初始化,其内部属性可能都是 null,这将增加线程B遇到空指针异常的风险。

因此,需要禁止指令重排,即使用 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
29
public class Singleton {

private Singleton() {
System.out.println(Thread.currentThread().getName());
}

// 添加 volatile 关键字
private volatile static Singleton instance;

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

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

}

4. 静态内部类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* @author 木又枯了
* @since 2024/9/23 11:25
* <p>
* 静态内部类
**/
public class Singleton {

private Singleton() {
}

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

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

}

这种方式能达到双检锁方式一样的功效,且实现更简单。

静态内部类同样利用了 classloader 机制来保证初始化 instance 时只有一个线程,而这种方式是 Singleton 类被装载了,instance 不一定被初始化。因为 SingletonHolder 类没有被主动使用,只有通过显式调用 getInstance 方法时,才会显式装载 SingletonHolder 类,从而实例化 instance 。这种方法不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

5. 反射、序列化与反序列化

使用反射、序列化与反序列化,它们俩都可以把单例对象破坏掉(产生多个对象)。

5.1 反射破坏

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

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) throws Exception {
Singleton s1 = Singleton.getInstance();

//获取 Singletion 类的字节码
Class<Singleton> singletonClass = Singleton.class;
//获取无参构造方法,用来创建对象
Constructor con = singletonClass.getDeclaredConstructor();
//由于是private修饰,所以使用暴力破解
con.setAccessible(true);
//创建对象
Singleton s2 = (Singleton) con.newInstance();
System.out.println(s1 == s2);
}

运行结果:

1
2
3
main
main
false

显然创建的两个对象不是同一个,单例模式失效…

解决方法是在构造方法来添加一些限制,如果存在实例,那么就抛出异常,修改私有的构造方法:

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

运行代码,控制台打印:

懒汉式单例模式优化

好像解决问题了?

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

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

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

5.2 序列化与反序列化破坏

依旧还是以懒汉式最后给出的示例代码为基础,先让 Singleton 类实现 Serializable 接口,再对 main() 方法进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) throws Exception {
// 创建输出流
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Singleton.file"));
// 将单例对象写到文件中
oos.writeObject(Singleton.getInstance());
// 从文件中读取单例对象
File file = new File("Singleton.file");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
Singleton newInstance = (Singleton) ois.readObject();
// 判断是否是同一个对象
System.out.println(newInstance == Singleton.getInstance());
}

运行代码后,控制台打印:

1
2
main
false

可以发现创建的两个对象不是同一个,单例模式失效…

实际上对于序列化与反序列化破坏单例模式的问题,主要是通过 readObject() 方法,出现了破坏单例模式的现象,主要是因为这个方法最后会通过反射调用无参数的构造方法创建一个新的对象,从而每次返回的对象都不一致。

那要怎么解决这个问题呢?

readObject() 方法的调用栈的底层方法中有这么两个方法:

hasReadResolveMethod:表示如果实现了 serializable 或者 externalizable 接口的类中包含 readResolve 则返回 true 。

invokeReadResolve :通过反射的方式调用要被反序列化的类的 readResolve 方法。

详细讲解可以看这篇文章:设计模式|序列化、反序列化对单例的破坏、原因分析、解决方案及解析

所以,原理也就清楚了,主要在Singleton中定义 readResolve 方法,并在该方法中指定要返回的对象的生成策略,就可以防止单例被破坏。

Singleton 类里添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton implements Serializable {

// -- skip --

// 防止序列化
private Object readResolve() {
return instance;
}

// -- skip --

}

运行代码测试:

1
2
main
true

成功!

6. 枚举实现

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

《Effective Java》一书作者Joshua Bloch书中提到:单元素的枚举类型已经成为实现 Singleton 的最佳方法。

在这种实现方式中,既可以避免多线程同步问题,还可以防止通过反射和反序列化来重新创建新的对象。

我们先来看看枚举如何实现单例模式的,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* @author 木又枯了
* @since 2024/9/23 12:55
* <p>
* 枚举实现
**/
public enum EnumSingle {
INSTANCE;

public void businessMethod() {
System.out.println("我是一个单例!");
}
}

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);
}
}

运行结果:

1
2
3
1349393271
1349393271
true

哈哈,确实是单例唉!!

再来试试用反射破坏枚举实现的单例模式?修改上面代码的 main() 方法:

1
2
3
4
5
6
public static void main(String[] args) throws Exception {
Constructor<EnumSingle> declaredConstructor = EnumSingle.class.getDeclaredConstructor(String.class, int.class);
declaredConstructor.setAccessible(true);
EnumSingle instance = declaredConstructor.newInstance();
System.out.println(instance.hashCode());
}

运行测试:

反射破环枚举实现的单例模式

会发现 newInstance() 方法直接不让 enum 类型利用反射来创建实例。所以,通过枚举实现单例模式是非常安全的。