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


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


四、面试题-死锁


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


总结:


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


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


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






相关文章
|
1月前
|
Java
Java—多线程实现生产消费者
本文介绍了多线程实现生产消费者模式的三个版本。Version1包含四个类:`Producer`(生产者)、`Consumer`(消费者)、`Resource`(公共资源)和`TestMain`(测试类)。通过`synchronized`和`wait/notify`机制控制线程同步,但存在多个生产者或消费者时可能出现多次生产和消费的问题。 Version2将`if`改为`while`,解决了多次生产和消费的问题,但仍可能因`notify()`随机唤醒线程而导致死锁。因此,引入了`notifyAll()`来唤醒所有等待线程,但这会带来性能问题。
Java—多线程实现生产消费者
|
21天前
|
缓存 安全 算法
Java 多线程 面试题
Java 多线程 相关基础面试题
|
1月前
|
JSON 前端开发 Java
【Bug合集】——Java大小写引起传参失败,获取值为null的解决方案
类中成员变量命名问题引起传送json字符串,但是变量为null的情况做出解释,@Data注解(Spring自动生成的get和set方法)和@JsonProperty
|
1月前
|
安全 Java Kotlin
Java多线程——synchronized、volatile 保障可见性
Java多线程中,`synchronized` 和 `volatile` 关键字用于保障可见性。`synchronized` 保证原子性、可见性和有序性,通过锁机制确保线程安全;`volatile` 仅保证可见性和有序性,不保证原子性。代码示例展示了如何使用 `synchronized` 和 `volatile` 解决主线程无法感知子线程修改共享变量的问题。总结:`volatile` 确保不同线程对共享变量操作的可见性,使一个线程修改后,其他线程能立即看到最新值。
|
10天前
|
JSON 前端开发 安全
【潜意识java】前后端跨域问题及解决方案
本文深入探讨了跨域问题及其解决方案。跨域是指浏览器出于安全考虑,限制从一个域加载的网页请求另一个域的资源。
38 0
|
1月前
|
消息中间件 缓存 安全
Java多线程是什么
Java多线程简介:本文介绍了Java中常见的线程池类型,包括`newCachedThreadPool`(适用于短期异步任务)、`newFixedThreadPool`(适用于固定数量的长期任务)、`newScheduledThreadPool`(支持定时和周期性任务)以及`newSingleThreadExecutor`(保证任务顺序执行)。同时,文章还讲解了Java中的锁机制,如`synchronized`关键字、CAS操作及其实现方式,并详细描述了可重入锁`ReentrantLock`和读写锁`ReadWriteLock`的工作原理与应用场景。
|
1月前
|
存储 安全 Java
Java多线程编程秘籍:各种方案一网打尽,不要错过!
Java 中实现多线程的方式主要有四种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口和使用线程池。每种方式各有优缺点,适用于不同的场景。继承 Thread 类最简单,实现 Runnable 接口更灵活,Callable 接口支持返回结果,线程池则便于管理和复用线程。实际应用中可根据需求选择合适的方式。此外,还介绍了多线程相关的常见面试问题及答案,涵盖线程概念、线程安全、线程池等知识点。
188 2
|
1月前
|
安全 算法 Java
Java多线程编程中的陷阱与最佳实践####
本文探讨了Java多线程编程中常见的陷阱,并介绍了如何通过最佳实践来避免这些问题。我们将从基础概念入手,逐步深入到具体的代码示例,帮助开发者更好地理解和应用多线程技术。无论是初学者还是有经验的开发者,都能从中获得有价值的见解和建议。 ####
|
1月前
|
Java 调度
Java中的多线程编程与并发控制
本文深入探讨了Java编程语言中多线程编程的基础知识和并发控制机制。文章首先介绍了多线程的基本概念,包括线程的定义、生命周期以及在Java中创建和管理线程的方法。接着,详细讲解了Java提供的同步机制,如synchronized关键字、wait()和notify()方法等,以及如何通过这些机制实现线程间的协调与通信。最后,本文还讨论了一些常见的并发问题,例如死锁、竞态条件等,并提供了相应的解决策略。
68 3
|
2月前
|
安全 Java 开发者
Java中的多线程编程:从基础到实践
本文深入探讨了Java多线程编程的核心概念和实践技巧,旨在帮助读者理解多线程的工作原理,掌握线程的创建、管理和同步机制。通过具体示例和最佳实践,本文展示了如何在Java应用中有效地利用多线程技术,提高程序性能和响应速度。
84 1