当前位置: 首页 > news >正文

【JUC】深入解析 JUC 并发编程:单例模式、懒汉模式、饿汉模式、及懒汉模式线程安全问题解析和使用 volatile 解决内存可见性问题与指令重排序问题

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


单例模式


单例模式确保某个类在程序中只有一个实例,避免多次创建实例(禁止多次使用new)。

要实现这一点,关键在于将类的所有构造方法声明为private

这样,在类外部无法直接访问构造方法,new操作会在编译时报错,从而保证类的实例唯一性。例如,在JDBC中,DataSource实例通常只需要一个,单例模式非常适合这种场景。

单例模式的实现方式主要有两种:“饿汉式”和“懒汉式”


饿汉模式


img


下面这段代码,是对唯一成员 instance 进行初始化,用 static 修饰 instance,对 instance 的初始化,会在类加载的阶段触发;类加载往往就是在程序一启动就会触发;img

由于是在类加载的阶段,就早早地创建好了实例(static修饰),这也就是“饿汉模式” 名字的由来。


在初始化好 instance 后,后续统一通过调用 getInstance() 方法获取 instance

img


单例模式的“点睛之笔”,用 private 修饰类中所有构造方法,因为可以防止通过 new 关键字在类外部创建实例,只能通过调用内部静态方法,来获取单例类实例:

img


img


懒汉模式


  • 饿汉模式在类加载时即创建实例,通过将构造方法声明为private,防止外部创建其他实例。
  • 懒汉模式:延迟创建实例,仅在真正需要时才创建。这种模式在某些情况下无需实例对象时,可避免不必要的实例化,减少开销并提升效率。

单线程版本


在懒汉模式下,实例的创建时机是在第一次被使用时,而不是在程序启动时。

如果程序启动后立即需要使用实例,那么懒汉模式和饿汉模式的效果相似。

然而,如果程序运行了较长时间仍未使用该实例,懒汉模式会延迟实例的创建,从而减少不必要的开销

img


多线程版本


img


单例模式产生线程安全的原因


img


饿汉模式


img


懒汉模式


为什么会有单线程版本和多线程版本的懒汉模式写法呢?我们来看单线程版本,如果运用到多线程的环境下,会出现什么问题:

img

在懒汉模式中,instance被声明为static,因此多个线程调用getInstance()时,返回的是同一个实例。

然而,getInstance()方法中既包含读操作(检查instance是否为null),也包含写操作(实例化instance)。

尽管赋值操作本身是原子的,但整个getInstance()方法并非原子操作。这意味着在多线程环境下,判断和赋值操作不能保证紧密执行,从而导致线程安全问题。

img

在多线程环境下,若两个线程(如 t1 和 t2)同时执行 getInstance() 方法,可能会导致值覆盖问题。

如上图,t2 线程的赋值操作可能会覆盖 t1 线程新创建的对象,导致第一个线程创建的对象被垃圾回收(GC)

这不仅增加了不必要的开销,还违背了单例模式的核心目标:避免重复创建实例,减少耗时操作,节省资源。即使第一个对象很快被释放,其创建过程中的数据加载依然会产生额外开销。


总结:

  • 饿汉模式:仅涉及对实例的读操作,不涉及写操作,因此天然线程安全。无论在单线程还是多线程环境下,其基本形式保持不变。
  • 懒汉模式:在getInstance()中包含紧密相关的读写操作(检查实例是否存在并创建实例),但这些操作无法紧密执行,导致线程安全问题。

解决单例模式的线程安全问题


面试题:

这两个单例模式的 getInstance() 在多线程环境下调用,是否会出现 bug,如何解决 bug?


1. 通过加锁让读写操作紧密执行


饿汉模式本身不存在线程安全问题,因为它仅涉及读操作,不涉及写操作。

然而,懒汉模式在多线程环境下可能出现线程安全问题,原因在于getInstance()方法中的读写操作(判断 + 赋值)不能紧密执行。

为解决这一问题,需要对相关操作进行加锁,以确保线程安全。


方法一:对方法中的读操作加锁


img

这样加锁后,如果 t1 和 t2 还出现下图读写逻辑的执行顺序:

img

  • t2 会阻塞等待 t1(或 t1 等待 t2)完成对象的创建(读写操作结束后),释放锁后,第二个线程才能继续执行。
  • 此时,第二个线程发现 instance 已非 null,会直接返回已创建的实例,不再重复创建。

方法二:对整个方法加锁


img

直接对getInstance()方法加锁,也能确保读写操作紧密执行。此时,锁对象为SingletonLazy.class。这两种方法的效果相同


2. 处理加锁引入的新问题


问题描述


对于当前懒汉模式的代码,多个线程共享一把锁,不会导致死锁。只需确保第一个线程调用getInstance()时,读写操作紧密执行即可。

后续线程在读取时发现instance != null就不会触发写操作,从而自然保证了线程安全。

img


然而,若每次调用getInstance()方法时都进行加锁解锁操作,由于synchronized是重量级锁,多次加锁,尤其是重量级锁会导致显著的性能开销,从而降低程序效率

