【设计模式笔记18】:并发安全与双重检查锁定的单例模式

文章目录

    • [1. 懒汉式的线程安全问题回顾](#1. 懒汉式的线程安全问题回顾)
    • [2. 解决思路一:同步方法](#2. 解决思路一:同步方法)
    • [3. 解决思路二:同步代码块](#3. 解决思路二:同步代码块)
    • [4. 终极方案:双重检查锁定](#4. 终极方案:双重检查锁定)
    • [5. 总结](#5. 总结)

在 上一篇文章中,我们学习了单例模式的 饿汉式 (线程安全但可能浪费内存)和 懒汉式 (延迟加载但线程不安全)。

本篇文章将重点讨论如何在保证延迟加载 的同时,解决多线程并发 下的安全问题,并最终引出经典的双重检查锁定(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) 的概念。

核心逻辑

  1. 第一次检查 :在 synchronized 块外面检查 instance == null。如果实例已经存在,直接返回,避免进入同步块,提升效率。
  2. 加锁:只有当实例不存在时,才进入同步块。
  3. 第二次检查 :在 synchronized 块内部再次检查 instance == null。这是为了防止在多线程环境下,有其他线程抢先完成了实例化。

关键点:volatile 关键字

在双重检查模式中,必须使用 volatile 关键字修饰实例变量。
private static volatile Singleton instance;

为什么要用 volatile? instance = new Singleton(); 这行代码在 JVM 中并不是一个原子操作,它大致分为三步:

  1. 给 instance 分配内存空间。
  2. 调用构造函数,初始化对象。
  3. 将 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. 总结

在实际开发中,如果需要实现单例模式:

  1. 如果对内存要求不严格,且确定该实例一定会被用到,推荐使用饿汉式(简单、安全)。
  2. 如果需要懒加载,且涉及多线程环境,推荐使用双重检查锁定 (DCL)
相关推荐
曲莫终5 小时前
spring.main.lazy-initialization配置的实现机制
java·后端·spring
❀͜͡傀儡师5 小时前
docker部署Docker Compose文件Web管理工具Dockman
java·前端·docker·dockman
沐雪架构师5 小时前
大模型Agent面试精选题(第五辑)-Agent提示词工程
java·面试·职场和发展
IT19955 小时前
MySQL运维笔记-一种数据定期备份的方法
运维·笔记·mysql
云飞云共享云桌面5 小时前
SolidWorks服务器怎么实现研发软件多人共享、数据安全管理
java·linux·运维·服务器·数据库·自动化
是喵斯特ya5 小时前
JNDI注入漏洞分析
java·安全
kong@react5 小时前
wsl2安装及命令(详细教程)
java·docker·容器
学Linux的语莫5 小时前
k8s知识点整体概览
java·linux·kubernetes
k***92165 小时前
list 迭代器:C++ 容器封装的 “行为统一” 艺术
java·开发语言·数据结构·c++·算法·list