「JavaEE」线程安全1:成因死锁

server/2024/9/23 17:56:24/

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

线程安全成因&死锁

  • 🍉线程安全问题的成因
  • 🍉可重入性
  • 🍉死锁
    • 🍌解决方案

🍉线程安全问题的成因

一个代码,如果在单个线程或多个线程下执行时都不会产生 bug,那么这个情况就称为线程安全
而如果这个代码在单线程下能正确运行,但是在多线程下可能会产生 bug,则称为线程不安全或存在线程安全问题

举一个线程不安全的典例:两个计算都让同一个数 sum 自增 5w 次,然后观察结果

java">public class MyThread{public static int sum = 0;public static void main(String[] args) throws InterruptedException{Thread t1 = new Thread(()-> {for(int i = 1;i <= 50000;i++) sum++;});Thread t2 = new Thread(()-> {for(int i = 1;i <= 50000;i++) sum++;});t1.start();t2.start();t1.join();t2.join();System.out.println(sum);}
}

但是我们发现结果不符合预期,那么这就是个 bug

在这里插入图片描述
在这里插入图片描述
除了结果不对之外,每次运行的结果还都不一样

接下来我们来分析一下原因
sum++ 看上去只有一步,但其实它是由三个 CPU 指令构成的:

  1. load:从内存中读取数据到 CPU 的寄存器
  2. add:把寄存器中的值 +1
  3. save:把寄存器的值写回到内存中

假设 sum 为 0,那么过程如下图:
在这里插入图片描述
如果只有一个线程执行上面三个指令,那肯定不会出问题
但如果有两个或多个线程并发执行上面的操作,那就不一定了(注意这里的“并发”是并发执行和并行执行的统称)
以两个线程 t1、t2 为例,因为线程之间调度的顺序是不确定的,所以两个线程这三个指令的执行顺序就无法确定,下面列举其中四种情况

在这里插入图片描述
我们列举的情况都是 t1 和 t2 只执行一次,实际上可能 t1 执行一次 ++ 时,t2 已经执行了两次甚至三次,所以实际是有无数种情况
虽然有很多种情况,但只有按照第二个线程的 load 在第一个线程的 save 之后这样的顺序,得到的结果才是正确的

这个其实挺好想的,因为第二个线程如果在第一个线程 save 之前 load,那么读取到 CPU 寄存器的数据就是第一个线程 add 之前的数据,如果第一个线程自增两次并save,然后第二个线程 save 的话,那就会把第一个线程的结果给覆盖掉,结果自然就是错的

由上面这个例子,我们可以总结出导致线程不安全的原因:

  1. 操作系统上的线程是“抢占式执行”/随机调度,这给线程之间的执行顺序带来了变数(根本原因,前面的文章强调过很多次)
  2. 代码中多个线程同时修改同一个变量。如果多个线程读取同一个变量,那不会出问题;修改不同变量也没事儿;但是如果修改同一个变量,那就会存在相互覆盖的情况(代码结构层面的原因)
  3. 自增这个操作,本身不是“原子的”(直接原因)

实际上还有两个原因:内存可见性问题、指令重排序问题。不过在上面的例子不涉及到,所以放到后面再解释

既然知道了原因,那就可以针对这些原因采取预防措施
根本原因肯定没法采取啥措施,因为机制本来就是这样
代码结构的原因得看具体情况,有时候代码结构可以调整,但是有时候没法调整

所以貌似只能针对直接原因入手了
count++ 看起来是生成三个指令,但是我们通过一些手段把这三个指令打包到一起,成为一个整体。这就是我们接下来要讲的加锁

锁是一种机制,用来控制对于共享资源的访问,它可以确保在同一时间内只有一个线程可以访问共享资源,其他线程必须等待获取锁后才能访问

加锁需要先准备好一个锁对象,加锁、解锁都是围绕它展开的,一般使用 Object 类的实例作为锁对象

一个线程在针对一个对象加锁后,其他线程如果也尝试给这个对象加锁,就会产生阻塞(就是处于 BLOCKED 状态,这个过程称为锁冲突/锁竞争),一直阻塞到前一个进程释放锁为止(被硬控了)
(如果两个线程是针对不同对象加锁,那就不会有锁竞争,也就不会阻塞了)
加锁方式有很多种,我们最主要使用 synchronized 关键字来加锁,我们在上面的代码中加入 synchronized:

在这里插入图片描述

在这里插入图片描述
加锁就是将 sum++ 的三个指令打包变成原子,所以加锁后 t1 和 t2 的 sum++ 部分是串行执行的(但是 for 循环部分是可以并发执行的),但在执行三个操作的过程中,加锁的线程是有可能被调度走的,不过即使如此,其他线程也无法“插队”执行

加锁虽然会影响到多线程的执行效率,不过还是比单线程串行执行快

上面我们是在 main 方法中让 sum 自增,我们也可以把 sum 放在一个类的实例中,这个类有一个 add 方法,通过 add 方法来修改 sum

