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

多线程(4)——线程安全,锁

目录

  • 线程不安全代码示例
      • 问题分析
  • 线程安全的概念
  • 线程不安全的原因
    • 【根本原因】线程调度是随机的
    • 修改共享数据(多个线程同时修改同一个变量)
    • 原子性(修改操作非原子)
    • 内存可见性
    • 指令重排序
  • synchronized 关键字 --监视器锁monitor lock
      • 1.synchronized(锁对象){ }
      • 2.锁普通方法,可以省略锁对象
      • 3.锁静态方法
    • 死锁
      • 触发死锁的三种情况
      • 死锁的危害
      • 如何避免死锁?
    • synchronized 的特性
      • 互斥
      • 可重入
    • synchronized 使用示例
      • 线程不安全代码解决
  • Java标准库中的线程安全类
  • volatile 关键字
    • volatile 能保证内存可见性
    • volatile 不保证原子性
  • wait和notify
    • wait() 方法
    • notify() 方法
    • notifyAll() 方法
    • wait和sleep 的对比(面试题)


线程不安全代码示例

在这里插入图片描述
运行结果却是:
在这里插入图片描述
在这里插入图片描述
甚至每次的运行结果都不一样,这就出现了线程不安全问题

问题分析

上面代码中t1 和 t2 两个线程在同时修改 count 这个变量
并且修改操作不是“原子的”,就会产生上述问题
像count++ 这样的操作,在CPU层面上来说,其实是三个指令(CPU执行的任务的具体细节)
CPU会一条一条的 读取指令,解析指令,执行指令
对于CPU来说,每个指令都是执行的最基本单位,由于操作系统的调度是随机的,某个线程执行到任意一个指令的时候都可能会触发CPU的调度
count++ 本质上对应三个指令
1.load 把内存中的数值加载到寄存器中
2.add 把寄存器中的数据进行加一操作,结果还是放到寄存器中
3.save 把寄存器中的值写回到内存里

