「JavaEE」线程安全2:内存可见性问题 wait、notify

🎇个人主页:Ice_Sugar_7
🎇所属专栏:JavaEE
🎇欢迎点赞收藏加关注哦!

内存可见性问题& wait、notify

  • 🍉Java 标准库的线程安全类
  • 🍉内存可见性问题
    • 🍌volatile 关键字
  • 🍉wait & notify
    • 🍌wait 和 join、sleep 的区别
  • 🍉小结

🍉Java 标准库的线程安全类

线程安全线程不安全
Vector(不推荐使用)ArrayList
HashTable(不推荐使用)LinkedList
ConcurrentHashMapHashMap
StringBufferTreeMap
StringHashSet
TreeSet
StringBuilder

这几个线程安全的类在关键的方法上加了 synchronized

不过也不是说加了 synchronized 就一定是线程安全的,关键还得看具体代码是怎么写的。就比如一个线程加锁,一个不加锁,或者两个线程给不同对象加锁,虽然都有 synchronized,但仍然存在线程安全问题


🍉内存可见性问题

先来看一个代码:

public class Main {public static int flag = 0;public static void main(String[] args) {Thread t1 = new Thread(()-> {while(flag == 0) {}System.out.println("t1 线程结束");});Thread t2 = new Thread(()-> {System.out.println("请输入 flag 的值:");Scanner in = new Scanner(System.in);flag = in.nextInt();});t1.start();t2.start();}
}

在这里插入图片描述
程序运行起来后,我们会发现输入一个非 0 的数后等不到 “t1 线程已经结束” 这句话,说明 t1 线程始终在循环里面

这个 bug 和内存可见性问题有关
t1 线程中的 while 循环有两条核心指令:

  1. load 读取内存中 flag 的值到 cpu 寄存器中
  2. 拿寄存器的值和 0 进行比较(这涉及到条件跳转指令)

因为循环体是空的,所以循环执行速度会非常快,在 t2 线程执行输入之前,t1 就已经执行了上百亿次循环,而这些循环每次 load 操作的执行结果都是一样的(flag 都为 0)

频繁执行 load 指令会有很大的开销,并且每次 load 的结果都一样,那此时 JVM 就会怀疑这里的 load 操作是否真有存在的必要。所以 JVM 可能优化代码,把上面的 load 给优化掉(就相当于没有 load 这一步了,这种做法比较激进,不过确实可以提高循环的执行速度)

load 被优化之后,就不会再读取内存中 flag 的值了,而是直接使用寄存器之前缓存的值,也就是 flag == 0,所以即使后面我们通过输入改了 flag 的值,但为时已晚

这里就相当于 t2 修改了内存,但是 t1 没看到内存的变化,这就称为内存可见性问题

补充:很多代码会涉及到代码优化,JVM 会智能分析出当前写的代码哪里不太合理,然后在保证原有逻辑不变的前提下调整代码,提高程序效率。不过 “保证逻辑不变”不是一件易事,如果是单线程,那还比较好调整,而如果是多线程,那么很容易出现误判(可以视为 bug)

内存可见性问题高度依赖编译器优化,啥时候会触发这个问题,啥时候不会触发,其实不好说

🍌volatile 关键字

不过我们更希望无论代码怎么写,都不会出现这个问题,所以可以用 volatile 关键字,它可以强制关闭上述的编译器优化,这样就可以确保每次循环都会从内存中读取数据
既然是强制读取内存数据,那么开销势必会变大,效率也会因此降低,不过数据的准确性和逻辑的正确性都提高了

volatile 除了可以保证内存可见性,还可以禁止指令重排序,这个后面再讲


🍉wait & notify

我们知道,多个线程之间是随机调度的,而引入 wait 和 notify 是为了能从应用层面上干预不同线程的执行顺序。
注意这里所说的“干预”不是影响系统的线程调度策略(系统调度线程仍是无序的),而是让后执行的线程主动放弃被调度的机会,这样就能让先执行的线程把对应的代码执行完

考虑这样一个场景:有多个线程在竞争同一把锁,其中线程 t1 拿到了锁,但是它不具备执行逻辑的前提条件,也就是说它拿到锁后没法做啥
t1 释放锁之后还会和其他线程一起竞争锁,它就有可能再次拿到锁。反复获取锁但是啥都没做导致其他线程无法拿到锁
,这种情况称为线程饿死(线程饥饿)