img

拓展:


StringBuffer 就是为了解决,大量拼接字符串时,产生很多中间对象问题而提供的一个类,提供 appendinsert 方法,可以将字符串添加到,已有序列的 末尾 或 指定位置。


StringBuffer 的本质是一个线程安全的可修改的字符序列,把所有修改数据的方法都加上了synchronized。但是保证了线程安全是需要性能的代价的。


在很多情况下我们的字符串拼接操作,不需要线程安全,这时候 StringBuilder 登场了,
StringBuilder 是 JDK1.5 发布的, StringBuilderStringBuffer 本质上没什么区别,就是去掉了保证线程安全的那部分,减少了开销。所以在单线程情况下,优先考虑使用 StringBuilder


StringBufferStringBuilder 二者都继承了 AbstractStringBuilder,底层都是利用可修改的 char数组 (JDK9以后是 byte 数组)。


所以如果我们有大量的字符串拼接,如果能预知大小的话最好在new StringBuffer 或者 new StringBuilder 的时候设置好 capacity ,避免多次扩容的开销(扩容要抛弃原有数组,还要进行数组拷贝创建新的数组)。


解决方法


再嵌套一次判断操作,既可以保证线程安全,又可以避免大量加锁解锁产生的开销:

img

在单线程环境下,嵌套两层相同的if语句并无意义,因为单线程只有一个执行流,嵌套与否结果相同。但在多线程环境下,多个并发执行流,可能导致不同线程在执行判断操作时,因其他线程修改了instance而得到不同结果。

例如,在懒汉模式下,即使两个if语句形式相同,其目的和作用却不同

  • 第一个if用于判断是否需要加锁;
  • 第二个if用于判断是否需要创建对象。

这种结构虽看似巧合,但实则必要。


3. 引入 volatile 关键字


问题描述


懒汉模式的单例实现中,使用volatile关键字修饰instance至关重要。以下是懒汉模式的单例实现代码:

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

如果不使用volatile修饰instance,可能会出现以下问题:


内存可见性问题