在这里插入图片描述
整个循环2w次的过程中,就不知道多少次是 “正确” 的情况,多少次是 “错误” 的情况(因为线程的调度顺序是随机的

线程安全的概念

某一段代码,在单线程环境下执行是正确的,但是放到多线程环境下执行,就会产生bug,这就是线程不安全

线程不安全的原因

【根本原因】线程调度是随机的

修改共享数据(多个线程同时修改同一个变量)

注意
一个线程修改一个变量,没事
多个线程读取同一个变量,没事
多个线程修改不同变量,没事
只有当多个线程同时修改同一个变量,才会出现线程不安全问题

原子性(修改操作非原子)

像 count++ 这样的修改就不是原子的
但是像 = (赋值)这样的操作,就属于原子的(在Java中针对 内置类型进行赋值,或者针对引用赋值 都是原子的)

内存可见性

首先观察一段代码:
在这里插入图片描述
预期情况:
t1开始
t2开始
输入flag的值–>
1
t1结束
实际情况:
在这里插入图片描述
t2 线程对flag 变量的修改,对于 t1 线程“不可见了”
为什么会出现这种问题?因为内存可见性

编译器优化
编译器设计者考虑到程序员的代码水平参差不齐,为了提高代码的执行效率,对代码自动进行分析优化,在确保程序执行逻辑不变的前提下提高程序的效率
编译器优化的效果很明显,但大前提是“程序的逻辑不变”
大多数情况下,编译器优化都可以做到“逻辑不变”前提,但是在有些特定场景下,编译器优化可能会出现“误判” 导致逻辑发生改变,比如 多线程代码

对于上面的代码来说,编译器看到的效果是:
有一个变量flag,会快速的,反复的读取这个内存的值(反复执行 load,compare,load,cmp…),同时,反复执行的过程中,每次拿到的 flag 的值还都是一样的
上述的 load 会触发读内存,比cmp(读寄存器)或缓存 耗时会多很多【读内存比读寄存器效率慢很多】
既然load 督导的值都一样,而且load 开销怎么多,于是编译器就直接把从内存读取flag 这个操作优化了(不读内存了)
这时t2 虽然修改了flag 的值,但是编译器无法判断 flag 修改会不会执行,什么时候执行

上述内存可见性问题,是编译器优化机制自身出现的bug
为了解决上述问题,Java中有一个关键字能够解决【volatile】(在后面提到了,详情可看目录传送门)

指令重排序

什么是代码重排序
⼀段代码是这样的:

  1. 去前台取下U盘
  2. 去教室写10分钟作业
  3. 去前台取下快递

如果是在单线程情况下,JVM、CPU指令集会对其进⾏优化,⽐如,按1->3->2的⽅式执⾏,也是没问
题,可以少跑⼀次前台。这种叫做指令重排序

编译器对于指令重排序的前提是"保持逻辑不发⽣变化".这⼀点在单线程环境下⽐较容易判断,但是
多线程环境下就没那么容易了,多线程的代码执⾏复杂程度更⾼,编译器很难在编译阶段对代码的
执⾏效果进⾏预测,因此激进的重排序很容易导致优化后的逻辑和之前不等价.

重排序是⼀个⽐较复杂的话题,涉及到CPU以及编译器的⼀些底层⼯作原理,此处不做过多讨论

synchronized 关键字 --监视器锁monitor lock

synchronized中文翻译为 同步的,同步化的
相当于“协调顺序”,把非原子操作协调为同时进行

对于锁的概念,会涉及两个核心操作
加锁 解锁
在Java中就用这一个关键字来表示(用代码块框起来)
如:

synchronized(){//进入就是加锁count++;
}//出来就是解锁

在synchronized() 的括号中加入 锁对象,才真正把一系列操作“锁在一个房间”,这样别人就不能对这个正在执行的操作进行干预,只能等外面的人开锁出来,外面的人才能进去

1.synchronized(锁对象){ }

在Java中,任何一个对象都可以作为 锁对象(引用类型,不能是内置类型),如

private static Object locker=new Object();
Thread t1=new Thread(()->{for (int i=0;i<10000;i++){synchronized (locker){count++;}}
});

注意:加锁是把若干个操作 “打包成一个原子”
并不是把 count++ 的三个指令变成一个指令了,也不是说这三个指令就必须要一口气在CPU上执行完,不会触发调度
加锁会影响到其他的加锁线程,而且是加同一个锁的线程
例如两个线程针对一个变量进行修改,第一个线程先执行修改操作,第二个线程也想修改,但因为第一个线程锁住了 只能等待(阻塞),此时就称为“锁竞争” “锁冲突”

只有当两个线程,尝试竞争同一把锁才会产生“阻塞”
如果是竞争不同的锁,则没有影响
,synchronized(锁对象),就看这个锁对象是不是同一个了

加锁和解锁,可以视为两个CPU指令

2.锁普通方法,可以省略锁对象

此外,synchronized还可以修饰一个普通方法,就可以省略锁对象
例如
在这里插入图片描述
结果:
在这里插入图片描述

此时,相当于针对this 加锁,不是没有锁对象,而是把锁对象给省略了
上面被框住的代码等价于

public void add(){synchronized (this){count++;}}

注意
锁对象 是什么对象,不重要, 重要的是两个线程是否针对同一个对象加锁

3.锁静态方法

static修饰的方法,也叫做“类方法”
不是针对“实例”的方法,而是针对类的
在这个方法中没有this
在这里插入图片描述

相当于
在这里插入图片描述

死锁

触发死锁的三种情况

1.一个线程一把锁,连续加锁两次
2. 两个线程两把锁,每个线程先获取到一把锁,再尝试获取对方的锁
比如,线程1 先获取到锁A,线程2 先获取到锁B,然后线程1 在持有锁A的情况下尝试获取锁B,线程2也尝试获取锁A
在这里插入图片描述
在这里插入图片描述
最后会一直这样无法执行下去
两个线程的状态:BLOCKED(因为死锁触发阻塞)
3. N个线程M把锁
典型案例:哲学家就餐问题
在这里插入图片描述
如果哲学家都拿起左手边的筷子(锁),要想吃桌子上的面条,都要拿起右手边的筷子(锁),而右手边的筷子在其他哲学家(其他线程)手里,他们不会放下自己手上的筷子(锁),于是一直等右手边的哲学家放下筷子(等别的线程解锁),于是就会一直等,线程就集体死锁了

死锁的危害

死锁在代码中是概率性问题,一旦出现bug整个程序都执行不了,而且很难查出来,所以在编写代码的过程中要极力避免出现死锁的情况

如何避免死锁?

理解死锁的四个必要条件

  1. 锁是互斥的
  2. 锁不可被抢占 —>线程1 拿到锁之后,线程2 也想要这个锁,线程2会阻塞等待,而不是把锁抢夺过来
  3. 请求和保持——> 拿到第一把锁的情况下,不去释放第一把锁,再尝试请求第二把锁
  4. 循环等待——> 等待锁释放,等待的关系(顺序)构成了循环(哲学家就餐问题)

对于synchronized 来说,条件1 和条件2 都是synchronized 的基本特点(无法打破)
尝试从3和4进行突破
1.规定拿到了一把锁的情况下,不要再去申请第二把锁
在这里插入图片描述
线程就能够顺利执行
在这里插入图片描述
2. 规定不要让等待关系构成循环
针对锁进行编号,约定加多个锁的时候,必须按照一定的顺序来加锁(比如按照编号从小到大的顺序)
在这里插入图片描述
在这里插入图片描述

synchronized 的特性

互斥

synchronized 会起到互斥效果,某个线程执⾏到某个对象的synchronized中时,其他线程如果也执⾏
到同⼀个对象synchronized就会阻塞等待.

进⼊synchronized修饰的代码块,相当于加锁
退出synchronized修饰的代码块,相当于解锁

可重入

synchronized 同步块对同⼀条线程来说是可重⼊的,不会出现⾃⼰把⾃⼰锁死的问题
比如
在这里插入图片描述
像这样重复加了两次锁,按理来说第一次加锁成功后进入代码块,遇到一个针对同一个加锁的对象就会阻塞,触发锁冲突/锁竞争,此时线程就会阻塞等待,一直等到锁释放,就会一直卡住不动了
像这样的情况,就称为死锁(deadlock)
但是Java优化了这种代码,像上面自己把自己锁死的情况,Java是不会出现的
像这样同一个线程,针对同一把锁,连续加锁多次,不会触发死锁,此时这个锁就可以称为 可重入锁

可重入锁机制:
让锁对象本身记录下来拥有者是哪个线程(把线程id记录下来了)
Java的对象Object,除了有一个内存区域保存程序员自定义的成员之外,还有一个“隐藏区域”,保存“对象头”
在这里插入图片描述
后续再针对这个对象加锁,就会先判定这个锁是不是已经有了,如果有就不会再触发加锁(阻塞),如果没有就触发阻塞

synchronized 使用示例

线程不安全代码解决

重新观察错误代码
在这里插入图片描述
从线程不安全原因分析,上述代码不安全有两个原因
1.线程调度随机(操作系统定好的,不能修改)
2.coun++ 操作不是原子的
针对第二个原因,我们把count++ 操作打包成原子的
把coun++ 操作,用 synchronized 打包成整体
修改之后:
在这里插入图片描述
在这里插入图片描述
图片过程示例:
## 这里编辑一下

Java标准库中的线程安全类

Java 标准库中很多都是线程不安全的.这些类可能会涉及到多线程修改共享数据,⼜没有任何加锁措施
ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder
还有⼀些是线程安全的.使⽤了⼀些锁机制来控制.
Vector(不推荐使⽤),HashTable(不推荐使⽤),ConcurrentHashMapStringBuffer
StringBuffer的核⼼⽅法都带有synchronized
还有的虽然没有加锁,但是不涉及"修改",仍然是线程安全String

加锁虽然能解决线程安全问题,但是加锁会非常明显的影响到程序的执行效率(阻塞),所以要考虑周全来加锁

volatile 关键字

volatile(adj.易变的,易失的)

volatile关键字的作用:

  1. 保证内存可见性:基于屏障指令实现,即当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
  2. 保证有序性:禁止指令重排序。编译时 JVM 编译器遵循内存屏障的约束,运行时靠屏障指令组织指令顺序。

volatile 能保证内存可见性

通过这个关键字,提醒编译器某个变量是“易变” 的,此时就不用针对这种“易变” 的变量进行编译器优化
如:
在这里插入图片描述
在这里插入图片描述
用volatile就可以让程序正常结束了
如果给变量添加了 volatile 关键字,编译器在看到 volatile 的时候,就会提醒JVM 【在读写 volatile 变量的指令前后添加“内存屏障相关的指令”】运行的时候不进行上述优化

题外话:存储数据,不只有内存,还有外存(硬盘),还有CPU寄存器,CPU上还有缓存
CPU——>效率高,空间小
内存——>效率较CPU低,空间大
而缓存介于两者之间(效率两者之间,空间也是)

编译器优化,并非是百分之百触发,会根据不同代码结构产生不同优化效果
如果上面代码没有volatile,但在t1 线程中加 sleep 编译器就不会发生优化,原因:

  1. 循环速度大幅度降低了
  2. 有了sleep 异常循环的瓶颈,就不是 load 的问题了(sleep比load慢多了,且sleep 本身会触发“线程调度”,调度过程触发上下文切换),此时再优化 load 就没什么用了

是否优化取决于flag 判定 占比大不大

volatile 不保证原子性

比如:
在这里插入图片描述

wait和notify

wait 和 notify 的作用是协调线程之间的执行顺序
(join 是控制线程的结束顺序)

例如有一个线程1 和线程2 ,希望线程1 限制性玩某个逻辑之后,再让线程2 执行
就可以让线程2 通过wait 主动进行阻塞,让线程1 先参与调度
等线程1 把对应的逻辑执行完了,就可以通过 notify 唤醒线程2

此外,wait 和 notify 也能解决 “线程饿死” 问题
即多个线程等待一个线程,这个线程反复对一个锁加锁解锁(其他线程要等待操作系统唤醒,而这个线程就是在CPU上执行,其他线程就竞争不过这个线程),其他线程无法去CPU上执行,就 “饿死” 了
像线程饿死的问题没有死锁严重,因为其他线程也能竞争这个锁,只是大大影响效率,而死锁是直接卡住不动

wait 和 notify 都是Object 类的方法(Java中随便一个类,都有这两个方法)

wait() 方法

如果直接使用wait:
在这里插入图片描述

实际结果:
在这里插入图片描述
错误原因:
wait 方法内部做的第一件事就是 释放锁
上面代码类中没有锁(得先拿到锁,才能释放锁)
wait 必须放到 synchronized 代码块内部来使用
在这里插入图片描述
在这里插入图片描述
此时阻塞会一直持续下去,直到其他线程调用 notify()

wait 做的事情(同时进行):
使当前执行代码的线程继续等待(把线程放到等待队列中)
释放当前的锁
满足一定条件时被唤醒,重新尝试获取这个锁

使用wait 的时候,阻塞其实有两个阶段的:

  1. WAITING 的阻塞,通 wait 等待其他线程通知
  2. BLOCKED 的阻塞,当收到通知后,就会重新尝试获取锁,此时很可能会遇到锁竞争(如 notify 后又有一堆别的逻辑,多占有了这个锁一会,就会触发竞争)

wait 进入阻塞后,需要通过 notify 唤醒,默认情况下,wait 的阻塞也是 “死等”
可以设定等待的时间上限(超时时间)
在这里插入图片描述

notify() 方法

在这里插入图片描述
在这里插入图片描述
如果有多个线程都在进行 wait (同一个对象上 wait )
此时进行 notify 是随机唤醒一个线程

notifyAll() 方法

唤醒全部的等待线程
在这里插入图片描述
如果没有 wait 就 notify 或 notifyAll 是不会出现报错的(无影响)

wait和sleep 的对比(面试题)

1.wait 的设计就是为了提前唤醒的,超时时间是 “后手”
sleep 的设计就是为了到时间唤醒,虽然也可以通过interrupt() 提前唤醒,但是这样做会产生异常

2.wait 需要搭配锁使用,wait 执行时会先释放锁
sleep 不需要搭配锁使用,当把 sleep 放到 synchronized 内部时,是不会释放锁的

http://www.xdnf.cn/news/6950.html

相关文章:

  • [Windows] 系统综合优化工具 RyTuneX 1.3.1
  • 安全性(二):数字签名
  • MoveIt Setup Assistant 在导入urdf文件的时候报错
  • 中国电力行业CCUS多目标优化模型分析
  • 数据结构与算法-线性表-循环链表(Circular Linked List)
  • 1.Hello Python!
  • Git 项目切换到新的远程仓库地址
  • STM32外设DA实战-DAC + DMA 输出正弦波
  • 文字溢出省略号显示
  • 一、电机篇
  • 降维,流行学习,度量学习
  • Redis的发布订阅模型是什么,有哪些缺点?
  • Doris bitmap原理
  • 阿里通义千问 Qwen3 系列模型正式发布,该模型有哪些技术亮点?
  • pytorch小记(二十一):PyTorch 中的 torch.randn 全面指南
  • WebAuthn开发常见问题及解决方案汇总:多语言支持、依赖管理与安全验证实践
  • Android同屏采集并推送RTMP和启动轻量级RTSP服务技术实践
  • QT之LayOut布局
  • SVGPlay:一次 CodeBuddy 主动构建的动画工具之旅
  • GO语言学习(三)
  • 项目管理学习-CSPM-4考试总结
  • VC++6.0分步执行常见问题及解决方案
  • 阿里云国际站与国内站的核心布局与本土化服务的选择
  • Linux中的进程
  • 提示词工程框架:CoT、ToT、GoT、PoT( 链式提示)
  • MySQL 索引优化以及慢查询优化
  • Linux面试题集合(2)
  • 20250517 我设想一个空间,无限大,空间不与其中物质进行任何作用,甚至这个空间能容纳可以伸缩的空间
  • 【技巧】GoogleChrome浏览器开发者模式查看dify接口
  • Day119 | 灵神 | 二叉树 | 二叉树的最近共公共祖先