死锁是一种特定的程序状态,在实体之间,由于循环依赖导致彼此一直处于等待之中,没有任何个体可以继续前进。死锁不仅仅是在线程之间会发生,存在资源独占的进程之间同样也可能出现死锁。通常来说,我们大多是聚焦在多线程场景中的死锁,指两个或多个线程之间,由于互相持有对方需要的锁,而永久处于阻塞的状态。

你可以利用下面的示例图理解基本的死锁问题:
image.png
定位死锁最常见的方式是利用 jstack 等工具获取线程栈,然后定位互相之间的依赖关系,进而找到死锁。如果程序运行时发生了死锁,绝大多数情况下都是无法在线解决的,只能重启、修正程序本身问题。所以,代码开发阶段互相审查或者利用工具进行预防性排查,往往也是很重要的。

死锁分析

下面提供了一个基本的死锁示例,通过两个嵌套的 synchronized 去获取锁来产生死锁,具体代码如下:

  1. public class DeadLockSample extends Thread {
  2. private final String first;
  3. private final String second;
  4. public DeadLockSample(String name, String first, String second) {
  5. super(name);
  6. this.first = first;
  7. this.second = second;
  8. }
  9. @Override
  10. public void run() {
  11. synchronized (first) {
  12. System.out.println(this.getName() + " obtained: " + first);
  13. try {
  14. Thread.sleep(1000L);
  15. synchronized (second) {
  16. System.out.println(this.getName() + " obtained: " + second);
  17. }
  18. } catch (InterruptedException e) {
  19. // Do nothing
  20. }
  21. }
  22. }
  23. public static void main(String[] args) throws InterruptedException {
  24. String lockA = "lockA";
  25. String lockB = "lockB";
  26. DeadLockSample t1 = new DeadLockSample("Thread1", lockA, lockB);
  27. DeadLockSample t2 = new DeadLockSample("Thread2", lockB, lockA);
  28. t1.start();
  29. t2.start();
  30. t1.join();
  31. t2.join();
  32. }
  33. }

这个程序编译执行后,几乎每次都可以重现死锁,请看下面截取的输出。另外,有个比较有意思的地方,为什么先调用 Thread1 的 start,但是 Thread2 却先打印出来了呢?这就是因为线程调度依赖于(操作系统)调度器,虽然可以通过优先级之类进行影响,但是具体情况是不确定的。
image.png
下面来定位下死锁产生的代码位置,首先通过 jps 或者系统的 ps 命令、任务管理器等工具,确定进程 ID。
image.png
获取进程 ID 后,调用 jstack 获取线程栈:

  1. jstack -l 1382

然后,分析得到的线程栈信息,具体片段如下:
image.png
如图所示,jstack 打印的线程栈信息非常清晰的展示了死锁的位置。

如果我们是开发自己的管理工具,需要用更加程序化的方式扫描服务进程、定位死锁,可以考虑使用 Java 提供的标准管理 API,ThreadMXBean,其直接就提供了 findDeadlockedThreads() 方法用于定位。具体使用如下所示:

  1. public static void main(String[] args) throws InterruptedException {
  2. ThreadMXBean mbean = ManagementFactory.getThreadMXBean();
  3. Runnable dlCheck = () -> {
  4. long[] threadIds = mbean.findDeadlockedThreads();
  5. if (threadIds != null) {
  6. ThreadInfo[] threadInfos = mbean.getThreadInfo(threadIds);
  7. System.out.println("Detected deadlock threads:");
  8. for (ThreadInfo threadInfo : threadInfos) {
  9. System.out.println(threadInfo.getThreadName());
  10. }
  11. }
  12. };
  13. ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
  14. // 稍等5秒,然后每10秒进行一次死锁扫描
  15. scheduler.scheduleAtFixedRate(dlCheck, 5L, 10L, TimeUnit.SECONDS);
  16. // 死锁样例代码......
  17. }

在实际应用中,可以据此收集进一步的信息,然后进行预警等后续处理。但是要注意的是,对线程进行快照本身是一个相对重量级的操作,还是要慎重选择频度和时机。

预防死锁

要避免死锁就需要分析死锁发生的条件,只有以下这四个条件都发生时才会出现死锁:

  • 互斥条件,类似 Java 中的 Monitor 都是独占的,共享资源 X 和 Y 只能被一个线程占用

  • 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X

  • 不可抢占,其他线程不能强行抢占线程 T1 占有的资源

  • 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源

反过来分析,也就是说只要我们破坏其中一个条件,就可以成功避免死锁的发生。其中,互斥这个条件我们没有办法破坏,因为我们用锁的目的就是为了互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?

1)避免使用多个锁
对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。如果可能的话,尽量避免使用多个锁,并且只有需要时才持有锁。否则,即使是非常精通并发编程的工程师,也难免会掉进坑里,嵌套的 synchronized 或者 lock 非常容易出问题。

2)获取锁时提供超时时间
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。类似 Object.wait(…) 或者 CountDownLatch.await(…),都支持所谓的 timed_wait,我们完全可以就不假定该锁一定会获得,指定超时时间,并为无法得到锁时准备退出逻辑。

如 ReentrantLock 还支持非阻塞式的获取锁操作 tryLock(),这是一个插队行为,并不在乎等待的公平性,如果执行时对象恰好没有被独占,则直接获取锁。有时,我们希望条件允许就尝试插队,不然就按照现有公平性规则等待,一般可以采用下面的方法:

  1. if (lock.tryLock() || lock.tryLock(timeout, unit)) {
  2. // ...
  3. }

3)确认获取锁的顺序
对于“循环等待”这个条件,可以靠按序申请资源来预防,尽量设计好锁的获取顺序。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化以后再获取自然就不存在循环了。

业界也有一些其他方面的尝试,比如通过静态代码分析(如 FindBugs)去查找固定的模式,进而定位可能的死锁或者竞争情况。实践证明这种方法也有一定作用,请参考相关文档

除了典型的死锁场景,还有一些更令人头疼的死锁,比如类加载过程发生的死锁,尤其是在框架大量使用自定义类加载时,因为往往不是在应用本身的代码库中,jstack 等工具也不见得能够显示全部锁信息,所以处理起来比较棘手。对此,Java 有官方文档进行了详细解释,并针对特定情况提供了相应 JVM 参数和基本原则。