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

关于懒汉饿汉模式下的线程安全问题

1.java标准库中的线程安全

java标准库中有很多的都是线程不安全的,这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施,如下所示:

上面的这些类中都没有进行任何的加锁限制,因此线程不安全,但是还有一些是线程安全的,使用了一些锁机制来控制。

其中的ConcurrentHashMap但是加锁这件事情也不是没有代价的,一旦在代码中使用了锁意味着代码可能会因为锁的竞争产生阻塞,程序的效率也大打折扣。

同时还有一些虽然没有加锁,但是不涉及“修改”,仍然是线程安全的

因为String里面没有提供public的修改方法

2.volatile关键字

在说volatile之前,我们先说一下内存可见性问题。我们首先写一个代码,如下图所示:

关于上面的代码,我们虽然输入了非0的值,但是此时t1线程循环并没有结束t1线程继续执行,很明显这个也是bug。上述代码一个线程读取,一个线程修改,修改线程的值并没有被线程读取到,这就是内存可见性问题。那为什么会出现这样的现象呢?这是因为编译器优化的问题。程序员写代码的水平层次不齐,研究JDK的大佬们就希望通过 编译器& JVM对程序员的代码自动优化。本来写的代码是进行xxxxx,编译器会在你原有的逻辑不变的前提下,对代码进行调整使程序执行的效率更高。虽然编译器声称优化操作是能够保持逻辑不变,尤其在多线程的程序中,编译器的判断可能出现失误,可能导致编译器的优化,使优化后的逻辑和优化前的逻辑出现细节上的偏差。

那如果稍微调整上述代码sleep1秒,那会如何?

结果如下所示:

本来这个循环转的飞起来,1s钟几千万次上亿次,但是如果加了sleep(1)之后循环次数大幅度降低了,假设本身读取flag的操作是1ns的话,如果把内存操作优化成寄存器可以优化50%以上,如果引入sleep,sleep直接占用1ms,此时优不优化flag无足轻重。就相当于你全部身家有几百万,丢100就无所谓了,但是你只有500的化丢100得话影响就比较大了。

那我们该如何解决这样的问题呢?

编译JDK的大佬知道上述的可见性问题在编译器优化的角度难以进行调整,在语法上引入volatite关键字,通过这个关键字来修饰某个变量,此时编译器对这个读取操作就不会优化成读取寄存器。

这样的变量读取操作时就不会被编译器进行优化了,但是volatile不保证原子性保证的是内存的可见性。

3.wait和notify

由于线程之间的时抢占式执行的,因此线程之间的执行顺序是难以预知的,但在实际开发中有的时候我们希望合理的协调多个线程之间的执行顺序。因此这个时候引入了wait和notify,协调线程之间的执行逻辑顺序。可以让后执行的逻辑,等待先执行的逻辑,虽然无法干预调度器的调度顺序但是可以让后执行的逻辑等待,等待到先执行的逻辑跑完了,通知以下当前线程让他继续执行。当然我们前面也讲了等待join,join是等待另一个线程彻底执行完之后才继续走,而wait也是等,等到另一个线程执行到notify才继续走(不需要另一个线程执行完)。

wait:让当前线程进入等待状态

notify:唤醒在当前对象上等待的线程

接下来我们写一个关于wait 、notify的函数,wait和notify都是Object的方法,Java中的任意对象都提供了wait和notify

接下来我们运行这个代码发现运行异常,上面显示非法的锁状态(monitor表示锁),那为什么会出现这样的现象呢?收先我们分析一下Objiect.wait(),它一上来就是释放Objiect对象对应的锁。能够释放锁的前提是Objiect对象应该处于加锁的状态,才能释放锁。那为什么wait要先释放锁呢?wait这个等待最关键的一点就是要先释放锁,给其他线程取锁的机会。但是wait要先加锁才能谈上释放。