java">public class MyThread {public static int sum = 0;public void add() {synchronized (this) {sum++;}}public static void main(String[] args) throws InterruptedException{MyThread mt = new MyThread();Thread t1 = new Thread(()-> {for(int i = 1;i <= 50000;i++) {mt.add();}});Thread t2 = new Thread(()-> {for(int i = 1;i <= 50000;i++) {mt.add();}});t1.start();t2.start();t1.join();t2.join();System.out.println(sum);}
}

注意 add 方法中的锁对象写作 this,代表当前对象,谁调用这个方法就对谁加锁。显然 t1 和 t2 都是对 mt 加锁,所以这样也能保证线程安全

上面 synchronized (this) 也可以 synchronized 加到方法上,这两种写法是等价的

java">synchronized public void add() {sum++;
}

括号内除了写 this,我们也可以写类对象,因为一个 Java 进程中一个类只有一个类对象,所以 t1、t2 仍是对同一对象加锁,还是存在锁竞争

java">public void add() {synchronized (MyThread.class) {sum++;}
}

拓展:synchronized 如果加到静态方法上,那就等价于给类对象加锁

🍉可重入性

在同一个线程中,如果某个对象已经处于加锁的状态,那此时如果尝试再给它加锁不会出现阻塞的情况,这个特性就称为可重入性
原因也很简单,因为是同一线程,锁对象知道第二次加锁的线程就是持有锁的线程,所以第二次操作就直接放行通过,而不会出现阻塞

下面拿一段代码作为示例:

java">    public static void main(String[] args) throws InterruptedException{Object locker = new Object();Thread t = new Thread(()-> {synchronized (locker) {synchronized (locker) {System.out.println("这是可重入锁");}}});t.start();}

可以顺利打印出“这是可重入锁”

在这里插入图片描述

C++ 中使用一个 std::mutex 的锁,它是不可重入的,所以对于上面的代码,就会出现阻塞,而且无法自动恢复,于是这个线程就卡死了。这种卡死的情况,称为死锁
可重入锁就可以避免上述出现死锁的情况

我们前面说 synchronized 在进大括号时加锁,出大括号解锁,那对于上面那种嵌套了两个 synchronized 的情况,什么时候加锁、解锁呢?
对于可重入锁,它内部有两个信息:

  1. 当前这个锁被哪个线程持有
  2. 加锁次数的计数器

计数器初始情况下为 0,每进一个 synchronized 的大括号后就会加 1,同时记录是哪个线程加锁。第二次加锁发现加锁线程和持有锁的线程是同一个线程,此时只对计数器加1,没有其他操作
每出一个 synchronized 大括号,计数器就会减1,当计数器减到0时,就会进行解锁

由此可以得到一个结论:进入 synchronized 最外层大括号时加锁;出最外层大括号时解锁

这样的机制可以保证即使嵌套了多层 synchronized,也不会使解锁操作混乱,始终可以在正确的时机解锁

🍉死锁

加锁能够解决线程安全问题,但如果加锁方式不当,就可能产生死锁
有三种典型的死锁场景:

  1. 一个线程一把锁
    一个线程如果对一个不可重入锁加锁两次,就会出现死锁
  2. 两个线程两把锁
    线程 1 获取到锁 A,线程 2 获取到锁 B。然后 1 尝试获取 B,2 尝试获取 A,就会出现死锁
java">public static void main(String[] args) throws InterruptedException{Object A = new Object();Object B = new Object();MyThread mt = new MyThread();Thread t1 = new Thread(()-> {synchronized (A) {try {//sleep 是为了给 t2 时间让它拿到 BThread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (B) {System.out.println("t1 拿到两把锁");}}});Thread t2 = new Thread(()-> {synchronized (B) {try {//sleep 是为了给 t1 时间让它拿到 AThread.sleep(1000);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (A) {System.out.println("t2 拿到两把锁");}}});t1.start();t2.start();
}

程序运行起来后发现没有结果

在这里插入图片描述

查看线程状态,可以发现两个线程都处于 BLOCKED 状态

在这里插入图片描述

在这里插入图片描述

  1. N 个线程 M 把锁
    这里需要引入一个著名的问题——哲学家就餐问题

哲学家就餐问题是一个经典的并发编程问题,它讲的是五位哲学家围坐在一张圆桌上就餐,每个人需要两根筷子才能进餐,吃一些食物后会放下筷子去思考人生,现在有五根筷子,每根筷子刚好放在每两位哲学家中间

注:在这个问题中 N = M

在这里插入图片描述
因为每位哲学家啥时候吃饭,啥时候思考人生是不确定的,所以绝大部分情况下,上述模型是可以正常运行的
不过在一些极端情况下就会出问题
假设某一时刻,所有人都想吃饭,同时拿起左手的筷子,那此时当他们想再拿起右手的筷子,会发现根本拿不了。所有人都不想放下已经拿起来的筷子,都在等待旁边的人放下筷子,那就成为死锁了