这种问题属于概率性事件,并且发生概率还不低,因为 t1 在拿到锁时处于 RUNNABLE 状态,其他线程由于锁冲突而处于 BLOCKED 状态,需要唤醒后才能参与到锁竞争,而 t1 不用,所以 t1 在释放锁之后比较容易再次拿到锁。好在线程饿死不像死锁那样“一旦出现,程序就会挂”,但是也会极大影响其他线程运行
在这种情况下,它就应该主动放弃争夺锁(主动放弃到 cpu 上调度执行),进入阻塞状态,等到条件具备了再解除阻塞,参与竞争。这个过程简单概括就是“把机会留给有需要的人”
此时就可以使用 wait 和 notify。看 t1 是否满足当前条件,若不满足则 wait,等到有其他线程让条件满足之后,再通过 notify 唤醒 t1

wait 内部会做三件事:

  1. 释放锁
  2. 进入阻塞等待
  3. 当其他线程调用 notify 时,解除阻塞,并重新获取到锁

通过 1、2 这两步,就可以让其他线程有机会拿到锁

接下来说一下如何使用 wait
既然要释放锁,说明要先拿到锁,所以 wait 必须放在 synchronized 中使用。并且 wait 和 sleep、join 一样有可能会被 interrupt 提前唤醒,所以也要用 try-catch 语句
至于 notify,Java中特别约定要把它也放在 synchronized 里面
这两个方法都是由锁对象调用
下面拿段代码演示一下

public class Main {public static void main(String[] args) {Object locker = new Object();Thread t1 = new Thread(()->{synchronized (locker) {System.out.println("t1 wait 之前");try {locker.wait();} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("t1 wait 之后");}});Thread t2 = new Thread(()-> {try {Thread.sleep(3000);synchronized (locker) {System.out.println("t2 notify 之前");locker.notify();System.out.println("t2 notify 之后");}} catch (InterruptedException e) {throw new RuntimeException(e);}});t1.start();t2.start();}
}

运行结果:
在这里插入图片描述
由结果来梳理一下上述代码的执行过程:

  1. t1 执行后会先拿到锁(因为 t2 sleep 5秒,这个时间够 t1 拿到锁了),并且打印第一句,执行 wait 方法后释放锁并进入阻塞状态
  2. t2 sleep 结束后顺利拿到锁并打印第二句,接着执行 notify 唤醒 t1
  3. 由于 t2 还没释放锁,所以 t1 从 WAITING 状态恢复后尝试获取锁,此时会出现一个小阻塞,这个阻塞是由锁竞争引起的
  4. t2 打印第三句之后 t2 线程执行完毕,此时 t1 可以获取到锁了,就会继续打印第四句

wait 和 notify 是通过 Object 对象联系起来的,需要同一个锁对象才能唤醒,比如下面这样是无法唤醒的

locker1.wait();
locker2.notify();

如果两个 wait 是同一个对象调用的,那 notify 会随机唤醒其中一个
如果想要一次性唤醒所有等待的线程,可以用 notifyAll。不过全唤醒后这些线程要重新获取锁,就会因为锁竞争导致它们实际上是串行执行的(谁先拿到,谁后拿到,是不确定的)

🍌wait 和 join、sleep 的区别

join 是等待另一个线程执行完才会继续执行(死等的情况下)
wait 则是等待其他线程通过 notify 通知才继续执行(也是死等的情况下),相比于 join 就不要求另一个线程必须执行完

和 join 一样,wait 也提供了带有超时时间的等待,超过超时时间没有线程来 notify 的话,就不会再等下去了

wait 和 sleep 都可以被提前唤醒。分别通过 notify 和 interrupt 唤醒
wait 主要是在不知道要等待多久的前提下使用的;而 sleep 是在知道要等多久的前提下使用的,虽然可以提前唤醒,但由于它是通过异常唤醒的,而这说明程序可能出现了一些特殊的情况,所以这种操作不应该作为正常的业务流程


🍉小结

