如何优雅地处理Java中的死锁?
死锁是指两个或多个进程在执行时由于资源竞争或相互通信而无法继续执行是出现的阻塞情况。如果没有外部干预,他们就会陷入相互等待的僵局。
争用的资源可以包括锁、网络连接、磁盘上的共享变量等等。
我们使用锁来确保线程安全,但不当或过度使用可能会导致死锁。
一旦并发程序死锁,通常没有直接的解决方案,而且通常唯一的办法就是重新启动它。
因此,我们必须尽可能防止死锁。
死锁的发生
如果有一个线程A按照先锁a,后锁b的顺序获取锁,而另一个线程B按照先锁b,后锁a的顺序获取锁,就会出现如下情况:
用代码模拟一下,假设Spring Boot环境:
scss
@Component
publicclass DeadLock {
privatestatic Object lockA = new Object();
privatestatic Object lockB = new Object();
public void deadLock() {
Thread threadA = new Thread(() -> {
synchronized (lockA) {
System.out.println(Thread.currentThread().getName() + "Get lockA success");
try {
TimeUnit.SECONDS.sleep(1);
System.out.println(Thread.currentThread().getName() + "Try to get lockB ");
synchronized (lockB) {
System.out.println(Thread.currentThread().getName() + "Get lockB success");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread threadB = new Thread(() -> {
synchronized (lockB) {
System.out.println(Thread.currentThread().getName() + "Get lockB success");
try {
TimeUnit.SECONDS.sleep(1);
System.out.println(Thread.currentThread().getName() + "Try to get lockA");
synchronized (lockA) {
System.out.println(Thread.currentThread().getName() + "get lockA success");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
threadA.start();
threadB.start();
}
}
输出:
vbnet
Thread-3 Get lockB success
Thread-4 Get lockA success
Thread-4 Try to get lockB
Thread-3 Try to get lockA
可以看到到线程 3 成功获取了锁 A,但在尝试获取锁 B 时会失败。 这种相互等待对方释放锁就造成了死锁。
线程池死锁
另一种类型的死锁可能发生在线程池内。
kotlin
final ExecutorService executorService =
Executors.newSingleThreadExecutor();
Future<Long> f1 = executorService.submit(new Callable<Long>() {
public Long call() throws Exception {
System.out.println("start f1");
Thread.sleep(1000);
Future<Long> f2 =
executorService.submit(new Callable<Long>() {
public Long call() throws Exception {
System.out.println("start f2");
return -1L;
}
});
System.out.println("result" + f2.get());
System.out.println("end f1");
return -1L;
}
});
在单线程线程池中,当任务1依赖于任务2的执行结果时,就会出现死锁。 由于单线程的特性,如果任务1没有完成,任务2将永远没有机会执行,从而导致死锁。
如何检查死锁
我们可以使用jstack命令来检查死锁。 该命令可以生成虚拟机当前状态的线程快照。 线程快照是每个线程当前正在执行的方法的集合。 其主要目的是查明线程长时间暂停背后的原因,例如线程死锁、无限循环或外部资源请求导致的长时间等待。 首先,使用jps获取正在运行的Java进程的进程ID。
ruby
$ jps
13448 Jps
7321 JUnitStarter
然后,使用jstack查看当前进程的堆栈信息。
ruby
$ jstack -F 7321
Attaching to process ID 7321, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.181-b13
Deadlock Detection:
Found one Java-level deadlock:
=============================
"Thread-4":
waiting to lock Monitor@0x000000001f0134f8 (Object@0x00000007721d90f0, a java/lang/Object),
which is held by "Thread-3"
"Thread-3":
waiting to lock Monitor@0x000000001f011ef8 (Object@0x00000007721d90e0, a java/lang/Object),
which is held by "Thread-4"
Found a total of 1 deadlock.
Thread 21: (state = BLOCKED)
- com.zero.demo.deadlock.DeadLock.lambda$deadLock$1() @bci=79, line=35 (Interpreted frame)1. Acquiring locks in the correct order.
- com.zero.demo.deadlock.DeadLock$$Lambda$170.run() @bci=0 (Interpreted frame)
- java.lang.Thread.run() @bci=11, line=748 (Interpreted frame)
Thread 20: (state = BLOCKED)
- com.zero.demo.deadlock.DeadLock.lambda$deadLock$0() @bci=79, line=20 (Interpreted frame)
- com.zero.demo.deadlock.DeadLock$$Lambda$169.run() @bci=0 (Interpreted frame)
- java.lang.Thread.run() @bci=11, line=748 (Interpreted frame)
如何防止死锁
现在我们了解了死锁是如何发生的,我们也知道如何检查它们。
如果一个线程一次只能获取一个锁,则不会出现嵌套锁获取顺序导致的死锁问题。
以正确的顺序获取锁
如果必须获取多个锁,则必须考虑不同线程获取这些锁的顺序。 上例中出现死锁的根本原因是无序获取锁,这是我们无法控制的。 在上述示例的最佳情况下,应该抽象业务逻辑并将锁获取代码放置在公共方法中。
两个线程都从公共方法中获取锁。
当Thread1进入公共方法并获得锁A时,Thread2也进入,但锁A已经被Thread1持有,所以Thread2别无选择,只能阻塞等待。
随后,当Thread1获取锁B时,Thread2无法获取锁A,更不用说锁B了。这就建立了一个特定的顺序。
只有当Thread1释放所有锁时,Thread2才能获取它们。 例如,在前面的示例中,我们可以进行以下更改:
scss
@Component
publicclass DeadLock {
privatestatic Object lockA = new Object();
privatestatic Object lockB = new Object();
public void deadLock() {
Thread threadA = new Thread(() -> {
getLock();
});
Thread threadB = new Thread(() -> {
getLock();
});
threadA.start();
threadB.start();
}
private void getLock() {
synchronized (lockA) {
System.out.println(Thread.currentThread().getName() + "Get lockA success");
try {
TimeUnit.SECONDS.sleep(1);
System.out.println(Thread.currentThread().getName() + "Try to get lockB");
synchronized (lockB) {
System.out.println(Thread.currentThread().getName() + "Get lockB success");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
查看打印结果,我们可以观察到线程 4 成功获取了锁,允许线程 3 继续获取锁。
vbnet
Thread-4 Get lockA success
Thread-4 Try to get lockB
Thread-4 Get lockB success
Thread-3 Get lockA success
Thread-3 Try to get lockB
Thread-3 Get lockB success
超时和放弃
当线程尝试获取锁时超时,它会放弃该尝试,从而防止发生与锁相关的死锁。
当使用synchronized关键字提供的内置锁时,如果线程无法获得锁,就会无限期地等待。
但是,Lock 接口提供了一个方法 boolean tryLock(long time, TimeUnit unit) throws InterruptedException,该方法允许线程等待固定的时间来获取锁。
这使得线程可以在超时后主动释放之前获取的所有锁,有效避免死锁。
总结
当两个任务以不正确的顺序不合理地争用资源时,就会发生死锁。 因此,为了减少死锁,应用程序必须以正确的顺序处理资源获取。
有的时候,死锁可能不会立即在应用程序中显现出来。 通常,它们会在应用程序在生产环境中运行一段时间后逐渐浮出水面。
由于死锁的隐蔽性,在测试过程中检查它们具有挑战性,并且它们经常出现在最坏的情况下,特别是在生产环境的高负载下。 因此,开发人员在开发过程中应该仔细分析各个系统资源的利用率,有效避免死锁。
此外,如果确实发生死锁,可以使用本文提到的工具进行深入分析,从而找出根本原因。