Java多线程基础-13:一文阐明死锁的成因及解决方案

简介: 死锁是指多个线程相互等待对方释放资源而造成的一种僵局,导致程序无法正常结束。发生死锁需满足四个条件:互斥、请求与保持、不可抢占和循环等待。避免死锁的方法包括设定加锁顺序、使用银行家算法、设置超时机制、检测与恢复死锁以及减少共享资源。面试中可能会问及死锁的概念、避免策略以及实际经验。

死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。


一、死锁的几种情况


1、一个线程,一把锁(同一线程给同一对象加两次锁的情况)


可重入锁没事,不可重入锁可能死锁。


class BlockingQueue {
    synchronized void put(int elem){
        this.size();
        ...
    }
    
    synchronized int size() {
        ...
    }
}


补充:可重入锁与不可重入锁


可重入锁


是一种支持同一线程多次获取该锁的锁。当线程第一次获取可重入锁后,可以多次重复获取该锁,而不会被自己所持有的锁所阻塞。


比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因的可重入锁也叫做递归锁)。


Java里,只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。可重入锁在加锁时会判定看当前申请锁的线程是否已经是锁的拥有者,如果是,则直接放行。


不可重入锁


是一种不允许同一线程多次获取该锁的锁。当线程第一次获取不可重入锁后,再次尝试获取该锁时会被自己所持有的锁所阻塞。如果同一线程在获取不可重入锁后再次尝试获取该锁,会因“把自己锁死”而导致死锁。


2、两个线程,两把锁


即使是可重入锁也可能会死锁。如下图情况,t1与t2并发执行。t1先对locker1加锁,t2先对locker2加锁;t1继续执行,又要对locker2加锁,但必须等待t2先释放locker2;t2继续执行,又要对locker1加锁,但必须等待t1先释放locker1。这时就发生了死锁。


这就相当于疫情时期你没带口罩准备去超时买口罩,但超市却说你没带口罩不让你进。



3、N个线程M把锁


线程的数量和锁的数量增多,就更容易造成死锁了。

这就涉及到那个著名的“哲学家就餐问题”:




显然,如果此时五位哲学家同时拿起左手边的筷子,就死锁了。也就是说,一个线程如果要加两次锁,如果已经加了一个,另一个被抢走了,它就会一直等待,同时占用着第一次加的锁。


二、造成死锁的4个必要条件⭐


互斥使用:即当资源被一个线程使用(占有)时,别的线程不能使用。【锁的基本特点】

不可抢占:资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。【锁的基本特点】

请求和保持:即当资源请求者在请求其他的资源的同时,保持对原有资源的占有。(吃着碗里的看着锅里的。)(没拿到第二根筷子时也不会放弃前一根。)【代码的特点】

循环等待:即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个逻辑依赖循环的等待环路。(家钥匙锁车里了,车钥匙锁家里了。)(上面的哲学家中,5等待4,4等待3,……,2等待1,1又等待5)【代码的特点】


三、如何避免死锁


避免死锁的方法有很多,这里只列举出5种。其中,重点阐述第一种:加锁顺序。


1、加锁顺序-破除循环等待


当上述四个条件都成立的时候形成死锁。死锁的情况下,如果打破上述任何一个条件,便可让死锁消失。 其中最容易破坏的是 “循环等待”。


一个简单的破解循环等待情况的方法是:针对锁进行编号,如果需要同时获取多把锁,约定加锁顺序务必是先对小的编号加锁后对大的编号加锁。


如下图,约定每个哲学家只能先获取左手和右手中编号较小的筷子。2号哲学家先获取1号筷子,剩下的哲学家也依次获取左右手之间较小的筷子,轮到最后一位1号哲学家时,由于1号筷子已经被占用,他就无法获取1号筷子,而进入阻塞等待。




在1号哲学家阻塞等待时,5号哲学家就能拿起5号筷子,开始吃面。当他完成任务后,放下4号和5号两根筷子,此时4号哲学家又能拿起4号筷子吃面。依次地,3号、2号哲学家也能完成吃面。等到2号哲学家放下筷子后,1号哲学家可以获取到1号筷子和5号筷子,从而结束阻塞等待,开始执行吃面任务。