至此,多线程的一些基础用法已经讲得差不多了,在这里总结一下学了啥

  1. 线程的基本概念、线程的特性、线程和进程的区别
  2. Thread 类创建线程
  3. Thread 类一些属性
  4. 启动线程、终止线程、等待线程
  5. 获取线程引用
  6. 线程休眠
  7. 线程状态
  8. 线程安全问题
    ①产生原因
    ②如何解决——使用 synchronized 加锁
    ③死锁问题
    ④内存可见性导致的线程安全问题——使用 volatile 保证内存可见性
  9. wait 和 notify 控制线程执行顺序

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/web/6501.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

M2M vs. IoT?

有任何关于GSMA\IOT\eSIM\RSP\业务应用场景相关的问题,欢迎W: xiangcunge59 一起讨论, 共同进步 (加的时候请注明: 来自CSDN-iot). 连接设备已经开辟了创造价值和解决重大世界问题的广泛机会,例如可持续发展。 今天,我们网络设备的方式可…

【linuxC语言】vfork、wait与waitpid函数

文章目录 前言一、函数使用1.1 vfork1.2 wait1.3 waitpid 二、示例代码总结 前言 在Linux系统编程中,vfork()、wait() 和 waitpid() 函数是处理进程管理和控制流的重要工具。这些函数允许我们创建新进程、等待子进程结束并获取其退出状态,从而实现进程间…

GDPU JavaWeb 猜字母游戏

他在对你重定向打卡的大饼与立即跳转到你面前的谎言之间反复横跳。 sendRedirect与forward sendRedirect与forward区别 sendRedirect用于将请求重定向到另一个资源,可以是同一个应用程序内的其他 Servlet,也可以是其他 Web 应用程序的资源,…

农作物害虫检测数据集VOC+YOLO格式18975张97类别

数据集格式:Pascal VOC格式YOLO格式(不包含分割路径的txt文件,仅仅包含jpg图片以及对应的VOC格式xml文件和yolo格式txt文件) 图片数量(jpg文件个数):18975 标注数量(xml文件个数):18975 标注数量(txt文件个数):18975 标…

C++入门系列-类对象模型this指针

&#x1f308;个人主页&#xff1a;羽晨同学 &#x1f4ab;个人格言:“成为自己未来的主人~” 类对象模型 如何计算类对象的大小 class A { public:void printA(){cout << _a << endl;} private:char _a; }; 算算看&#xff0c;这个类的大小是多少 我们知道…

掌握JavaScript面向对象编程核心密码:深入解析JavaScript面向对象机制对象概念、原型模式与继承策略全面指南,高效创建高质量、可维护代码

ECMAScript&#xff08;简称ES&#xff0c;是JavaScript的标准规范&#xff09;支持面向对象编程&#xff0c;通过构造函数模拟类&#xff0c;原型链实现继承&#xff0c;以及ES6引入的class语法糖简化面向对象开发。对象可通过构造函数创建&#xff0c;使用原型链共享方法和属…

从0开始linux(1)——文件操作

欢迎来到博主的专栏——从0开始linux 博主ID&#xff1a;代码小豪 博主使用的linux发行版是&#xff1a;CentOS 7.6 不同版本下的操作可能存在差异 文章目录 命令文件操作命令文件树和文件路径文件树绝对路径相对路径 文件属性tree指令删除文件复制文件 大家还记得在小学第一次…

Amazon EKS创建EBS的存储类

1、创建 Amazon EBS CSI 驱动程序 IAM 角色 相关文档 先决条件&#xff0c;是否有 IAM OIDC 提供商&#xff0c;详情 IAM OIDC 提供商创建文档 IAM OIDC 提供商id 在 Select trusted entity&#xff08;选择受信任的实体&#xff09;页面上操作&#xff0c;最后点击下一步 在…

代码随想录算法训练营第25天 | 216.组合总和III、17.电话号码的字母组合

代码随想录算法训练营第25天 | 216.组合总和III、17.电话号码的字母组合 自己看到题目的第一想法看完代码随想录之后的想法 链接: 216.组合总和III 链接: 17.电话号码的字母组合 自己看到题目的第一想法 216.组合总和III&#xff1a;递归函数终止条件为搜索得到的数相加为n&…

ssh远程访问windows系统下的jupyterlab

网上配置这一堆那一堆&#xff0c;特别乱&#xff0c;找了好久整理后发在这里 由于既想打游戏又想做深度学习&#xff0c;不舍得显卡性能白白消耗&#xff0c;这里尝试使用笔记本连接主机 OpenSSH 最初是为 Linux 系统开发的&#xff0c;现在也支持包括 Windows 和 macOS 在内…