🍌解决方案

要解决死锁问题,就需要了解产生死锁的条件,只要让产生条件无法成立(破坏条件),那就可以解决了
产生死锁有四个必要条件,缺一不可:

  1. 获取锁的过程是互斥的。就是一个线程拿到这把锁,另一线程想获取的话,就需要阻塞等待。这是锁最基本的特性,没法破坏
  2. 不可抢占。就是一个线程拿到锁之后,只能主动解锁,不能让别的线程强行抢走锁。这也是最基本的特性
  3. 一个线程拿到锁之后,在持有它的前提下,尝试获取另一个锁,但是这个锁被其他线程所持有,这个线程不会释放已持有的锁,并等待其他线程释放锁。这个涉及到代码结构,不一定可以破坏。如果可以破坏,我们一般会采用“请求保持”的策略:一个线程在获取锁之前必须释放已经拿到的锁
  4. 循环等待(环路等待)。线程与线程之间形成循环链,每个线程都在等待下一个线程释放其所持有的锁。以就餐问题为例,就是每个哲学家同时拿起左手的筷子,然后等待旁边的人放下筷子。这个最容易破坏,只需指定加锁顺序给锁进行编号,约定每个线程获取锁的时候一定要先获取编号小的锁,然后才能获取编号大的锁

在这里插入图片描述
在极端情况下,五个人同时伸手拿筷子,到最后 1 号或 5 号肯定能拿到一双筷子
其实有很多种方案可以解决死锁,比如引入额外的筷子(锁)、去掉一个线程、引入计数器来限制同一时刻最多有几个人吃饭…这些方案都不太复杂,但是普适性不高,有时候用不了。最通用的还得是上面所说的“引入加锁顺序的规则”

补充:实际上还有一种做法也能解决死锁问题——银行家算法,但是这种算法太复杂了,你写的算法本身很可能存在 bug,所以它只是“理论可行”而已,实践中不推荐这么做


http://www.ppmy.cn/server/27015.html

相关文章

Spring-aop切面环绕通知

1.pom引入 <!-- 切面依赖--> <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId> </dependency> 2.定义注解&#xff1a;AroundAnnotation import java.lang.annotation…

leetcode40

给定一个候选人编号的集合 candidates 和一个目标数 target &#xff0c;找出 candidates 中所有可以使数字和为 target 的组合。 candidates 中的每个数字在每个组合中只能使用 一次 。 注意&#xff1a;解集不能包含重复的组合。 示例 1: 输入: candidates [10,1,2,7,6…

python项目入门新手攻略

最近工作需要接手了代码量比较大的python开发的项目&#xff0c;平时写python不多&#xff0c;记录一下如何熟悉项目。 分析调用流程-pycallgraph 因为代码量比较大&#xff0c;所以希望通过工具生成代码调用流程&#xff0c;因此用到了pycallgraph。 pycallgraph&#xff0…

CSS之显示覆盖内容(z-index)

前言&#xff1a; 我们有的时候&#xff0c;希望下方的内容能够显示到上方&#xff0c;达到类似于多个图层的效果&#xff0c;此时我们可以利用z-index这个属性。 介绍&#xff1b; z-index属性值是用来设置元素的堆叠顺序(元素层级)。 覆盖原则&#xff1a; <1>特殊…

Microsoft Universal Print 与 SAP 集成教程

引言 从 SAP 环境打印是许多客户的要求。例如数据列表打印、批量打印或标签打印。此类生产和批量打印方案通常使用专用硬件、驱动程序和打印解决方案来解决。 Microsoft Universal Print 是一种基于云的打印解决方案&#xff0c;它允许组织以集中化的方式管理打印机和打印机驱…

docker-compose单机容器集群编排工具

一 docker-compose 简介 使用一个Dockerfile模板文件可以定义一个单独的应用容器&#xff08;可实现在单机上对容器集群编排管理 的工具&#xff09;&#xff0c;如果需要定义多个容器就需要服务编排。服务编排有很多种技术方案&#xff0c;给大家介绍 Docker 官方产品 Dock…

[华为OD] C卷 5G网络 现需要在某城市进行5G网络建设,已经选取N个地点设置5G基站 200

题目 现需要在某城市进行5G网络建设&#xff0c;已经选取N个地点设置5G基站&#xff0c;编号固定为1到N,接 下来需要各个基站之间使用光纤进行连接以确保基站能互联互通&#xff0c;不同基站之间架设光纤的成 本各不相同&#xff0c;且有些节点之间已经存在光纤相连&#…

品牌百度百科词条需要什么资料?

品牌百度百科词条是一个品牌的数字化名片&#xff0c;更是品牌历史、文化、实力的全面展现。 作为一个相当拿得出手的镀金名片&#xff0c;品牌百度百科词条创建需要什么资料&#xff0c;今天伯乐网络传媒就来给大家讲解一下。 一、品牌基本信息&#xff1a;品牌身份的明确 品…