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

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

  c96f743646e841f8bb30b2d242197f2f.gif

ddb5ae16fc92401ea95b48766cb03d96.jpeg692a78aa0ec843629a817408c97a8b84.gif


目录

1. 单例模式  

 (1) 饿汉模式  

 (2) 懒汉模式   

  1. 单线程版本  

  2. 多线程版本  

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

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

  (2) 解决线程安全问题   

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

方法一

方法二

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

  问题描述   

  解决方法  

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

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

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

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

2. 指令重排序概述

3. 指令重排序类型

(1)编译器重排序

(2) 处理器重排序

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


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


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


StringBuffer 和 StringBuilder 二者都继承了 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 后面,后续的逻辑了

左边是 多线程版本 的 懒汉模式 中的 getInstance() 要执行的操作,右边是多个线程调用 getInstance();

如果真的因为指令重排序,而导致 内存空间的首地址,赋值给引用变量(拿到钥匙) 的操作,被重排序到 初始化 instance 之前(装修),就会出现下面的问题:


在一个线程已经获取锁对象,进行加锁,还未来得及初始化 instance 时,另一个线程会因为最外层的 if 语句,而跳过 if 中代码块里的加锁操作,而避免了阻塞等待;


如果后续的 getInstance() 在第一层 if 语句后,还有其他逻辑,第二个线程就会拿着未被初始化的 instance 来进行后面的逻辑。


这就是指令重排序问题,而使用 volatile 修饰 instance 后,不但能确保每次读取操作,都是读内存,而且关于该变量的读取和修改操作,不会触发重排序


  2. 指令重排序概述  

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

JMM(Java 内存模型)详解


   指令重排序   


为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。


   什么是指令重排序?   


简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。


   常见的指令重排序有下面2种情况:   


  (1) 编译器优化重排:   


编译器(包括JVM、JIT编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。


   (2) 指令并行重排:   


现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。


另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在JMM里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。


Java 源代码会经历编译器优化重排—>指令并行重排一>内存系统重排的过程,最终才变成操作系统可执行的指令序列。


指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致,所以在多线程下,指令重排序可能会导致一些问题。


对于编译器优化重排,和处理器的指令重排序(指令并行重排和内存系统重排都属于是处理器级别的指令重排序),处理该问题的方式不一样。


  • 对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。
  • 对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。


另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。


  3. 指令重排序类型  

   (1)编译器重排序   

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


   (2) 处理器重排序   

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


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

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


  c96f743646e841f8bb30b2d242197f2f.gif

692a78aa0ec843629a817408c97a8b84.gif

http://www.lryc.cn/news/482785.html

相关文章:

  • MySQL电商多级分类表设计方案对比
  • 网络安全工程师需要知道哪些IPSec的基本原理?
  • leetcode 148. 排序链表 中等
  • 动态规划与贪心算法:核心区别与实例分析
  • .NET 公共语言运行时(Common Language Runtime,CLR)
  • SpringBoot使用TraceId日志链路追踪
  • YOLO11 旋转目标检测 | OBB定向检测 | ONNX模型推理 | 旋转NMS
  • PCL 点云拟合 拟合空间直线
  • 我的创作纪念日-20241112-感谢困难
  • 苍穹外卖05-Redis相关知识点
  • unity 玩家和炸弹切线计算方式
  • 【MySQL】MySQL中的函数之REGEXP_LIKE
  • 跟着尚硅谷学vue2—进阶版4.0—Vuex1.0
  • 深度学习服务器租赁AutoDL
  • excel常用技能
  • Mac电脑中隐藏文件(即以 . 开头的文件/文件夹)的显示和隐藏的两种方法
  • 【Linux】:进程信号(信号概念 信号处理 信号产生)
  • Flink运行时架构以及核心概念
  • 用 Python 从零开始创建神经网络(五):损失函数(Loss Functions)计算网络误差
  • [CKS] K8S RuntimeClass SetUp
  • 【Python爬虫实战】轻量级爬虫利器:DrissionPage之SessionPage与WebPage模块详解
  • 计算机网络-2.1物理层
  • 纯血鸿蒙系统 HarmonyOS NEXT自动化测试实践
  • C 语言标准库 - <errno.h>
  • Golang自带的测试库testing的使用
  • 29.电影院售票系统(基于springboot和vue的Java项目)
  • 大学生就业平台微信小程序
  • Redis 缓存击穿
  • 初探鸿蒙:从概念到实践
  • PHP API的路由设计思路