【JAVA项目】基于SSM的【寝室管理系统设计】

技术简介&#xff1a;采用B/S架构、ssm 框架和 java 开发的 Web 框架&#xff0c; eclipse开发工具。 系统简介&#xff1a;寝室管理设计的主要使用者分为管理员、宿舍长和学生&#xff0c;实现功能包括管理员权限&#xff1a;首页、个人中心、学生管理、宿舍号管理、宿舍长管理…

链舞算法谱---链表经典题剖析

前言&#xff1a;探究链表算法的奥秘&#xff0c;解锁编程新世界&#xff01; 欢迎来到我的链表算法博客&#xff0c;这将是您深入了解链表算法&#xff0c;提升编程技能的绝佳机会。链表作为数据结构的重要成员之一&#xff0c;其动态性和灵活性在实现某些功能上发挥不可替代的…

生成树协议(STP,MSTP,RSTP)详解

目录 STP生成树协议 二层环路出现的原因&#xff1a; 二层环路引发的危害&#xff1a; stp生成树防环的基本思路&#xff1a; 802.1D生成树协议&#xff1a; 配置BPDU的报文结构&#xff1a; 配置BPDU中某些字段的解析&#xff1a; TCN BPDU报文格式&#xff1a; stp中…

Java中接口的默认方法

为什么要使用默认方法 当我们把一个程序的接口写完后 用其他的类去实现&#xff0c;此时如果程序需要再添加一个抽象方法的时候我们只有两种选择 将抽象方法写在原本的接口中 但是这样写会导致其他所有改接口的实现类都需要实现这个抽象方法比较麻烦 写另一个接口 让需要的实…

程序的机器级表示——Intel x86 汇编讲解

往期地址&#xff1a; 操作系统系列一 —— 操作系统概述操作系统系列二 —— 进程操作系统系列三 —— 编译与链接关系操作系统系列四 —— 栈与函数调用关系操作系统系列五 —— 目标文件详解操作系统系列六 —— 详细解释【静态链接】操作系统系列七 —— 装载操作系统系列…

基于肤色模型的人脸识别FPGA实现,包含tb测试文件和MATLAB辅助验证

目录 1.算法运行效果图预览 2.算法运行软件版本 3.部分核心程序 4.算法理论概述 5.算法完整程序工程 1.算法运行效果图预览 matlab2022a的测试结果如下&#xff1a; vivado2019.2的仿真结果如下&#xff1a; 将数据导入到matlab中&#xff0c; 系统的RTL结构图如下图所示…

多态的原理

前言:以下的内容均是在VS2019的环境中&#xff0c;32位平台下的 目录 1.多态的实现条件 虚函数重写的两个例外 一个题加深理解 总结 重载 重写 重定义区别 2.多态的实现原理 单继承 多继承 动态多态和静态多态 多态的好问题 1.多态的实现条件 虚函数&#xff1a;被…

使用Ruoyi的定时任务组件结合XxlCrawler进行数据增量同步实战-以中国地震台网为例

目录 前言 一、数据增量更新机制 1、全量更新机制 2、增量更新机制 二、功能时序图设计 1、原始请求分析 2、业务时序图 三、后台定时任务的设计与实现 四、Ruoyi自动任务配置 1、Ruoyi自动任务配置 2、任务调度 总结 前言 在之前的相关文章中&#xff0c;发表文章列…

2024年 Java 面试八股文——SpringBoot篇

目录 1. 什么是 Spring Boot&#xff1f; 2. 为什么要用SpringBoot 3. SpringBoot与SpringCloud 区别 4. Spring Boot 有哪些优点&#xff1f; 5. Spring Boot 的核心注解是哪个&#xff1f;它主要由哪几个注解组成的&#xff1f; 6. Spring Boot 支持哪些日志框架&#…

应用分层和企业规范

目录 一、应用分层 1、介绍 &#xff08;1&#xff09;为什么需要应用分层&#xff1f; &#xff08;2&#xff09;如何分层&#xff1f;&#xff08;三层架构&#xff09; MVC 和 三层架构的区别和联系 高内聚&#xff1a; 低耦合&#xff1a; 2、代码重构 controlle…