接下来我们看看在代码中wait表示的几种状态

 在等待的过程中谈不上线程安不安全,同时这里要求synchronized的锁对象必须和wait()的对象是同一个。接下来我们来运行一个带有notify的代码

在这段代码中,Sc.next()是带有阻塞的操作等待用户在控制台输入,notify这里同样也是需要先拿到锁再进行notify(属于java给出的限制),wait操作必须搭配锁来进行因为wait需要释放锁,但是notify操作原则上说不涉及加锁的操作但是java也是强制要求notify搭配synchronized,线程、锁都是操作系统本身支持的特性,wait和notify在操作系统中也有原生的对应的api,原生api中wait必须搭配锁来使用notify则不需要。同时notify和wait的锁对象必须相同,这样才能生效,如果是两个不同的对象则没有任何的影响和作用。线程的调度顺序我们无法决定但是notify的执行要在wait之后,这样才有作用,如果先notify后wait此时的wait无法被唤醒,notify的这个线程这个时候也没有副作用(notify一个没有wait的对象不会报错)。接下来我们再来看看多个wait对应一个notify会怎么样?

import java.util.Scanner;public class Demo3 {public static void main(String[] args) {Object object = new Object();Thread t1 = new Thread(()->{try{System.out.println("wait1之前");synchronized(object){object.wait();}System.out.println("wait1之后");}catch (InterruptedException e){throw new RuntimeException();}});Thread t2 = new Thread(()->{try{System.out.println("wait2之前");synchronized(object){object.wait();}System.out.println("wait2之后");}catch (InterruptedException e){throw new RuntimeException();}});Thread t3 = new Thread(()->{Scanner sc = new Scanner(System.in);System.out.println("输入任意内容唤醒");sc.next();synchronized (object) {object.notify();}});t1.start();t2.start();t3.start();}}

如果有多个线程在同一个对象上wait进行notify的时候是随机唤醒其中的一个线程,一次notify唤醒一个线程。但是如果把notif换成notifyAll()之后两个线程都会被唤醒。

虽然同时唤醒了t1和t2,由于wait唤醒之后,要重新加锁,其中某个线程先加锁开始执行,另一个线程因为加锁失败再次阻塞等待,等到第一个线程先走的线程解锁之后,后走的线程才能加上锁继续执行。同时wait和join也是类似的提供死等版本和超时版本。

wait引入超时时间之后直观上看起来就和sleep很像。

相似点:

wait有等待时间,sleep也有等待时间

wait可以使用notify唤醒,sleep也可以使用Interrupt提前唤醒。

不同点:

wait和sleep最主要的区别在针对锁的操作。

1.wait必须要搭配锁,先加锁才能使用wait,sleep不需要

2.如果都是在synchronized内部使用,wait会释放锁,sleep不会释放锁

Interrupt看起来是唤醒sleep其实本身的作用就是通知线程终止

4.多线程的代码案例编写

4.1单例模式

单例模式是一种设计模式,什么是设计模式?在围棋中,针对红方的一些走法,黑方应招的时候有一些固定的套路,按照套路走局势不会吃亏。软件开发中也有很多的常见的问题场景,针对这些问题场景,大佬们总结了一些固定的套路,按照套路来实现代码不会吃亏。那什么是单例模式呢?单例就是单个对象,强制要求某个类在某个程序中只有唯一一个实例(不允许创建多个实例,不允许new多次)。只有一个实例(实例指的是一个对象一台服务器也能称为一个实例)这样的要求在开发中是非常常见的常景,有些程序编写时需要启动很多的数据到内存中,如果实例多次那么内存就会不够,为了硬性要求在代码中不能创建多个对象从而设计的一套模式,就是单例模式。单例模式具体实现方式有很多种,最常见的是“饿汉”和“懒汉”。

4.1.1 饿汉模式

private static Singleton instance = new Singleton();
关于这句静态成员的初始化是在类的加载的阶段触发的,类加载往往就是在一个程序一启动就会触发

