本文主要是介绍多线程篇(基本认识 - 锁机制 - 死锁 活锁 锁机饿)(持续更新迭代),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
目录
前言
一、死锁(DeadLock)
1. 简介
2. 死锁产生原因/如何避免死锁、排查死锁
3. 死锁产生的四个必要条件
3.1. 互斥条件
3.2. 不可剥夺条件
3.3. 请求与保持条件
3.4. 环状等待条件
知识小结
4. 资源引发的死锁问题
4.1. 前言
4.2. 系统资源的分类
永久性资源
临时性资源
可抢占式资源
不可抢占式资源
5. 死锁案例分析
6. 死锁处理
6.1. 预防死锁
方式一:破坏“互斥”条件
方式二:破坏“不可剥夺”条件
方式三:破坏“请求与保持”条件
方式四:破坏“环状等待链”条件
知识小结
6.2. 避免死锁
方式一:有序资源分配法
方式二:银行家算法
知识小结
6.3. 检测死锁
6.3.1. 简介
6.3.2. 线上排查死锁的方式(Java)
6.3.3. 其他工具
6.4. 解除死锁
6.4.1. 简介
6.4.2. 解决进程级别死锁(三种)
资源剥夺法
撤销进程法
进程回退法
知识小结
二、活锁(LiveLock)
1. 简介
2. 活锁解决方案
三、锁饥饿(LockStarving)
1. 简介
前言
在多核时代中,多线程、多进程的程序虽然大大提高了系统资源的利用率以及系统的吞吐量,但并发执行也带来
了新的一系列问题:死锁、活锁与锁饥饿。
死锁、活锁与锁饥饿都是程序运行过程中的一种状态,而其中死锁与活锁状态在进程中也是可能存在这种情况
的,以下就是对死锁、活锁、锁机饿的介绍。
一、死锁(DeadLock)
1. 简介
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力
作用的情况下,这些线程会一直相互等待而无法继续运行下去。
由图可知,线程A已经持有了资源2,它同时还想申请资源1,线程B已经持有了资源1,它同时还
想申请资源2,所以线程1和线程2就因为相互等待对方已经持有的资源,而进入了死锁状态。
举个栗子:
某一天竹子和熊猫在森林里捡到一把玩具弓箭,竹子和熊猫都想玩,原本说好一人玩一次的来,
但是后面竹子耍赖,想再玩一次,所以就把弓一直拿在自己手上,而本应该轮到熊猫玩的,所以
熊猫跑去捡起了竹子前面刚刚射出去的箭,然后跑回来之后便发生了如下状况:
熊猫道:竹子,快把你手里的弓给我,该轮到我玩了.... 竹子说:不,你先把你手里的箭给我,
我再玩一次就给你.... 最终导致熊猫等着竹子的弓,竹子等着熊猫的箭,双方都不肯退步,结果
陷入僵局场面....。 相信这个场景各位小伙伴多多少少都在自己小时候发生过,这个情况在程序
中发生时就被称为死锁状况,如果出现后则必须外力介入,然后破坏掉死锁状态后推进程序继续
执行。如上述的案例中,此时就必须第三者介入,把“违反约定”的竹子手中的弓拿过去给熊
猫......
2. 死锁产生原因/如何避免死锁、排查死锁
关于锁饥饿和活锁前面阐述的内容便已足够了,不过对于死锁这块的内容,无论在面试过程中,
还是在实际开发场景下都比较常见,所以再单独拿出来分析一个段落。
在前面提及过死锁的概念:死锁是指两个或两个以上的线程(或进程)在运行过程中,因为资源
竞争而造成相互等待的现象。而此时可以进一步拆解这句话,可以得出死锁如下结论:
① 参与的执行实体(线程或进程)必须要为两个或两个以上。
② 参与的执行实体都需要等待资源方可执行。
③ 参与的执行实体都均已占据对方等待的资源。
④ 死锁情况下会占用大量资源而不工作,如果发生大面积的死锁情况可能会导致程序或系统崩
溃。
3. 死锁产生的四个必要条件
而诱发死锁的根本从前面的分析中可以得知:是因为竞争资源引起的。当然,产生死锁存在四个
必要条件,如
3.1. 互斥条件
指分配到的资源具备排他使用性,即在一段时间内某资源只能由一个执行实体使用。
如果此时还有其它执行实体请求资源,则请求者只能等待,直至占有资源的执行实体使用完成后
释放才行。
3.2. 不可剥夺条件
指执行实体已持有的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
3.3. 请求与保持条件
指运行过程中,执行实体已经获取了至少一个资源,但又提出了新的资源请求,而该资源已被其
它实体占用,此时当前请求资源的实体阻塞,但在阻塞时却不释放自己已获得的其它资源,一直
保持着对其他资源的占用。
3.4. 环状等待条件
指在发生死锁时,必然存在一个执行实体的资源环形链。
比如:线程T1等待T2占用的一个资源,线程在等待线程T3占用的一个资源,而线程则在等待占
用的一个资源,最终形成了一个环状的资源等待链。
知识小结
以上就是死锁发生的四个必要条件,只要系统或程序内发生死锁情况,那么这四个条件必然成
立,只要上述中任意一条不符合,那么就不会发生死
4. 资源引发的死锁问题
4.1. 前言
前面曾提到过一句,死锁情况的发生必然是因为资源问题引起的,而在上述资源中,竞争临时性
资源和不可剥夺性资源都可能引起死锁发生,也包括如果资源请求顺序不当也会诱发死锁问题,
如两条并发线程同时执行,持有资源M1,线程持有M2,而又在请求,又在请求,两者都会因为
所需资源被占用而阻塞,最终造成死锁。
当然,也并非只有资源抢占会导致死锁出现,有时候没有发生资源抢占,就单纯的资源等待也会
造成死锁场面,如:服务A在等待服务B的信号,而服务恰巧也在等待服务的信号,结果也会导
致双方之间无法继续向前推进执行。
不过从这里可以看出:A和B不是因为竞争同一资源,而是在等待对方的资源导致死锁。
对于这个例子有人可能会疑惑,这不是活锁情况吗? 答案并非如此,因为活锁情况讲究的是一
个“活”字,而上述这个案例,双方之间都是处于相互等待的“死”
4.2. 系统资源的分类
操作系统以及硬件平台上存在各种各样不同的资源,而资源的种类大体可以分为永久性资源、临
时性资源、可抢占式资源以及不可抢占式
永久性资源
永久性资源也被称为可重复性资源,即代表着一个资源可以被执行实体(线程/进程)重复性使
用,它们不会因为执行实体的生命周期改变而发生变化。
比如所有的硬件资源就是典型的永久性资源,这些资源的数量是固定的,执行实体在运行时即不
能创建,也不能销毁,要使用这些资源时必须要按照请求资源、使用资源、释放资源这样的顺序
临时性资源
临时性资源也被称为消耗性资源,这些资源是由执行实体在运行过程中动态的创建和销毁的,如
硬件中断信号、缓冲区内的消息、队列中的任务等,这些都属于临时性资源,通常是由一个执行
实体创建出来之后,被另外的执行实体处理后销毁。比如典型的一些消息中间件的使用,也就是
生产者-消费者
可抢占式资源
可抢占式资源也被称为可剥夺性资源,是指一个执行实体在获取到某个资源之后,该资源是有可
能被其他实体或系统剥夺走的。
可剥夺性资源在程序中也比较常见,如:
- 进程级别:CPU、主内存等资源都属于可剥夺性资源,系统将这些资源分配给一个进程之后,系统是可以将这些资源剥夺后转交给其他进程使用的。
- 线程级别:比如 Java 中的 ForkJoin 框架中的任务,分配给一个线程的任务是有可能被其他线程窃取的。可剥夺性资源还有很多,诸如上述过程中的一些类似的资源都可以被称为可剥夺性
不可抢占式资源
同样,不可抢占式资源也被称为不可剥夺性资源,不可剥夺性是指把一个执行实体获取到资源之
后,系统或程序不能强行收回,只能在实体使用完后自行释放。如:
- 进程级别:磁带机、打印机等资源,分配给进程之后只能由进程使用完后自行释放。
- 线程级别:锁资源就是典型的线程级别的不可剥夺性资源,当一条线程获取到锁资源后,其他线程不能剥夺该资源,只能由获取到锁的线程自行释放。
5. 死锁案例分析
上述对于死锁的理论进行了大概阐述,下来来个简单例子感受一下死锁情景:
public class DeadLock implements Runnable {public boolean flag = true;// 静态成员属于class,是所有实例对象可共享的private static Object o1 = new Object(), o2 = new Object();public DeadLock(boolean flag) {this.flag = flag;}@Overridepublic void run() {if (flag) {synchronized (o1) {System.out.println("线程:" + Thread.currentThread().getName() + "持有o1....");try {Thread.sleep(500);} catch (Exception e) {e.printStackTrace();}System.out.println(+Thread.currentThread().getName() + "等待o2....");synchronized (o2) {System.out.println("true");}}}if (!flag) {synchronized (o2) {System.out.println(+Thread.currentThread().getName() + "持有o2....");try {Thread.sleep(500);} catch (Exception e) {e.printStackTrace();}System.out.println(+Thread.currentThread().getName() + "等待o1....");synchronized (o1) {System.out.println("false");}}}}public static void main(String[] args) {Thread t1 = new Thread(new DeadLock(true), "T1");Thread t2 = new Thread(new DeadLock(false), "T2");
// 因为线程调度是按时间片切换决定的,
// 所以先执行哪个线程是不确定的,也就代表着:
// 后面的t1.run()可能在t2.run()之前运行t1.start();t2.start();}
}// 运行结果如下:
/*
线程:T1持有o1....
线程:T2持有o2....
线程:T2等待o1....
线程:T1等待o2....
*/
如上是一个简单的死锁案例,在该代码中:
- 当flag==true时,先获取对象o1的锁,获取成功之后休眠500ms,而发生这个动作的必然是t1,因为在main方法中,我们将任务的flag显式的置为了true。
- 而当线程睡眠时,t2线程启动,此时任务的flag=false,所以会去获取对象o2的锁资源,然后获取成功之后休眠。
- 此时线程睡眠时间结束,线程被唤醒后会继续往下执行,然后需要获取对象的锁资源,但此时已经被持有,此时会阻塞等待。
- 而此刻线程也从睡眠中被唤醒会继续往下执行,然后需要获取对象的锁资源,但此时已经被持有,此时会阻塞等待。
- 最终导致线程t1、t2相互等待对象的资源,都需要获取对方持有的资源之后才可继续往下执行,最终导致死锁产生。
6. 死锁处理
对于死锁的情况一旦出现都是比较麻烦的,但这也是设计并发程序避免不了的问题,当你想要通
过多线程编程技术提升你的程序处理速度和整体吞吐量时,对于死锁的问题也是必须要考虑的一
项,而处理死锁问题总的归纳来说可以从如下四个角度出发:
① 预防死锁:通过代码设计或更改配置来破坏掉死锁产生的四个条件其中之一,以此达到预防
死锁的目的。
② 避免死锁:在资源分配的过程中,尽量保证资源请求的顺序性,防止推进顺序不当引起死锁
问题产生。
③ 检测死锁:允许系统在运行过程中发生死锁情况,但可设置检测机制及时检测死锁的发生,
并采取适当措施加以清除。
④ 解除死锁:当检测出死锁后,便采取适当措施将进程从死锁状态中解脱
6.1. 预防死锁
处理方式
前面提过,预防死锁的手段是通过破坏死锁产生的四个必要条件中的一个或多个,以此达到预防
死锁的目的。
方式一:破坏“互斥”条件
在程序中将所有“互斥”的逻辑移除,如果一个资源不能被独占使用时,那么死锁情况必然不会发
生。但一般来说在所列的四个条件中,“互斥”条件是不能破坏的,因为程序设计中必须要考虑线
程安全问题,所以“互斥”条件是必需的。因此,在死锁预防里主要是破坏其他几个必要条件,不
会去破坏“互斥”条件。
方式二:破坏“不可剥夺”条件
破坏“不可剥夺性”条件的含义是指取消资源独占性,一个执行实体获取到的资源可以被别的实体
或系统强制剥夺,在程序中可以这样设计:
- ① 如果占用资源的实体下一步资源请求失败,那么则释放掉之前获取到的所有资源,后续再重新请求这些资源和另外的资源(和分布式事务的概念有些类似)。
- ② 如果一个实体需要请求的资源已经被另一个实体持有,那么则由程序或系统将该资源释放,然后让给当前实体获取执行。这种方式在Java中也有实现,就是设置线程的优先级,优先级高的线程是可以抢占优先级低的资源先执行的。
方式三:破坏“请求与保持”条件
破坏“请求与保持”条件的意思是:系统或程序中不允许出现一个执行实体在获取到资源的情况下
再去申请其他资源,主要有两种方案:
- ① 一次性分配方案:对于执行实体所需的资源,系统或程序要么一次性全部给它,要么什么都不给。
- ② 要求每个执行实体提出新的资源申请前,释放它所占有的资源。
但总归来说,这种情况也比较难满足,因为程序中难免会有些情况下要占用多个资源后才能一起
操作,就比如最简单的数据库写入操作,
在Java程序这边需要先获取到锁资源后才能通过连接对象进行操作,但获取到的连接对象在往
DB表中写入数据的时候还需要再和DB中其他连接一起竞争DB那边的锁资源方可真正写表。
方式四:破坏“环状等待链”条件
破坏“环状等待链”条件实际上就是要求控制资源的请求顺序性,防止请求顺序不当导致的环状等
待链闭环出现。这个点主要是在编码的时候要注意,对于一些锁资源的获取、连接池、RPC调
用、MQ消费等逻辑,尽量保证资源请求顺序合理,避免由于顺序性不当引起死锁问题出现。
知识小结
因为预防死锁的策略需要实现会太过苛刻,所以如果真正的在程序设计时考虑这些方面,可能会
导致系统资源利用率下降,也可能会导致系统/程序整体吞吐量降低。
总的来说,预防死锁只需要在系统设计、进程调度、线程调度、业务编码等方面刻意关注一下:
如何让死锁的四个必要条件不成立避免死锁
避免死锁是指系统或程序对于每个能满足的执行实体的资源请求进行动态检查,并且根据检查结
果决定是否分配资源,如果分配后系统可能发生死锁,则不予分配,反之则给予资源分配,这是
一种保证系统不进入死锁状态的动态策略。
6.2. 避免死锁
方式一:有序资源分配法
有序资源分配法:这种方式大多数被操作系统应用于进程资源分配。
假设此时有两个进程
P1、P2
进程 P1
P1
需要请求资源顺序为
R1、R2
而进程 P2
P2
使用资源的顺序则为
R2、R1
如果这个情况下两个进程并发执行,采用动态分配法的情况下是有一定几率发生死锁的,所以可
以采用有序资源分配法,把资源分配的顺序改为如下情况,从而做到破坏环路条件,避免死锁发
生。
P1:R1,R2P2:R1,R2
方式二:银行家算法
银行家算法顾名思义是来源于银行的借贷业务,有限的本金要应多个客户的借贷周转,为了防止
银行家资金无法周转而倒闭,对每一笔贷款,必须考察其借贷者是否能按期归还。在操作系统中
研究资源分配策略时也有类似问题,系统中有限的资源要供多个进程使用,必须保证得到的资源
的进程能在有限的时间内归还资源,以供其他进程使用资源,确保整个操作系统能够正常运转。
如果资源分配不得到就会发生进程之间环状等待资源,则进程都无法继续执行下去,最终造成死
锁现象。
OS实现:把一个进程需要的、已占有的资源情况记录在进程控制块中,假定进程控制块PCB其
中“状态”有就绪态、等待态和完成态。当进程在处于等待态时,表示系统不能满足该进程当前的
资源申请。“资源需求总量”表示进程在整个执行过程中总共要申请的资源量。显然,每个进程的
资源需求总量不能超过系统拥有的资源总数,通过银行家算法进行资源分配可以避免死锁。
上述的两种算法更多情况下是操作系统层面对进程级别的资源分配算法,而在程序开发中又该如
何编码才能尽量避免死锁呢?
大概有如下两种方式:
① 顺序加锁
② 超时加
知识小结
对于上述中的两种方式从字面意思就可以理解出:
前者是保证锁资源的请求顺序性,防止请求顺序不当引起资源相互等待,最终造成死锁发生。
而后者则是获取锁超时中断的意思,在JDK级别的锁,如ReetrantLock、Redisson等,都支持该
方式,也就是在指定时间内未获取到锁资源则放弃获取锁
6.3. 检测死锁
6.3.1. 简介
检测死锁这块也分为两个方向来谈,也就是分别从进程和线程两个角度出发。进程级别来说,操
作系统在设计的时候就考虑到了进程并行执行的情况,所以有专门设计死锁的检测机制,该机制
能够检测到死锁发生的位置和原因,如果检测到死锁时会暴力破坏死锁条件,从而使得并发进程
从死锁状态中恢复。
而对于Java程序员而言,如果在线上程序运行中发生了死锁又该如何排查检测呢?我们接着来进
行详细分析。
6.3.2. 线上排查死锁的方式(Java)
- 通过 jps+jstack 工具排查
- 通过 jconsole 工具排查
- 通过 jvisualvm 工具排查
6.3.3. 其他工具
当然你也可以通过其他一些第三方工具排查问题,但前面两种都是JDK自带的工具。
6.4. 解除死锁
6.4.1. 简介
当排查到死锁的具体发生原因和发生位置之后,就应立即釆取对应的措施解除死锁,避免长时间
的资源占用导致最终拖垮程序或系统。
而一般操作系统处理进程级别的死锁问题主要用三种方式:
6.4.2. 解决进程级别死锁(三种)
资源剥夺法
挂起某些死锁进程,并剥夺它的资源,将这些资源分配给其他的死锁进程。
但应当合理处置被挂起的进程,防止进程长时间挂起而得不到资源,一直处于资源匮乏的状态。
撤销进程法
强制撤销部分、甚至全部死锁进程并剥夺这些进程的资源。撤销的原则可以按进程优先级、进程
重要性和撤销进程代价的高低进行。
进程回退法
让一个或多个进程回退到足以避免死锁发生的位置,进程回退时自己释放资源而不是被剥夺。
要求系统保持进程的历史信息,设置还原点。
知识小结
当然,这些对于非底层开发程序员而言不必太过关注,重点我们还是放在线程级别的死锁问题解
决上面,比如经过上一个阶段之后,我们已经成功定位死锁发生位置又该如何处理死锁问题呢?
一般而言在Java程序中只能修改代码后重新上线程序,因为大部分的死锁都是由于代码编写不当
导致的,所以将代码改善后重新部署即可
其实在数据库中是这样处理死锁问题的,数据库系统中考虑了检测死锁和从死锁中恢复。
当DB检测到死锁时,将会选择一个线程(客户端那边的连接对象)牺牲者并放弃这个事务,作
为牺牲者的事务会放弃它占用的所有资源,从而使其他事务继续执行,最终当其他死锁线程执行
完毕后,再重新执行被强制终止的事务。
而你的项目如果在短时间内也不能重启,那么只能写一个与DB类似的死锁检测器+处理器,然后
通过自定义一个类加载器将该类动态加载到JVM中(需提前设计),然后在运行时通过你编写的
死锁处理机制,强制性的掐断死锁问题。
但对于这种方式我并不太建议使用,因为强制掐断线程执行,可能会导致业务出现问题,所以对
于Java程序的死锁问题解决,更多的还是需要从根源:代码上着手解决,因为只有当代码正确了
才能根治死锁问题。
二、活锁(LiveLock)
1. 简介
活锁是指正在执行的线程或进程没有发生阻塞,但由于某些条件没有满足,导致反复重试-失败-
重试-失败的过程。与死锁最大的区别在于:活锁状态的线程或进程是一直处于运行状态的,在
失败中不断重试,重试中不断失败,一直处于所谓的“活”态,不会停止。
而发生死锁的线程则是相互等待,双方之间的状态是不会发生改变的,处于所谓的“死”态。
死锁没有外力介入是无法自行解除的,而活锁状态有一定几率自行解除。
其实本质上来说,活锁状态就是指两个线程虽然在反复的执行,但是却没有任何效率。
正如生活中那句名言:“虽然你看起来很努力,但结果却没有因为你的努力而发生任何改变”,也
是所谓的做无用功。
同样举个生活中的栗子理解:
生活中大家也都遇见过的一个事情:在一条走廊上两个人低头玩手机往前走,突然双方一起抬头
都发现面对面快撞上了,然后双方同时往左侧跨了一步让开路,然后两个人都发现对方也到左边
来了,两个人想着再回到右边去给对方让路,然后同时又向右边跨了一步,然后不断重复这个过
程,再同时左边跨、右边跨、左边跨........ 这个栗子中,虽然双方都在不断的移动,但是做的却
是无用功,如果一直这样重复下去,可能从太阳高照到满天繁星的时候,双方还是没有走出这个
困境。 这个状态又该如何打破呢?主要有两种方案,一种是单方的,其中有一方打破“同步”的频
率。另一种方案则是双方之间先沟通好,制定好约定之后再让路,比如其中一方开口说:你等会
儿走我这边,我往那边走。而另一方则说:好。在程序中,如果两条线程发生了某些条件的碰撞
后重新执行,那么如果再次尝试后依然发生了碰撞,长此下去就有可能发生如上案例中的情况,
这种情况就被称为协同导致的活锁。
比如同时往某处位置写入数据,但同时只能允许一条线程写入数据,所以在写入之前会检测是否
有其他线程存在,如果有则放弃本次写入,过一段时间之后再重试。而此时正好有两条线程同时
写入又相互检测到了对方,然后都放弃了写入,而重试的时间间隔都为1s,结果1s后这两条线程
又碰头了,然后来回重复这个过程.....
当然,在程序中除开上述这种多线程之间协调导致的活锁情况外,单线程也会导致活锁产生,比
如远程RPC调用中就经常出现,A调用B的RPC接口,需要B的数据返回,结果B所在的机器网络
出问题了,A就不断的重试,最终导致反复调用,不断失败。
2. 活锁解决方案
活锁状态是有可能自行解除的,但时间会久一点,不过在编写程序时,我们可以尽量避免活锁情
况发生,一方面可以在重试次数上加上限制,第二个方面也可以把重试的间隔时间加点随机数,
第三个则是前面所说的,多线程协同式工作时则可以先在全局内约定好重试机制,尽量避免线程
冲突发生。
三、锁饥饿(LockStarving)
1. 简介
锁饥饿是指一条长时间等待的线程无法获取到锁资源或执行所需的资源,而后面来的新线程反而
“插队”先获取了资源执行,最终导致这条长时间等待的线程出现饥饿。
ReetrantLock的非公平锁就有可能导致线程饥饿的情况出现,因为线程到来的先后顺序无法决定
锁的获取,可能第二条到来的线程在第十八条线程获取锁成功后,它也不一定能够成功获取锁。
锁饥饿这种问题可以采用公平锁的方式解决,这样可以确保线程获取锁的顺序是按照请求锁的先
后顺序进行的。
但实际开发过程中,从性能角度而言,非公平锁的性能会远远超出公平锁,非公平锁的吞吐量会
比公平锁更高。
当然,如果你使用了多线程编程,但是在分配纤程组时没有合理的设置线程优先级,导致高优先
级的线程一直吞噬低优先级的资源,导致低优先级的线程一直无法获取到资源执行,最终也会使
低优先级的线程产生饥饿。
这篇关于多线程篇(基本认识 - 锁机制 - 死锁 活锁 锁机饿)(持续更新迭代)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!