Java 中的死锁
概念
多个并发线程(进程)因争夺系统资源而产生相互等待的现象。
由于在 Java 程序中我们大多讨论的是线程并发问题,所以以下都只针对线程来讨论。
必要条件
产生死锁必须同时满足四个必要条件,这四个条件被称为 Coffman 条件:
- 互斥条件(Mutual Exclusion):至少有一个资源是处于非共享模式,即一次只有一个线程可以持有该资源。可以理解为是针对资源的阐述,是说该资源有这种属性或处于某种状态。比如打印机,或者某个对象锁。
- 请求与保持条件(Hold and Wait)(占有且等待):一个线程必须持有至少一个资源,并且正在等待获取目前被其他线程持有的一个或多个资源。
- 不可抢占条件(No Preemption):线程持有的资源只能由自己释放,不能被其他线程强行占有。
- 循环等待条件(Circular Wait):存在一个线程-资源循环链列,链中每个线程都占有下一个线程所需的资源。
预防与解除
如果同时满足了以上四个必要条件就会产生死锁现象,进而可能导致系统崩溃。所以预防(避免)死锁的产生和产生死锁以后如何排查与解决就显得很重要。
预防死锁产生的关键是不要同时满足产生死锁的四个必要条件,而解决死锁问题的关键是如何打破以上条件中的一个或多个。由于互斥条件是非共享资源所必须的,不仅不能改变,还应加以保证,所以,主要是破坏产生死锁的其他三个条件。
预防
破坏“占有且等待”条件
方法1:所有的进程在开始运行之前,必须一次性地申请其在整个运行过程中所需要的全部资源。
优点:简单易实施且安全。
缺点:因为某项资源不满足,进程无法启动,而其他已经满足了的资源也不会得到利用,严重降低了资源的利用率,造成资源浪费。使进程经常发生饥饿现象。
方法2:该方法是对第一种方法的改进,允许进程只获得运行初期需要的资源,便开始运行,在运行过程中逐步释放掉分配到的已经使用完毕的资源,然后再去请求新的资源。这样的话,资源的利用率会得到提高,也会减少进程的饥饿问题。
破坏“不可抢占”条件
当一个已经持有了一些资源的进程在提出新的资源请求没有得到满足时,它必须释放已经保持的所有资源,待以后需要使用的时候再重新申请。这就意味着进程已占有的资源会被短暂地释放或者说是被抢占了。
该种方法实现起来比较复杂,且代价也比较大。释放已经保持的资源很有可能会导致进程之前的工作实效等,反复的申请和释放资源会导致进程的执行被无限的推迟,这不仅会延长进程的周转周期,还会影响系统的吞吐量。
破坏“循环等待”条件
可以通过定义资源类型的线性顺序来预防,可将每个资源编号,当一个进程占有编号为i的资源时,那么它下一次申请资源只能申请编号大于i的资源。
这样虽然避免了循环等待,但是这种方法是比较低效的,资源的执行速度回变慢,并且可能在没有必要的情况下拒绝资源的访问,比如说,进程c想要申请资源1,如果资源1并没有被其他进程占有,此时将它分配个进程c是没有问题的,但是为了避免产生循环等待,该申请会被拒绝,这样就降低了资源的利用率。
使用前判断
利用额外的检验信息,在分配资源时判断是否会出现死锁,只在不会出现死锁的情况下才分配资源。 两种避免办法:
1、如果一个进程的请求会导致死锁,则不启动该进程
2、如果一个进程的增加资源请求会导致死锁 ,则拒绝该申请。
而避免死锁的具体实现通常利用银行家算法。
检测与解除
如果利用死锁检测算法检测出系统已经出现了死锁 ,那么,此时就需要对系统采取相应的措施。常用的解除死锁的方法:
1、抢占资源:从一个或多个进程中抢占足够数量的资源分配给死锁进程,以解除死锁状态。
2、终止(或撤销)进程:终止或撤销系统中的一个或多个死锁进程,直至打破死锁状态。 终止线程主要有以下两种策略:
终止所有的死锁进程:这种方式简单粗暴,但是代价很大,很有可能会导致一些已经运行了很久的进程前功尽弃。
逐个终止进程:逐个终止,直至死锁状态解除。该方法的代价也很大,因为每终止一个进程就需要使用死锁检测来检测系统当前是否处于死锁状态。另外,每次终止进程的时候终止那个进程呢?每次都应该采用最优策略来选择一个“代价最小”的进程来解除死锁状态。一般根据如下几个方面来决定终止哪个进程:
- 进程的优先级
- 进程已运行时间以及运行完成还需要的时间
- 进程已占用系统资源
- 进程运行完成还需要的资源
- 终止进程数目
- 进程是交互还是批处理
Java死锁避免与解除
主动避免
为了避免死锁在我们编程的过程需要注意以下几点:
- 如果多个线程需要获取多个锁,确保它们总是按照相同的顺序获取锁。如:
public class LockOrder {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public void method1() {
synchronized (lock1) {
synchronized (lock2) {
// 执行操作
}
}
}
public void method2() {
synchronized (lock1) {
synchronized (lock2) {
// 执行操作
}
}
}
}
- 使用 tryLock
使用ReentrantLock的tryLock方法可以尝试获取锁,如果无法立即获取,则返回false。这允许线程在无法获取锁时立即放弃而不是阻塞。当然也可以为 tryLock 设置等待时间。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockWithTimeout {
private final ReentrantLock lock1 = new ReentrantLock();
private final ReentrantLock lock2 = new ReentrantLock();
public void method() {
boolean lock1Acquired = false;
boolean lock2Acquired = false;
try {
lock1Acquired = lock1.tryLock(1, TimeUnit.SECONDS);
if (lock1Acquired) {
lock2Acquired = lock2.tryLock(1, TimeUnit.SECONDS);
if (lock2Acquired) {
// 执行操作
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock2Acquired) lock2.unlock();
if (lock1Acquired) lock1.unlock();
}
}
}
- 避免持有锁时调用可能阻塞的方法
持有锁时调用可能阻塞的方法(如I/O操作)可能会导致其他线程无法获取锁,从而增加死锁的风险。 - 限制锁的范围
尽可能缩小锁的范围,只在必要的时候使用锁,并尽快释放锁。 - 使用ThreadLocal变量
对于每个线程都需要拥有独立副本的情况,可以使用ThreadLocal来避免共享资源的竞争。 - 使用java.util.concurrent包中的工具
java.util.concurrent包提供了许多高级的并发工具,如ExecutorService、Semaphore、CountDownLatch等,这些工具可以更安全地管理线程间的协作。
出现死锁如何排查?
一般出现死锁时,可能会导致CPU、内存等资源消耗过高,导致系统性能下降。也可能导致应用无响应或者假死等等,所以要从多角度进行死锁的排查。
首先是用top、df、free等命令查看操作系统的基本情况。然后可以使用jmap、jstack等命令查看JVM线程栈和堆内存的情况。一般出现死锁时,会在线程栈的信息里出现deadlock字样。
还可以采用VisualVM、JConsole等工具进行排查。

浙公网安备 33010602011771号