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

devtools/2024/10/21 6:11:34/

🎇个人主页: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/devtools/27464.html

相关文章

手写线程池|C语言版(二)|定义线程池的结构、创建线程池实例

文章目录 定义线程池结构任务结构体定义线程池结构体 组织头文件创建线程池实例函数原型定义线程池创建函数实现初始化线程池结构体指针初始化线程池结构体的各类参数 定义线程池的结构C代码创建线程池总体C代码 本文中&#xff0c;我们将创建线程池的结构体&#xff0c;该结构…

vue3 引用虚拟键盘simple-keyboard

simple-keyboard官网地址&#xff1a;https://virtual-keyboard.js.org 目前实现效果图是&#xff08;实现数字、大小写字母键盘&#xff09;&#xff1a; 1.需要先安装simple-keyboard npm install simple-keyboard --save2.封装sinpleKeyboard 组件 <!-- keyboard-bo…

k8s pod 镜像拉取策略

在 Kubernetes (k8s) 中&#xff0c;Pod 容器镜像的拉取策略通过 imagePullPolicy 属性来控制。这一策略决定了 kubelet 如何以及何时从容器镜像仓库中拉取镜像。以下是三种主要的镜像拉取策略及其详细说明&#xff1a; Always: 说明: 这是默认的拉取策略。当设置为 Always 时&…

第72天:漏洞发现-Web框架中间件联动GobyAfrogXrayAwvsVulmap

案例一&#xff1a;某 APP-Web 扫描-常规&联动-Burp&Awvs&Xray Acunetix 一款商业的 Web 漏洞扫描程序&#xff0c;它可以检查 Web 应用程序中的漏洞&#xff0c;如 SQL 注入、跨站脚本攻击、身份验证页上的弱口令长度等。它拥有一个操作方便的图形用户界 面&#…

解锁图像新维度:剑桥联手英特尔,利用大语言模型重构逆向图形学!

DeepVisionary 每日深度学习前沿科技推送&顶会论文分享&#xff0c;与你一起了解前沿深度学习信息&#xff01; 引言&#xff1a;探索逆图形学的新视角 逆图形学&#xff08;Inverse Graphics&#xff09;是计算机视觉和图形学中的一个基本挑战&#xff0c;它涉及将图像…

LCR 150. 彩灯装饰记录 II

1.leetcode原题链接&#xff1a;. - 力扣&#xff08;LeetCode&#xff09; 2.题目描述 一棵圣诞树记作根节点为 root 的二叉树&#xff0c;节点值为该位置装饰彩灯的颜色编号。请按照从左到右的顺序返回每一层彩灯编号&#xff0c;每一层的结果记录于一行。 示例 1&#xff…

Python和Julia河流湖泊沿海水域特征数值算法模型

&#x1f3af;要点 一维水流场景计算和绘图&#xff1a; &#x1f3af;恒定透射率水头和流量计算&#xff1a;&#x1f58a;两条完全穿透畜水层理想河流之间 | &#x1f58a;无承压畜水层两侧及两条完全穿透畜水层的补给 | &#x1f58a;分水岭或渗透性非常低的岩体的不渗透边…

[华为OD]实现一个支持优先级的队列,高优先级先出队列 100

题目&#xff1a; 实现一个支持优先级的队列&#xff0c;高优先级先出队列&#xff1b;同优先级时先进先出。 如果两个输入数据和优先级都相同&#xff0c;则后一个数据不入队列被丢弃。 队列存储的数据内容是一个整数。 输入描述&#xff1a; 一组待存入队列的数据&#…