文章目录
在 上一篇文章中,我们学习了单例模式的 饿汉式 (线程安全但可能浪费内存)和 懒汉式 (延迟加载但线程不安全)。
本篇文章将重点讨论如何在保证延迟加载 的同时,解决多线程并发 下的安全问题,并最终引出经典的双重检查锁定(Double-Checked Locking) 方案。
1. 懒汉式的线程安全问题回顾
我们在上一篇中提到的普通懒汉式写法如下:
java
public static Singleton getInstance() {
if (instance == null) {
// 多线程环境下,可能多个线程同时进入这里
instance = new Singleton();
}
return instance;
}
问题分析 :
假如线程 A 进入了 if (instance == null) 判断语句块,但还没有执行 new 操作;此时 CPU 切换到线程 B,线程 B 也进行了 if 判断,发现 instance 依然为 null,于是线程 B 创建了一个实例。接着线程 A 获得执行权,继续执行,又创建了一个实例。
这就导致了产生了多个实例,违背了单例模式的初衷。
2. 解决思路一:同步方法
说白了就是加锁
为了解决线程不安全问题,最直观的方法就是给 getInstance 方法加上锁。
代码实现
java
class Singleton {
private static Singleton instance;
private Singleton() {}
// 在静态方法上加入 synchronized 关键字
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
优缺点分析
- 优点:解决了线程不安全问题。
- 缺点 :效率太低 。
synchronized会锁住整个方法 。这意味着,无论实例是否已经被创建,每个线程在调用getInstance()时都需要进行同步排队。- 实际上,我们只需要在第一次创建实例时保证同步,一旦实例创建成功,后续的获取操作应该是直接读取,不需要同步。这种写法导致每次读取都加锁,严重影响性能。
3. 解决思路二:同步代码块
试图降低锁粒度
为了提高效率,有人可能会想到:既然锁整个方法效率低,那我只锁住创建实例的那部分代码行不行?
错误代码示例
java
public static Singleton getInstance() {
if (instance == null) {
// 试图只锁住创建代码块
synchronized (Singleton.class) {
instance = new Singleton();
}
}
return instance;
}
问题分析
这种写法并不能起到线程同步的作用 。
和最开始的懒汉式问题一样:假如线程 A 刚通过 if (instance == null),还没来得及进入 synchronized 块,线程 B 也通过了 if 判断。
此时,虽然两个线程会排队进入 synchronized 块,但它们都会 执行 new Singleton(),最终还是会创建两个实例。
4. 终极方案:双重检查锁定
为了既能保证线程安全,又能保证效率(实现懒加载),我们采用了双重检查(Double-Check) 的概念。
核心逻辑
- 第一次检查 :在
synchronized块外面检查instance == null。如果实例已经存在,直接返回,避免进入同步块,提升效率。 - 加锁:只有当实例不存在时,才进入同步块。
- 第二次检查 :在
synchronized块内部再次检查instance == null。这是为了防止在多线程环境下,有其他线程抢先完成了实例化。
关键点:volatile 关键字
在双重检查模式中,必须使用 volatile 关键字修饰实例变量。
private static volatile Singleton instance;
为什么要用 volatile?
instance = new Singleton();这行代码在 JVM 中并不是一个原子操作,它大致分为三步:
- 给 instance 分配内存空间。
- 调用构造函数,初始化对象。
- 将 instance 引用指向分配的内存地址(执行完这步 instance 就不为 null 了)。
由于 JVM 的指令重排序 优化,步骤 2 和 3 的顺序可能会颠倒。如果线程 A 执行了步骤 1 和 3(此时 instance 非空但未初始化),线程 B 抢占 CPU,执行第一次检查发现 instance 不为 null,直接返回了这个未初始化完全 的对象,导致程序出错。
volatile 关键字可以禁止指令重排序,通过保障该变量对于线程之间的可见性保证线程安全。
完整代码实现
java
class Singleton {
// volatile 保证可见性和禁止指令重排序
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
// 第一次检查:如果已经创建,直接返回,提高效率
if (instance == null) {
synchronized (Singleton.class) {
// 第二次检查:防止多个线程并发通过了第一次检查
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
优缺点分析
- 线程安全 :通过
synchronized和双重判断保证。 - 延迟加载:实例在第一次调用时才创建。
- 效率高 :大部分时候只需要进行一次
if判断,无需加锁。
5. 总结
在实际开发中,如果需要实现单例模式:
- 如果对内存要求不严格,且确定该实例一定会被用到,推荐使用饿汉式(简单、安全)。
- 如果需要懒加载,且涉及多线程环境,推荐使用双重检查锁定 (DCL)。