【JUC】深入解析 JUC 并发编程:单例模式、懒汉模式、饿汉模式、及懒汉模式线程安全问题解析和使用 volatile 解决内存可见性问题与指令重排序问题
单例模式
单例模式确保某个类在程序中只有一个实例,避免多次创建实例(禁止多次使用new
)。
要实现这一点,关键在于将类的所有构造方法声明为private
。
这样,在类外部无法直接访问构造方法,new
操作会在编译时报错,从而保证类的实例唯一性。例如,在JDBC中,DataSource
实例通常只需要一个,单例模式非常适合这种场景。
单例模式的实现方式主要有两种:“饿汉式”和“懒汉式”
饿汉模式
下面这段代码,是对唯一成员 instance 进行初始化,用 static 修饰 instance,对 instance 的初始化,会在类加载的阶段触发;类加载往往就是在程序一启动就会触发;
由于是在类加载的阶段,就早早地创建好了实例(static修饰),这也就是“饿汉模式” 名字的由来。
在初始化好 instance 后,后续统一通过调用 getInstance() 方法获取 instance
单例模式的“点睛之笔”,用 private 修饰类中所有构造方法
,因为可以防止通过 new 关键字在类外部创建实例
,只能通过调用内部静态方法,来获取单例类实例:
懒汉模式
- 饿汉模式:
在类加载时即创建实例
,通过将构造方法声明为private
,防止外部创建其他实例。- 懒汉模式:延迟创建
实例,仅在真正需要时才创建。
这种模式在某些情况下无需实例对象时,可避免不必要的实例化,减少开销并提升效率。
单线程版本
在懒汉模式下,实例的创建时机是在第一次被使用时
,而不是在程序启动时。
如果程序启动后立即需要使用实例,那么懒汉模式和饿汉模式的效果相似。
然而,如果程序运行了较长时间仍未使用该实例,懒汉模式会延迟实例的创建,从而减少不必要的开销
。
多线程版本
单例模式产生线程安全的原因
饿汉模式
懒汉模式
为什么会有单线程版本和多线程版本的懒汉模式写法呢?我们来看单线程版本,如果运用到多线程的环境下,会出现什么问题:
在懒汉模式中,instance
被声明为static
,因此多个线程调用getInstance()
时,返回的是同一个实例。
然而,getInstance()
方法中既包含读操作(检查instance
是否为null
),也包含写操作(实例化instance
)。
尽管赋值操作本身是原子的,但整个getInstance()
方法并非原子操作。这意味着在多线程环境下,判断和赋值操作不能保证紧密执行,从而导致线程安全问题。
在多线程环境下,若两个线程(如 t1 和 t2)同时执行 getInstance()
方法,可能会导致值覆盖问题。
如上图,t2 线程的赋值操作可能会覆盖 t1 线程新创建的对象,导致第一个线程创建的对象被垃圾回收(GC)
。
这不仅增加了不必要的开销,还违背了单例模式的核心目标:避免重复创建实例,减少耗时操作,节省资源。
即使第一个对象很快被释放,其创建过程中的数据加载依然会产生额外开销。
总结:
- 饿汉模式:仅涉及对实例的读操作,不涉及写操作,因此天然线程安全。无论在单线程还是多线程环境下,其基本形式保持不变。
- 懒汉模式:在
getInstance()
中包含紧密相关的读写操作(检查实例是否存在并创建实例),但这些操作无法紧密执行,导致线程安全问题。
解决单例模式的线程安全问题
面试题:
这两个单例模式的 getInstance() 在多线程环境下调用,是否会出现 bug,如何解决 bug?
1. 通过加锁让读写操作紧密执行
饿汉模式本身不存在线程安全问题,因为它仅涉及读操作,不涉及写操作。
然而,懒汉模式在多线程环境下可能出现线程安全问题,原因在于getInstance()
方法中的读写操作(判断 + 赋值)不能紧密执行。
为解决这一问题,需要对相关操作进行加锁,以确保线程安全。
方法一:对方法中的读操作加锁
这样加锁后,如果 t1 和 t2 还出现下图读写逻辑的执行顺序:
- t2 会阻塞等待 t1(或 t1 等待 t2)完成对象的创建(读写操作结束后),释放锁后,第二个线程才能继续执行。
- 此时,第二个线程发现
instance
已非null
,会直接返回已创建的实例,不再重复创建。
方法二:对整个方法加锁
直接对getInstance()
方法加锁,也能确保读写操作紧密执行。此时,锁对象为SingletonLazy.class
。这两种方法的效果相同
2. 处理加锁引入的新问题
问题描述
对于当前懒汉模式的代码,多个线程共享一把锁,不会导致死锁
。只需确保第一个线程调用getInstance()
时,读写操作紧密执行即可。
后续线程在读取时发现instance != null
,就不会触发写操作
,从而自然保证了线程安全。
然而,若每次调用getInstance()
方法时都进行加锁解锁操作,由于synchronized
是重量级锁,多次加锁,尤其是重量级锁会导致显著的性能开销,从而降低程序效率
。
拓展:
StringBuffer 就是为了解决,大量拼接字符串时,产生很多中间对象问题而提供的一个类,提供 append 和 insert 方法,可以将字符串添加到,已有序列的 末尾 或 指定位置。
StringBuffer 的本质是一个线程安全的可修改的字符序列,把所有修改数据的方法都加上了synchronized。但是保证了线程安全是需要性能的代价的。
在很多情况下我们的字符串拼接操作,不需要线程安全,这时候 StringBuilder 登场了,
StringBuilder 是 JDK1.5 发布的, StringBuilder 和 StringBuffer 本质上没什么区别,就是去掉了保证线程安全的那部分,减少了开销。所以在单线程情况下,优先考虑使用 StringBuilder。
StringBuffer 和 StringBuilder 二者都继承了 AbstractStringBuilder,底层都是利用可修改的 char数组 (JDK9以后是 byte 数组)。
所以如果我们有大量的字符串拼接,如果能预知大小的话最好在new StringBuffer 或者 new StringBuilder 的时候设置好 capacity ,避免多次扩容的开销(扩容要抛弃原有数组,还要进行数组拷贝创建新的数组)。
解决方法
再嵌套一次判断操作,既可以保证线程安全,又可以避免大量加锁解锁产生的开销:
在单线程环境下,嵌套两层相同的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
,可能会出现以下问题:
内存可见性问题
核心问题
- 在没有
volatile
修饰时,线程t1
对instance
的写入可能仅停留在线程本地缓存(CPU缓存或寄存器),而非立即同步到主内存
。 - 此时线程
t2
读取的可能是自己缓存中的旧值(null
),即使t1
已完成初始化。 - 即使
t2
进入同步块,第一次判空(if (instance == null)
仍可能读取到未更新的缓存值,导致不必要的锁竞争。 - 第二次判空
if (instance == null)
,t2
可能会错误地认为instance == null
,并再次执行实例化逻辑,导致又重复创建了新的实例。
内存可见性底层分析
- 硬件层面的原因
存储层级 | 读写速度 | 存储大小 | 特性 |
---|---|---|---|
寄存器 | 最快 | 最小(几十字节) | CPU直接计算使用的临时存储 |
CPU缓存 (L1/L2/L3) | 快 | 较小(KB~MB级) | 每个CPU核心/多核共享,减少访问主存延迟 |
主内存 (RAM) | 慢 | 大(GB级) | 所有线程共享,但访问速度比缓存慢100倍以上 |
- 速度差异:CPU为了避免等待慢速的主内存读写,会优先使用缓存和寄存器(如将
instance
的值缓存在核心的L1缓存中)。 - 副作用:线程
t1
修改instance
后,可能仅更新了当前核心的缓存,而其他核心的缓存或主内存未被同步,导致t2
读取到过期数据。
- Java内存模型(JMM)的抽象
- 硬件差异被
JMM
抽象为工作内存(线程私有)
和主内存(共享)
的分离: 工作内存
:包含CPU寄存器、缓存等线程私有的临时存储
。主内存
:所有线程共享的真实内存
。
- 问题本质:
- 当线程
t1
未强制同步(如缺少volatile
或锁)时,JVM/CPU
可能延迟将工作内存的修改刷回主内存,其他线程也无法感知变更。
指令重排序
指令重排序的具体问题
instance = new SingletonLazy()
的实际操作可分为以下步骤(可能被JVM/CPU重排序):
1. 分配对象内存空间(堆上分配,此时内存内容为默认值0/null)
2. 调用构造函数(初始化对象字段)
3. 将引用赋值给 instance 变量(此时 instance != null)
可能的危险重排序:
- JVM可能将步骤 3(赋值) 和 2(构造) 调换顺序,导致:
1. 分配内存
2. 赋值给 instance(此时 instance != null,但对象未初始化!)
3. 执行构造函数
这就是指令重排序问题。
- 多线程场景下指令重排序的后果
- 线程 t1 执行
getInstance()
时发生重排序:- 先执行步骤1和3,
instance
已不为null
,但对象未构造完成。
- 先执行步骤1和3,
- 线程 t2 调用
getInstance()
:- 第一次判空
if (instance == null)
会跳过 - 若 t2 立刻调用
instance.func()
,会访问未初始化的字段,导致:- 空指针异常(如果
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)
直接操作硬件层
:
- 写操作:强制将当前核心的缓存行(Cache Line)写回主内存,并失效其他核心的缓存。
- 读操作:强制从主内存重新加载数据,跳过缓存。
private static volatile SingletonLazy instance; // 通过volatile禁止缓存优化
总结
- 直接原因:CPU缓存和寄存器的速度优化导致可见性问题。
- 根本原因:硬件架构与编程语言内存模型的设计差异(JMM需在性能与正确性间权衡)。
- 解决方案:
volatile
通过内存屏障强制同步硬件层和JMM的约定。
总结:为什么双重检查锁(DCL)必须用volatile
?
- 可见性:确保
t1
的初始化结果对t2
立即可见。 - 禁止指令重排序:
instance = new SingletonLazy()
的字节码可能被重排序为:- 分配内存空间
- 将引用写入
instance
(此时instance != null
但对象未初始化!) - 执行构造函数
volatile
会禁止这种重排序,保证步骤2在3之后执行
。
4. 指令重排序问题
模拟编译器指令重排序情景
要在超市中买到左边购物清单的物品,有两种买法
方法一:根据购物清单的顺序买;(按照程序员编写的代码顺序进行编译)
方法二:根据物品最近距离购买;(通过指令重排序后再编译)
两种方法都能买到购物清单的所有物品,但是比起第一种方法,第二种方法在不改变原有逻辑的情况下,优化执行指令顺序,更高效地执行完所有的指令
。
指令重排序概述
指令重排序的定义
指令重排序是指编译器或处理器为了提高性能,在不改变程序执行结果的前提下,对指令序列进行重新排序的优化技术。这种技术可以让计算机在执行指令时更高效地利用计算资源,从而提高程序的执行效率。
指令重排序的类型
- 编译器重排序
编译器在生成目标代码时会对源代码中的指令进行优化和重排,以提高程序的执行效率。这一过程在编译阶段完成,目的是生成更高效的机器代码。
- 处理器重排序
处理器在执行指令时也可以对指令进行重排序,以最大程度地利用处理器的流水线和多核等特性,从而提高指令的执行效率。
指令重排序引发的问题
尽管指令重排序可以提高程序的执行效率,但在多线程编程中可能会引发内存可见性问题。由于指令重排序可能导致共享变量的读写顺序与代码中的顺序不一致,当多个线程同时访问共享变量时,可能会出现数据不一致的情况。
指令重排序解决方案
为了解决指令重排序带来的问题,可以采取以下措施:
- 编译器层面:通过禁止特定类型的编译器重排序,确保指令的执行顺序符合预期。
- 处理器层面:通过插入
内存屏障(Memory Barrier)
来禁止特定类型的处理器重排序。内存屏障是一种CPU指令,用来禁止处理器指令发生重排序,从而保障指令执行的有序性。此外,内存屏障还会在处理器写入或读取值之前,将主内存的值写入高速缓存并清空无效队列,从而保障变量的可见性。