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、避免共享资源


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


四、面试题-死锁


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


总结:


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


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


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






相关文章
|
22天前
|
设计模式 Java 开发者
Java多线程编程的陷阱与解决方案####
本文深入探讨了Java多线程编程中常见的问题及其解决策略。通过分析竞态条件、死锁、活锁等典型场景,并结合代码示例和实用技巧,帮助开发者有效避免这些陷阱,提升并发程序的稳定性和性能。 ####
|
22天前
|
缓存 Java 开发者
Java多线程编程的陷阱与最佳实践####
本文深入探讨了Java多线程编程中常见的陷阱,如竞态条件、死锁和内存一致性错误,并提供了实用的避免策略。通过分析典型错误案例,本文旨在帮助开发者更好地理解和掌握多线程环境下的编程技巧,从而提升并发程序的稳定性和性能。 ####
|
16天前
|
安全 算法 Java
Java多线程编程中的陷阱与最佳实践####
本文探讨了Java多线程编程中常见的陷阱,并介绍了如何通过最佳实践来避免这些问题。我们将从基础概念入手,逐步深入到具体的代码示例,帮助开发者更好地理解和应用多线程技术。无论是初学者还是有经验的开发者,都能从中获得有价值的见解和建议。 ####
|
16天前
|
Java 调度
Java中的多线程编程与并发控制
本文深入探讨了Java编程语言中多线程编程的基础知识和并发控制机制。文章首先介绍了多线程的基本概念,包括线程的定义、生命周期以及在Java中创建和管理线程的方法。接着,详细讲解了Java提供的同步机制,如synchronized关键字、wait()和notify()方法等,以及如何通过这些机制实现线程间的协调与通信。最后,本文还讨论了一些常见的并发问题,例如死锁、竞态条件等,并提供了相应的解决策略。
40 3
|
22天前
|
缓存 Java 开发者
Java多线程并发编程:同步机制与实践应用
本文深入探讨Java多线程中的同步机制,分析了多线程并发带来的数据不一致等问题,详细介绍了`synchronized`关键字、`ReentrantLock`显式锁及`ReentrantReadWriteLock`读写锁的应用,结合代码示例展示了如何有效解决竞态条件,提升程序性能与稳定性。
81 6
|
22天前
|
安全 Java 开发者
Java中的多线程编程:从基础到实践
本文深入探讨了Java多线程编程的核心概念和实践技巧,旨在帮助读者理解多线程的工作原理,掌握线程的创建、管理和同步机制。通过具体示例和最佳实践,本文展示了如何在Java应用中有效地利用多线程技术,提高程序性能和响应速度。
54 1
|
23天前
|
设计模式 安全 Java
Java 多线程并发编程
Java多线程并发编程是指在Java程序中使用多个线程同时执行,以提高程序的运行效率和响应速度。通过合理管理和调度线程,可以充分利用多核处理器资源,实现高效的任务处理。本内容将介绍Java多线程的基础概念、实现方式及常见问题解决方法。
43 0
|
26天前
|
缓存 Java 开发者
Java中的多线程编程:从基础到进阶
在Java的世界中,多线程编程是一块不可或缺的拼图。它不仅提升了程序的效率和响应性,还让复杂任务变得井然有序。本文将带你领略多线程编程的魅力,从创建线程的基础操作到高级同步机制的应用,再到线程池的高效管理,我们将一步步揭开多线程编程的神秘面纱。无论你是初学者还是有一定经验的开发者,这篇文章都会为你提供新的视角和深入的理解。让我们一起探索Java多线程编程的世界,开启一段精彩的旅程吧!
38 0
|
4月前
|
存储 监控 Java
Java多线程优化:提高线程池性能的技巧与实践
Java多线程优化:提高线程池性能的技巧与实践
130 1
|
7月前
|
设计模式 监控 Java
Java多线程基础-11:工厂模式及代码案例之线程池(一)
本文介绍了Java并发框架中的线程池工具,特别是`java.util.concurrent`包中的`Executors`和`ThreadPoolExecutor`类。线程池通过预先创建并管理一组线程,可以提高多线程任务的效率和响应速度,减少线程创建和销毁的开销。
236 2
下一篇
DataWorks