同时我们使用private私有化,这样的单例模式在类外面进行new操作,都会编译失败。

 4.1.3 懒汉模式

在程序中懒是一个褒义词,懒在程序中是高效率的意思。假设有一个很大的文件(千万级别),编译器一打开如果是饿汉模式的话会把所有的内容都从文件中加载带内存中再显示,而懒汉模式只是把一部分内容加载并显示,后续如果用户翻页随时加载后续数据。

懒汉模式的代码:

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

 在懒汉模式下,创建实例的时机是在第一次使用的时候,而不是在程序启动的时候。其实懒汉模式和俄汉模式都存在缺陷,比如可以通过反射的方式来创建该类的实例,本身属于“非常规的编程手段”,但是在日常开发中也不推荐使用反射。

4.1.4懒汉模式与饿汉模式的安全问题(重点)

刚才编写的两份代码是否存在线程安全问题?或者说这两个版本的getInstance在多线程环境下调用是否会出bug?

在饿汉模式下直接return instance,return是读操作在多线程的情况下不会引发线程安全问题。在多线程情况下如果涉及一个是读操作,一个是写操作就会触发线程安全问题。

但是在懒汉模式下,这里可能会涉及多线程的修改,getInstance是线程不安全的。因为instance = new SingletonLazy涉及赋值操作会修改而return instance是读操作,而且instance是static修饰的,只有一份这样的数据不管是赋值操作还是读操作都是针对一个变量进行的,一个修改一个不修改就会造成线程安全问题。很多人这个时候可能会想到,“=” 不是原子的操作呀,但是这里的“=”是有判断条件的是在if条件下进行的,这两个操作加在一起就不是原子性的啦。

 接下来我们来模拟实现以下在多线程操作下。我们假设有t1和t2两个线程,t1先进行判断接着t2进行判断都是空进入if,切换到t1线程进行一次实例化操作,接着又切换到t2进行一次实例化操作。但是我们不是在单例模式下只能实例一次的吗?这样代码就出现了Bug。同时这里也出现了覆盖的操作,后面一次实例会覆盖前面一次前面一次的实例会被垃圾回收,但是这不是主要的问题主要的问题是原子性的问题,针对原子性的问题前面已经说过可以通过加锁的方式进行解决。

 那加锁之后我们来测试一下这个锁是否会成功。加锁之后后执行的线程就会在前一个加锁的位置进行阻塞等待阻塞到前一个线程解锁。当后一个线程进入条件的时候前一个线程已经修改完毕Instance不再为空,就不会再进行new操作了,这样看线程安全问题好像解决了。解下来我们仔细分析一下是否还有其他的问题。

 我们第一次把实例创建好了之后,后续再调用getInstance此时都是直接执行return,如果只是进行if判断+return纯粹的读操作了不涉及线程安全问题,但是每次调用上述的方法都会触发一次加锁操作,虽然不涉及线程安全问题,但是在多线程情况下这里的加锁就会相互阻塞,从二影响程序的执行效率。这个时候我们就可以通过前面再加一个if条件判断什么情况下加锁什么情况下不需要加锁。

以往都是单线程中,连续两个相同的if是无意义的,单线程中的执行流只有一个,上一个if的判定结果和下一个if是一样得,但是在多线程中两次判定之间可能存在其他线程,if中的insta变量如果修改了,也就导致这里的if结论可能不同。而且在这里这两个条件进行判断后执行的步骤是不一样的,第一个是判断是否加锁,第二个是判断是否需要new对象。synchronized这里的代码也说明上述的加锁操作可能会引起阻塞,一旦阻塞此时对于计算机的时间间隔就是沧海桑田。那我们接着分析一下这段代码是否还存在问题呢?

