Update 设计模式 - 单例.md

补充双重校验锁指令重排序配图与说明,调整枚举及内部类单例机制描述;补充使用场景示例及与 Spring 单例 Bean 的对比,更新相关链接。
This commit is contained in:
CyC2018
2026-10-30 20:19:42 +08:00
parent da1df55d59
commit c7503b49fb
2 changed files with 26 additions and 19 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

+26 -19
View File
@@ -36,7 +36,7 @@ public class Singleton {
#### 2. 饿汉式-线程安全
线程不安全问题主要是由于 uniqueInstance 被实例化多次,采取直接实例化 uniqueInstance 的方式就不会产生线程不安全问题。
线程不安全问题主要是由于 uniqueInstance 被实例化多次,采取直接实例化 uniqueInstance 的方式就不会产生线程不安全问题:静态变量在类初始化时赋值,类的初始化由 JVM 加锁且只执行一次。
但是直接实例化的方式也丢失了延迟实例化带来的节约资源的好处。
@@ -86,7 +86,7 @@ public class Singleton {
}
```
考虑下面的实现,也就是只使用了一个 if 语句。在 uniqueInstance == null 的情况下,如果两个线程都执行了 if 语句,那么两个线程都会进入 if 语句块内。虽然在 if 语句块内有加锁操作,但是两个线程都会执行 `uniqueInstance = new Singleton();` 这条语句,只是先后的问题,那么就会进行两次实例化。因此必须使用双重校验锁,也就是需要使用两个 if 语句:第一个 if 语句用来避免 uniqueInstance 已经被实例化之后的加锁操作,而第二个 if 语句进行了加锁,所以只能有一个线程进入,就不会出现 uniqueInstance == null 时两个线程同时进行实例化操作。
两个 if 各有用处。第一个 if 让实例创建之后的调用不再加锁;第二个 if 在锁内再判断一次,防止重复创建。如果去掉第二个 if,写成下面这样,两个线程同时通过第一个 if 后,会先后进入同步块,各执行一次 `new Singleton()`:
```java
if (uniqueInstance == null) {
@@ -96,19 +96,21 @@ if (uniqueInstance == null) {
}
```
uniqueInstance 采用 volatile 关键字修饰也是很有必要的, `uniqueInstance = new Singleton();` 这段代码其实是分为三步执行:
uniqueInstance 还必须用 volatile 修饰。`uniqueInstance = new Singleton();` 分为三步:
1. 为对象分配内存空间
2. 初始化对象
3. 将 uniqueInstance 指向分配的内存地址
1. 为对象分配内存空间;
2. 执行构造方法,初始化对象;
3. 将 uniqueInstance 指向分配的内存地址。
但是由于 JVM 具有重排序的特性,执行顺序有可能变成 1>3>2。重排序在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T<sub>1</sub> 执行了 1 和 3,此时 T<sub>2</sub> 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
编译器和处理器可能把它重排序为 1→3→2。单线程下结果不变,多线程下另一个线程可能拿到还没初始化的对象。例如构造方法把 port 设为 8080,T<sub>1</sub> 执行完 1 和 3 时,T<sub>2</sub> 在第一个 if 处看到 uniqueInstance 不为空,不加锁直接返回,读到的 port 还是 0:
使用 volatile 可以禁止 JVM 的重排序,保证在多线程环境下也能正常运行。
<div align="center"> <img src="pics/singleton-dcl-reorder.png" width="780px"/> </div><br>
volatile 禁止了这种重排序:读到 uniqueInstance 不为空的线程,一定也能看到构造方法中的写入。
#### 5. 静态内部类实现
当 Singleton 类被加载时,静态内部类 SingletonHolder 没有被加载进内存。只有当调用 `getUniqueInstance()` 方法从而触发 `SingletonHolder.INSTANCE` 时 SingletonHolder 才会被加载,此时初始化 INSTANCE 实例,并且 JVM 能确保 INSTANCE 只被实例化一次。
Singleton 类初始化时,静态内部类 SingletonHolder 不会随之初始化。第一次调用 `getUniqueInstance()` 访问 `SingletonHolder.INSTANCE` 时,JVM 才初始化 SingletonHolder 并创建 INSTANCE;类的初始化由 JVM 加锁执行且只执行一次,因此 INSTANCE 只会被创建一次。
这种方式不仅具有延迟初始化的好处,而且由 JVM 提供了对线程安全的支持。
@@ -180,22 +182,27 @@ secondName
java.lang.IllegalArgumentException: Cannot reflectively create enum objects
```
该实现可以防止反射攻击。在其它实现中,通过 setAccessible() 方法可以将私有构造函数的访问级别设置为 public,然后调用构造函数从而实例化对象,如果要防止这种攻击,需要在构造函数中添加防止多次实例化的代码。该实现是由 JVM 保证只会实例化一次,因此不会出现上述的反射攻击。
该实现可以防止反射攻击。在其它实现中,调用私有构造函数的 setAccessible(true) 可以绕过访问检查,再调用 newInstance() 创建新的实例;要防止这种攻击,可以在构造函数中检查实例是否已经存在并抛出异常,但这只能挡住实例创建之后的反射调用:懒汉式在第一次调用 getUniqueInstance() 之前被反射创建时,检查不出来。对于枚举类型,Constructor.newInstance() 直接抛出 IllegalArgumentException,因此不会出现上述的反射攻击。
该实现在多次序列化和反序列化之后,不会得到多个实例。而其它实现需要使用 transient 修饰所有字段,并且提供 readResolve() 方法。
该实现在多次序列化和反序列化之后,不会得到多个实例。而其它实现要实现 Serializable,反序列化时每次都会创建一个新对象,需要提供 readResolve() 方法返回已有的实例来替换它;transient 只决定字段是否参与序列化,不能保证实例唯一。
### 示例
- Logger Classes
- Configuration Classes
- Accessing resources in shared mode
- Factories implemented as Singletons
- 日志类;
- 配置类;
- 以共享方式访问的资源,例如连接池;
- 以单例实现的工厂。
这些对象创建代价高或持有共享资源,多个实例只会浪费资源。例如 OkHttp 的每个 OkHttpClient 都自带连接池和线程池,官方建议整个应用共用一个;每次请求都 new 一个,请求量大时会同时留下大量空闲的连接和线程,可能导致 OOM。
需要注意的是,上面线程安全的实现只保证创建过程是线程安全的:实例只创建一次,其它线程拿到的实例已经初始化完成。单例对象被所有线程共享,如果它有可变的字段(例如计数器、缓存的 Map),之后读写这些字段时仍要自己加锁或使用并发容器。
### JDK 中的应用
- [java.lang.Runtime#getRuntime()](http://docs.oracle.com/javase/8/docs/api/java/lang/Runtime.html#getRuntime%28%29)
- [java.awt.Desktop#getDesktop()](http://docs.oracle.com/javase/8/docs/api/java/awt/Desktop.html#getDesktop--)
- [java.lang.System#getSecurityManager()](https://docs.oracle.com/javase/8/docs/api/java/lang/System.html#getSecurityManager--)(JDK 17 起已废弃,JEP 411)
- [java.lang.Runtime#getRuntime()](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Runtime.html#getRuntime())
- [java.awt.Desktop#getDesktop()](https://docs.oracle.com/en/java/javase/21/docs/api/java.desktop/java/awt/Desktop.html#getDesktop())
Spring 默认的单例 Bean 与这里的单例不同:它指的是一个容器中每个 Bean 定义只创建一个对象,由容器缓存后返回;类本身仍可以被 new 出多个实例,同一个类定义成两个 Bean 也会有两个对象,见[Bean 的作用域与线程安全](Spring.md#bean-的作用域与线程安全)。
## 参考资料
- 弗里曼. Head First 设计模式 [M]. 中国电力出版社, 2007.
- Gamma E. 设计模式: 可复用面向对象软件的基础 [M]. 机械工业出版社, 2007.
- Gamma E. 设计模式:可复用面向对象软件的基础 [M]. 机械工业出版社, 2007.