核心问题

  1. 在没有 volatile 修饰时,线程 t1instance写入可能仅停留在线程本地缓存(CPU缓存或寄存器),而非立即同步到主内存
  2. 此时线程 t2 读取的可能是自己缓存中的旧值(null),即使 t1 已完成初始化。
  3. 即使 t2 进入同步块,第一次判空(if (instance == null)仍可能读取到未更新的缓存值,导致不必要的锁竞争。
  4. 第二次判空if (instance == null)t2可能会错误地认为instance == null,并再次执行实例化逻辑,导致又重复创建了新的实例。

内存可见性底层分析


  1. 硬件层面的原因
存储层级读写速度存储大小特性
寄存器最快最小(几十字节)CPU直接计算使用的临时存储
CPU缓存 (L1/L2/L3)较小(KB~MB级)每个CPU核心/多核共享,减少访问主存延迟
主内存 (RAM)大(GB级)所有线程共享,但访问速度比缓存慢100倍以上
  • 速度差异:CPU为了避免等待慢速的主内存读写,会优先使用缓存和寄存器(如将instance的值缓存在核心的L1缓存中)。
  • 副作用:线程t1修改instance后,可能仅更新了当前核心的缓存,而其他核心的缓存或主内存未被同步,导致t2读取到过期数据。

  1. Java内存模型(JMM)的抽象
  • 硬件差异被JMM抽象为 工作内存(线程私有)主内存(共享)的分离:
  • 工作内存:包含CPU寄存器、缓存等线程私有的临时存储
  • 主内存所有线程共享的真实内存

  1. 问题本质:
  • 当线程t1未强制同步(如缺少volatile或锁)时,JVM/CPU可能延迟将工作内存的修改刷回主内存,其他线程也无法感知变更。

指令重排序

指令重排序的具体问题

img

instance = new SingletonLazy() 的实际操作可分为以下步骤(可能被JVM/CPU重排序):

1. 分配对象内存空间(堆上分配,此时内存内容为默认值0/null2. 调用构造函数(初始化对象字段)
3. 将引用赋值给 instance 变量(此时 instance != null

img
可能的危险重排序

  • JVM可能将步骤 3(赋值)2(构造) 调换顺序,导致:
1. 分配内存
2. 赋值给 instance(此时 instance != null,但对象未初始化!)
3. 执行构造函数

img
这就是指令重排序问题。


  1. 多线程场景下指令重排序的后果
  • 线程 t1 执行 getInstance() 时发生重排序:
    • 先执行步骤1和3,instance 已不为 null,但对象未构造完成。
  • 线程 t2 调用 getInstance()
    • 第一次判空 if (instance == null) 会跳过
    • 若 t2 立刻调用 instance.func(),会访问未初始化的字段,导致:img
      • 空指针异常(如果 func() 访问未初始化的引用字段)。
      • 数据不一致(如果 func() 依赖构造函数中初始化的值)。

解决方法


使用volatile修饰instance后,不仅能确保每次读取操作都直接从内存中读取,还能防止与该变量相关的读取和修改操作发生重排序。

private volatile static SingletonLazy instance;public static SingletonLazy getInstance() {if (instance == null) {          // 第一次无锁检查synchronized (locker) {      // 同步块if (instance == null) {  // 第二次检查instance = new SingletonLazy();  // 受volatile保护}}}return instance;
}

volatile 是怎么解决内存可见性问题的呢?


通过内存屏障(Memory Barrier)直接操作硬件层

  1. 写操作:强制将当前核心的缓存行(Cache Line)写回主内存,并失效其他核心的缓存。
  2. 读操作:强制从主内存重新加载数据,跳过缓存。
private static volatile SingletonLazy instance; // 通过volatile禁止缓存优化

总结

  • 直接原因:CPU缓存和寄存器的速度优化导致可见性问题。
  • 根本原因:硬件架构与编程语言内存模型的设计差异(JMM需在性能与正确性间权衡)。
  • 解决方案volatile通过内存屏障强制同步硬件层和JMM的约定。

总结:为什么双重检查锁(DCL)必须用volatile


  • 可见性:确保t1的初始化结果对t2立即可见。
  • 禁止指令重排序
    instance = new SingletonLazy() 的字节码可能被重排序为:
    1. 分配内存空间
    2. 将引用写入instance(此时instance != null但对象未初始化!)
    3. 执行构造函数
      volatile会禁止这种重排序,保证步骤2在3之后执行

4. 指令重排序问题


模拟编译器指令重排序情景


要在超市中买到左边购物清单的物品,有两种买法

img


方法一:根据购物清单的顺序买;(按照程序员编写的代码顺序进行编译)img

方法二:根据物品最近距离购买;(通过指令重排序后再编译)

img

两种方法都能买到购物清单的所有物品,但是比起第一种方法,第二种方法在不改变原有逻辑的情况下,优化执行指令顺序,更高效地执行完所有的指令


指令重排序概述


指令重排序的定义

指令重排序是指编译器或处理器为了提高性能,在不改变程序执行结果的前提下,对指令序列进行重新排序的优化技术。这种技术可以让计算机在执行指令时更高效地利用计算资源,从而提高程序的执行效率。


指令重排序的类型

  1. 编译器重排序

编译器在生成目标代码时会对源代码中的指令进行优化和重排,以提高程序的执行效率。这一过程在编译阶段完成,目的是生成更高效的机器代码。


  1. 处理器重排序

处理器在执行指令时也可以对指令进行重排序,以最大程度地利用处理器的流水线和多核等特性,从而提高指令的执行效率。


指令重排序引发的问题

尽管指令重排序可以提高程序的执行效率,但在多线程编程中可能会引发内存可见性问题。由于指令重排序可能导致共享变量的读写顺序与代码中的顺序不一致,当多个线程同时访问共享变量时,可能会出现数据不一致的情况。


指令重排序解决方案

为了解决指令重排序带来的问题,可以采取以下措施:

  1. 编译器层面:通过禁止特定类型的编译器重排序,确保指令的执行顺序符合预期。
  2. 处理器层面:通过插入内存屏障(Memory Barrier)来禁止特定类型的处理器重排序。内存屏障是一种CPU指令,用来禁止处理器指令发生重排序,从而保障指令执行的有序性。此外,内存屏障还会在处理器写入或读取值之前,将主内存的值写入高速缓存并清空无效队列,从而保障变量的可见性。

在这里插入图片描述

在这里插入图片描述

相关文章:

  • C++八股 —— 手撕线程池
  • Java限制单价小数位数方法
  • GitLens 教学(学习更新中)
  • 2025年渗透测试面试题总结-匿名[校招]红队攻防工程师(题目+回答)
  • 特伦斯 S75 电钢琴:重塑演奏美学的至臻之选
  • 数字化那点事系列文章
  • 软件工程:关于招标合同履行阶段变更的法律分析
  • [网页五子棋][对战模块]前后端交互接口(建立连接、连接响应、落子请求/响应),客户端开发(实现棋盘/棋子绘制)
  • 系统思考:经营决策沙盘
  • WebBuilder数据库:企业数据管理的能力引擎
  • Vad-R1:通过从感知到认知的思维链进行视频异常推理
  • 初学c语言21(文件操作)
  • C语言进阶--自定义类型详解(结构体、枚举、联合)
  • 2022 RoboCom 世界机器人开发者大赛-本科组(省赛)解题报告 | 珂学家
  • 【仿生机器人】机器人情绪系统的深度解析
  • 从监控到告警:Prometheus+Grafana+Alertmanager+告警通知服务全链路落地实践
  • Docker Compose使用自定义用户名密码启动Redis
  • 经典SQL查询问题的练习第二天
  • JNI开发流程
  • OS9.【Linux】基本权限(下)
  • 外国网站怎么做/网站免费下载安装
  • 芜湖网站制作公司/中国国家培训网正规吗
  • 企业网站seo教程/宁波网站推广公司有哪些
  • 浙江 网站建设/百度一下的网址
  • 开发一个交易平台需要多少钱/百度优化
  • 合肥快速建站在线咨询/免费的网站平台