如果进行上述的操作:t1在读取Instance的时候t2进行Instance的修改。那是否会出现内存可见性的问题呢?可能在读取的时候编译器进行优化,没有直接从内存中读取而是在寄存器上读取也是有可能的这是修改的数据就无效了,对于编译器的优化问题是非常复杂的谁也不能够保证绝对不会出现这样的问题。但是为了稳妥起见,可以给Instance直接加上一个volatile从根本上杜绝内存可见性问题。

其实相比于内存可见性的问题,更关键的问题是指令重排序的问题,这也是编译器进行优化的一种体现形式,编译会在逻辑不变的前提下,调整你代码的先后顺序,以达到性能提升的效果。这里我们可以举例说明情况,我们去超市买菜比如说要买西红柿、鸡蛋、茄子、黄瓜,但是超市的摆放顺序先是茄子再是鸡蛋、西红柿、黄瓜。如果按照上面的指令我们路线会比较绕,这个时候我们就可以按照超市摆放的顺序进行购买。这个时候我们明显感受到第一种路径会更好。

指令重排序问题的大前提是逻辑不变,在多线程的环境下这里的判断可能出现错误。在该段代码中instance = new SingletonLazy()赋值操作可以分为好几部分。

1.申请内存空间

2.在空间上构造对象

3.内存空间的首地址,赋值给引用对象

正常来说这三个步骤,按照1、2、3这样的顺序来执行的,但是在指令重排序下可能成为132这样的顺序来执行,在单线程下这样操作是无所谓的但是在多线程环境下1 3 2这样的顺序是会出现bug的。我们可以举个例子,比如我们把这三个步骤打比方成以下三个步骤

1.买房 

2.拿到钥匙

3.装修

而在多线程的情况会出现以下情况左边为t1线程,右边为t2线程。左边先执行1,3,接下来再切换执行右边此时的instance不为空直接返回Instance最后返回的就是一段乱码,后面再进行操作时拿到的可能就是乱码就相当于还没装修就住进去了。这样的情况也和双重if有关,开始的时候我们为了考虑效率直接在前面加了if判定条件此时没有加锁就不会阻塞等待,代码直接执行下去就会造成指令重排序这样的问题。

因此这里面加volatile的功能有两方面:

1.确保每次读取操作都是读取内存

2.关于该变量的读取和修改操作,不会触发重排序


http://www.mrgr.cn/news/55803.html

相关文章:

  • Shiro框架认证机制详解
  • 什么样的JSON编辑器才好用_
  • Qt调用Yolov11导出的Onnx分类模型开发分类检测软件
  • GRPC 压缩算法
  • C++源码生成·序章
  • Spring Boot 核心理解-自定义Starter
  • C++基础与实用技巧第三课:内存管理与性能优化
  • 字典学习算法
  • Stylish Archer Assets Pack 女弓箭手射箭动画动作
  • Docker 部署 EMQX 一分钟极速部署
  • 什么是运动控制器?运动控制器的特点
  • Echarts 点击事件无法使用 this 或者 this绑定的数据无法获取
  • 使二进制数组全部等于 1 的最少操作次数 II
  • 回归预测||时序预测||基于灰狼优化的时域卷积TCN连接Transformer-BiLSTM的数据回归预测|时序预测Matlab程序
  • 现代C语言:C23标准重大更新
  • Moectf-week1-wp
  • WSL2Linux 子系统(十三)
  • Mybatis 中<where>的用法注意事项(附Demo)
  • 商场楼宇室内导航系统
  • 不再手动处理繁琐任务!Python自动化方案梳理
  • 【力扣刷题实战】用队列实现栈
  • SpringBoot整合mybatisPlus实现批量插入并获取ID
  • 用docker Desktop 下载使用thingsboard/tb-gateway
  • 【Java面试——并发编程——相关类和关键字——Day4】
  • 华为OD机试 - 生成哈夫曼树(Python/JS/C/C++ 2024 D卷 100分)
  • 快快收藏!学习 Python 最常访问的10个网站