因此,只要约定了加锁顺序,循环等待条件就会自然破除,死锁也就不会形成了。体现在代码中,只要是一个线程中要加多把锁,就一定要注意加锁的顺序。可以约定每次加锁的时候都先给编号小的加锁,后给编号大的加锁,并且所有的线程都遵循这个顺序即可。


如下图所示,只要每次加锁的时候都先给locker1加锁,后给locker2加锁即可。(把线程加锁的顺序都固定。)



2、资源分配策略-银行家算法(略)


可以采用银行家算法(Banker's Algorithm)等资源分配策略,通过预先评估资源的最大需求量和可用量,确保系统分配资源时不会导致死锁的发生。


银行家算法的本质是对资源更合理的分配。它在学校操作系统课会学,也是期未考试必考题。


但其实本身比较复杂,实现这个算法本身还可能引入额外的 bug,得不偿失,因此不适合实际开发中使用。这里就不展开来说了。


3、超时机制


为获取锁操作设置一个超时时间,在等待超过一定时间后放弃获取锁,并进行相应的处理,避免长时间等待造成系统阻塞。


4、死锁检测与恢复


通过周期性地检测系统中是否存在死锁,并采取相应的措施进行恢复,例如终止某些进程或回滚操作。


5、避免共享资源


尽量减少进程间共享资源的数量,或者采用副本而不是共享资源的方式,避免资源竞争导致死锁的可能性。


四、面试题-死锁


谈谈死锁是什么,如何避免死锁,避免算法? 实际解决过没有?


总结:


死锁指的是两个或多个线程(或进程,以下只表述线程)无限地等待对方持有的资源,导致程序无法继续执行的状态。


死锁发生的四个必要条件是:互斥条件(资源在任意时刻只能被一个线程占用),请求与保持条件(线程在持有资源的同时也在等待获取其他线程持有的资源),不可抢占条件(线程不能被强行抢占已被别的线程占用的资源),循环等待条件(每个线程都在等待下一个线程所持有的资源)。


为了避免死锁,可以采取固定加锁顺序,调整资源分配策略,设置超时释放锁的时间,周期性检测死锁,避免共享资源等策略。避免算法是一种预防死锁的方法,其中最著名的算法是银行家算法。银行家算法基于资源分配的安全性,确保在分配资源时不会导致死锁的发生。






相关文章
|
3天前
|
Java
Java Socket编程与多线程:提升客户端-服务器通信的并发性能
【6月更文挑战第21天】Java网络编程中,Socket结合多线程提升并发性能,服务器对每个客户端连接启动新线程处理,如示例所示,实现每个客户端的独立操作。多线程利用多核处理器能力,避免串行等待,提升响应速度。防止死锁需减少共享资源,统一锁定顺序,使用超时和重试策略。使用synchronized、ReentrantLock等维持数据一致性。多线程带来性能提升的同时,也伴随复杂性和挑战。
|
2天前
|
安全 Java 程序员
Java多线程详解
Java多线程详解
16 3
|
3天前
|
缓存 安全 Java
Java线程面试题含答案
Java线程面试题含答案
15 2
|
3天前
|
算法 安全 网络协议
java高级面试题_java面试题大全带答案_线程面试题_java面试宝典2019
java高级面试题_java面试题大全带答案_线程面试题_java面试宝典2019
12 1
|
3天前
|
Java API
|
8月前
|
设计模式 缓存 Java
Java多线程线程池:提升应用性能的终极利器
Java多线程线程池:提升应用性能的终极利器
589 0
|
27天前
|
设计模式 监控 Java
Java多线程基础-11:工厂模式及代码案例之线程池(一)
本文介绍了Java并发框架中的线程池工具,特别是`java.util.concurrent`包中的`Executors`和`ThreadPoolExecutor`类。线程池通过预先创建并管理一组线程,可以提高多线程任务的效率和响应速度,减少线程创建和销毁的开销。
39 2
|
1月前
|
Java 数据库
【Java多线程】对线程池的理解并模拟实现线程池
【Java多线程】对线程池的理解并模拟实现线程池
28 1
|
1月前
|
Java 调度
Java多线程:什么是线程池(ThreadPool)?
Java多线程:什么是线程池(ThreadPool)?
59 0
|
27天前
|
设计模式 安全 Java
Java 多线程系列Ⅳ(单例模式+阻塞式队列+定时器+线程池)
Java 多线程系列Ⅳ(单例模式+阻塞式队列+定时器+线程池)