【多线程】线程安全的单例模式

线程安全的单例模式

单例模式能保证某个类在程序中只存在 唯一 一份实例, 而不会创建出多个实例,从而节约了资源并实现数据共享。

比如 JDBC 中的 DataSource 实例就只需要一个.

单例模式具体的实现方式, 分成 "饿汉" 和 "懒汉" 两种.

饿汉模式

类加载的同时, 创建实例.

javascript 复制代码
    class Singleton {
        private static Singleton instance = new Singleton();
        // 私有化构造方法,防止外部创建实例
        private Singleton() {}
        public static Singleton getInstance() {
            return instance;
        }
    }

注意:

  1. 使用 static 修饰 instance,该实例就是该类的唯一实例。
  2. 要私有化构造方法,防止外部创建实例。
  3. 饿汉模式中,线程只读取了实例,所以是线程安全的。

懒汉模式

单线程版

类加载的时候不创建实例. 只有真正第一次使用它的时候才创建实例.

javascript 复制代码
class Singleton {
    private static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

多线程版

上面的懒汉模式的实现是线程不安全的. (线程安全问题详解)

因为这里面 读取 和 修改 instance 是两个操作,不是原子操作,线程安全问题发生在首次创建实例时.

如果在多个线程中同时调用 getInstance 方法, 就可能导致创建出多个实例.

javascript 复制代码
class Singleton {
    private static Singleton instance = null;
    private Singleton() {}
    public synchronized static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

注意:

针对 Singleton 类对象加锁(类对象在一个程序中只能有一份),保证所有线程调用 getInstance 方法时,针对同一个对象进行加锁。

多线程版(改进)

代码可能出现线程安全问题的时机就在第一次创建实例时,一旦实例已经创建好了, 后面再多线程环境调用 getInstance 就不再有线程安全问题了(不再修改 instance 了) ,按照上面的加锁方式,不管是否会发生线程安全问题都会加锁,即使初始化之后线程安全了,仍然存在大量锁竞争,降低了程序的效率。

所以在加锁的基础上, 做出了进一步改动:

  • 使用双重 if 判定, 降低锁竞争的频率 。
  • 给 instance 加上了 volatile, 保证内存可见性以及防止指令重排序。
javascript 复制代码
class Singleton {
    private static volatile Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

为什么要双重 if 判定 ?

加锁 / 解锁是一件开销比较高的事情. 而懒汉模式的线程不安全 只是发生在首次创建实例的时候. 因此后续使用的时候, 不必再进行加锁了.

外层的 if 就是判定下看当前是否已经把 instance 实例创建出来了,如果已经创建出来了就不用再加锁了。

为什么要使用 volatile ? volatile 关键字详解

  1. 保证内存可见性:(内存可见性问题)

多个线程调用 getInstance 方法,就会造成大量读 instance 内存操作,这样就可能导致编译器进行优化,不读内存,直接读寄存器,一旦优化,即使其他线程创建了实例,该线程也感知不到。所以使用 volatile 关键字。

(主要针对外外层的 if 判断,因为 synchronized 也能防止指令重排序,所以 内层判断不会受影响。)

  1. 防止指令重排序

什么是指令重排序?

举个栗子:

一段代码是这样的:

javascript 复制代码
1. 去前台取下 U 盘
2. 去教室写 10 分钟作业
3. 去前台取下快递

为了提高效率, JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次前台,提高效率。这种就叫做指令重排序。

编译器对于指令重排序的前提是 "保持逻辑不发生变化".

这一点在单线程环境下比较容易判断, 但是在多线程环境下就没那么容易了,
多线程的代码执行复杂程度更高, 编译器很难在编译阶段对代码的执行效果进行预测,
因此激进的重排序很容易导致优化后的逻辑和之前不等价.

其中创建实例 new Singleton() 又分为 三个步骤:

  1. 分配内存空间
  2. 对内存空间进行初始化
  3. 把内存空间的地址赋给引用 instance

假如没有使用 volatile 关键字,编译器可能对此进行了优化,进行了指令重排序,那么有可能优化为 1 -> 3 -> 2 。

这样的话,当第一个线程 t1 要获取实例时,因为实例为null, 所以肯定会创建实例,但是可能编译器进行了优化,那么可能顺序就变成了 1 -> 3 -> 2

  • 先开辟了一块空间
  • 将空间地址赋值给引用
  • 对空间初始化

当进行完第二步,把空间地址赋值给引用后,还没来得及初始化,此时另外一个线程 t2 来获取实例了, 进行判断时,发现 instance 不为空,那么就直接返回实例了

t2 拿到实例后,直接进行使用,那么就会报错了,因为虽然开辟了空间,但是 t1 还没来得及对空间进行初始化,拿到的是不完整的对象。

解决:

对 instance 对象加上 volatile 关键字,禁止指令重排序,保证其他线程拿到的是一个完整的实例。

完整过程举栗:

  1. 有三个线程, 开始执行 getInstance , 通过外层的 if (instance == null) 知道了实例还没有创建的消息. 于是开始竞争同一把锁.

  2. 其中线程1 率先获取到锁, 此时线程1 通过里层的 if (instance == null) 进一步确认实例还没有创建, 于是就把这个实例创建出来.

  3. 当线程1 释放锁之后, 线程2 和 线程3 也拿到锁, 也通过里层的 if (instance == null) 来确认实例是否已经创建, 发现实例已经创建出来了, 就不再创建了.

  4. 后续的线程, 不必加锁, 直接就通过外层 if (instance == null) 就知道实例已经创建了, 从而不再尝试获取锁了. 降低了开销.

总结:

  1. 构造方法私有化,防止外部创建实例。
  2. 使用 static 修饰,保证是该类的唯一实例。
  3. 使用 volatile 修饰,保证内存可见性以及防止指令重排序。
  4. 双重 if 判断,第一次判断是否需要加锁,从而降低锁竞争,提高效率。
    第二层 if 判断是否真的需要创建实例。
  5. 使用 synchronized 进行加锁,防止第一次创建实例时由于线程安全问题而创建出多个实例。
相关推荐
用户9623779544811 小时前
DVWA 靶场实验报告 (High Level)
安全
数据智能老司机14 小时前
用于进攻性网络安全的智能体 AI——在 n8n 中构建你的第一个 AI 工作流
人工智能·安全·agent
数据智能老司机14 小时前
用于进攻性网络安全的智能体 AI——智能体 AI 入门
人工智能·安全·agent
用户9623779544816 小时前
DVWA 靶场实验报告 (Medium Level)
安全
red1giant_star16 小时前
S2-067 漏洞复现:Struts2 S2-067 文件上传路径穿越漏洞
安全
用户9623779544819 小时前
DVWA Weak Session IDs High 的 Cookie dvwaSession 为什么刷新不出来?
安全
cipher2 天前
ERC-4626 通胀攻击:DeFi 金库的"捐款陷阱"
前端·后端·安全
一次旅行5 天前
网络安全总结
安全·web安全
red1giant_star5 天前
手把手教你用Vulhub复现ecshop collection_list-sqli漏洞(附完整POC)
安全
ZeroNews内网穿透6 天前
谷歌封杀OpenClaw背后:本地部署或是出路
运维·服务器·数据库·安全