【JavaEE初阶 — 多线程】单例模式 & 指令重排序问题


目录

[1. 单例模式](#1. 单例模式)

[(1) 饿汉模式](#(1) 饿汉模式)

[(2) 懒汉模式](#(2) 懒汉模式)

[1. 单线程版本](#1. 单线程版本)

[2. 多线程版本](#2. 多线程版本)

[2. 解决懒汉模式产生的线程安全问题](#2. 解决懒汉模式产生的线程安全问题)

[(1) 产生线程安全的原因](#(1) 产生线程安全的原因)

[(2) 解决线程安全问题](#(2) 解决线程安全问题)

[1. 通过加锁让读写操作紧密执行](#1. 通过加锁让读写操作紧密执行)

方法一

方法二

[2. 处理加锁引入的新问题](#2. 处理加锁引入的新问题)

问题描述

解决方法

[3. 避免内存可见性&指令重排序](#3. 避免内存可见性&指令重排序)

[(1) 杜绝内存可见性问题](#(1) 杜绝内存可见性问题)

[(2) 避免指令重排序问题](#(2) 避免指令重排序问题)

[1. 模拟编译器指令重排序情景](#1. 模拟编译器指令重排序情景)

[2. 指令重排序概述](#2. 指令重排序概述)

[3. 指令重排序类型](#3. 指令重排序类型)

(1)编译器重排序

[(2) 处理器重排序](#(2) 处理器重排序)

[4. 指令重排序所引发的问题](#4. 指令重排序所引发的问题)


1. 单例模式

  • 单例模式能保证某个类在程序中,只存在唯 一 一 份实例,而不会创建出多个实例(不允许new多次)。

  • 要想保证单例模式只有唯一 一个实例,最重要的做法,就是用 private 修饰所有的构造方法;

  • 在 new 的过程中,会调用实例的类中的构造方法;

  • 只要用 private 修饰所有构造方法,在类外就无法获取到构造方法,进而使得 new 操作在编译时报错,因此保证某个类在程序中,只能有一份实例,而不能创建多个实例。

  • 这一点在很多场景上都需要,比如 JDBC 中的 DataSource 实例就只需要一个.

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


(1) 饿汉模式



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

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


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


单例模式的"点睛之笔",用 private 修饰类中所有构造方法



(2) 懒汉模式


  • 如果说,饿汉模式就是类加载的时候(一个比较早的时期)进行创建实例,并且使用private 修饰所有的构造方法,使得在代码中无法创建该类的其他实例
  • 那么懒汉方式的核心思路,就是延时的去创建实例,延时是真正用到的时候,再去创建。这样的思路,在编程中是非常有用的思路,一些情况下并不需要实例对象,通过懒汉模式来写代码,就不会去实例对象,进而可以减小开销,提升效率。

1. 单线程版本

懒汉模式下,创建线程的时机,是在第一次使用的时候,而不是在程序启动的时候;

如果程序一启动,就要去使用实例,那 懒汉模式 和 饿汉模式 没有区别,但是程序运行很久了,都没有用到,此时懒汉模式创建实例的时间更晚一些,这样能减少不必要的开销


2. 多线程版本


2.解决懒汉模式产生的线程安全问题


(1) 产生线程安全的原因


对于饿汉模式:

而对于懒汉模式,为什么会有单线程版本和多线程版本呢?我们来看单线程版本如果运用到多线程版本,会出现什么问题:

  • instance 被 static 修饰,多个线程调用 getInstance(),返回的是同一个内存变量;
  • 通过上面单线程版本的懒汉模式,我们可以发现,在 getInstance() 中,不但涉及了读操作,并且涉及了写操作;
  • 虽然对于图中标注的写操作,是赋值操作,并且这一步的操作是原子的,但是在多线程下调用的 getInstance() 方法,并不是原子的;
  • getInstance() 方法中,不但有写的操作,还有读的操作(满足条件才赋值,不满足条件不赋值),所以判断和赋值两个操作是紧密相连的,不能保证这两步操作紧密执行,就会出现下面的线程安全问题:
  • t1,t2如果按照上面的执行步骤,会出现值覆盖;随着第二个线程的覆盖操作,第一个线程 new 出来的对象会被 GC 回收掉。
  • 看起来没什么问题,但是 new 一个对象,会产生大量额外不必要的开销(new 一个对象的过程,可能会把大内存的数据,从硬盘加载到内存中);
  • 单例模式,不仅仅是期待只创建一个实例,更重要的是期望不要进行这种重复性的,耗时的工作,一来没意义,二来空间不允许;
  • 即使对于上面的情况,创建的第一个对象很快就被释放掉了,但是也是有数据加载过程的。

总结:

  • 饿汉模式只涉及对内存变量的读操作,不涉及写操作,因此饿汉模式是线程安全的,在单线程或者多线程的情况下,饿汉模式的基本形式不变;
  • 对于懒汉模式,在 getInstance() 中,涉及紧密相连的读写操作,但是因为读写操作不能紧密执行,导致出现线程安全问题。

(2) 解决线程安全问题


面试题:

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


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


对于上述饿汉模式出现线程安全问题的原因,就是读写操作(判断 + 赋值)不能紧密执行,因此,我们要对读写两步操作进行加锁,才能保证线程安全问题:

方法一

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

  • t2 会阻塞等待 t1 (或者 t1 会阻塞等待 t2)new好对象之后(读写操作结束后),释放锁,第二个线程才可以进行读写操作;
  • 此时第二个线程的判断,发现 instance != null,就会直接 return,而不会再进行实例 。

方法二

直接对 getInstance() 方法加锁,也能达到读写操作紧密执行的效果;

此时锁对象,locker ---> SingletonLazy.class,这两种方法达到的效果相同。


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


问题描述

对于当前懒汉模式的代码,两个线程一把锁,是不会构成请求保持(形成死锁)的;

多个线程调用 getInstance() 方法,其实只需要保证第一个线程调用 getInstance(),执行的读写操作是紧密执行的即可,后续的线程在进行读操作发现 instance != null,就都不会触发写操作,自然就保证了线程安全;

但是按照上图的 getInstance() 方法,发现多个线程每次调用 getInstance() 都会进行一次加锁解锁操作,因为synchronized 是重量锁,多次的加锁解锁,会造成大量额外的开销,大大减低性能:

拓展:


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


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


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


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


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


解决方法

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

  • 在单线程中,连续嵌套两层相同的 if 语句是没有意义的;因为单线程的 "执行流" 只有一个 ,嵌套两层相同的 if 语句结果相同;

  • 但是在多线程中,有多个并发执行的执行流,可能因为其中一个线程修改了 instance,导致其他线程再次执行到判断操作时,会有所不同;如上述懒汉模式,在多线程下,两个 if 得到的结果是不同的;

  • 虽然两个if相同,但是目的和作用截然不同;上面的 if,是用来判断是否需要加锁,下面的 if 判断是否需要new对象。

  • 虽然两个 if 相同,但是这只是一个巧和


3. 避免内存可见性&指令重排序


要杜绝可能会出现的内存可见性问题 ,并且避免指令重排序问题,只需要使用 volatile 修饰instance 即可:


(1) 杜绝内存可见性问题

假如不加volatile ,那么假设两个线程都执行到 synchronized 锁时, 一个线程加锁,另一个线程阻塞等待; 然后获取到锁对象的线程, 创建出来了这个单例;


释放锁之后,,另一个没获取到锁对象的线程, 获取锁之后,执行 if 判断,结果它读取的到的instance的值,是之前寄存器缓存中的值,而寄存器中缓存的 instance 还是null,因此第二个线程又回去执行锁中的逻辑,就又会去实例化一个新的 instance。


内存可见性就是保证, 每次去读取的时候, 读取到的值都是最新的值(内存中的值),而不是之前缓存在寄存器中的值;


如果不加volatile ,在上面说的案例中, 会有可能存在第二个线程获取到锁对象,结果发现这个单例(instance)是等于 null的情况;所以需要加上volatile 来保证不会出现这样的情况。


(2) 避免指令重排序问题

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

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

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

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

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



指令重排序在刚刚写的代码中(处理好加锁所引入的问题之后)会造成的问题


在第一个 if 结束后,可能不会直接 return,而是还有后续的逻辑;


这时候通过指令重排序,会在最开始的懒汉模式的版本,只有一个套着 synchronized 的 if 的时候,会因为加锁阻塞,而避免使用未触发对象 (instance未初始化) 的情况;


但是我们为了考虑效率,为了减少不必要的加锁操作,减少开销,我们多加了一层 if,正是多加了一层if,使得 t2 线程因为未触发 synchronized ,而不会进入阻塞等待;


所以在 t1 线程还没来得及初始化 instance 时,t2 就直接拿着未初始化的 instance 执行第一个 if 后面,后续的逻辑了


这样的问题,就是我们说的指令重排序问题。



2. 指令重排序概述

指令重排序是指编译器或处理器为了提高性能,在不改变程序执行结果的前提下,可以对指令序列

进行重新排序的优化技术。这种优化技术可以使得计算机在执行指令时更高效地利用计算资源,提

高程序的执行效率。


3. 指令重排序类型

(1)编译器重排序

编译器在生成目标代码时会对源代码中的指令进行优化和重排,以提高程序的执行效率。编译器重

排序时在编译阶段完成的,目的是生成更高效率的机器代码。


(2) 处理器重排序

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

的提高指令的执行效率。


4. 指令重排序所引发的问题

虽然指令重排序可以提高程序的执行效率,但是在多线程编程中可能会引发内存可见性问题。由于

指令重排序 可能导致共享变量的读写顺序,与代码中的顺序不一致,当多个线程同时访问共享变

量时,可能会出现数据不一致的情况。


相关推荐
yqcoder几秒前
reactflow 中 useNodesState 模块作用
开发语言·前端·javascript
baivfhpwxf202311 分钟前
C# 5000 转16进制 字节(激光器串口通讯生成指定格式命令)
开发语言·c#
许嵩6614 分钟前
IC脚本之perl
开发语言·perl
长亭外的少年24 分钟前
Kotlin 编译失败问题及解决方案:从守护进程到 Gradle 配置
android·开发语言·kotlin
直裾25 分钟前
Scala全文单词统计
开发语言·c#·scala
心仪悦悦25 分钟前
Scala中的集合复习(1)
开发语言·后端·scala
JIAY_WX27 分钟前
kotlin
开发语言·kotlin
阿龟在奔跑1 小时前
引用类型的局部变量线程安全问题分析——以多线程对方法局部变量List类型对象实例的add、remove操作为例
java·jvm·安全·list
飞滕人生TYF1 小时前
m个数 生成n个数的所有组合 详解
java·递归