一行代码导致的死锁悬案

813 阅读5分钟

周五晚上改了一行 Java 代码,自信满满地发布上线,结果发布一半,整个系统突然陷入了停滞,所有请求都报错 "Threadpool is exhausted",并且流量没有任何突增,GC 也没有明显增加,很可能是死锁了。赶紧将机器下线并进行排查。

随着排查的深入,案情越来越玄乎,玄就玄在:

  • 唯一的改动就是在子类中加了一行新建父类的代码,类似于 MyParent mp = new MyParent();
  • 应用中所有线程都处于 RUNNABLE 状态,那是怎么陷入停顿的呢?

复现代码

我设计了一个最小复现代码供大家玩赏:

  • MyParent.java
package simpledeadlock;  
  
import java.util.HashMap;  
import java.util.Map;  
  
public class MyParent {  
  
    private static final Map<String, Class<?>> REGISTRY = new HashMap<>();  
  
    static {  
        try {
            // 为了确保死锁可以稳定复现,增加一个 sleep
            Thread.sleep(1000);  
            REGISTRY.put("cc", Class.forName("simpledeadlock.MyChild"));  
        } catch (Exception ignore) {  
        }  
    }  
  
}
  • MyChild.java
package simpledeadlock;  
 
public class MyChild extends MyParent {  
   static {
       // 发布中唯一的一行变更 
       MyParent mp = new MyParent();  
   }  
}                            
  • Main.java:执行代码
package simpledeadlock;  
  
import java.util.concurrent.CountDownLatch;  
  
public class MyMain {  
  
    public static void main(String[] args) throws InterruptedException {  
        CountDownLatch cdl = new CountDownLatch(2);  
        new Thread(() -> {  
            MyParent myParent = new MyParent();  
            cdl.countDown();  
        }, "parent thread").start();  
        new Thread(() -> {  
            MyChild myChild = new MyChild();  
            cdl.countDown();  
        }, "child thread").start();  
        cdl.await();  
    }  
  
}                                                                    

执行 Main,会发现程序永远也结束不了。

如果尝试把 MyParent.java 中的 Thread.sleep(1000); 删除掉,程序就可以很快结束了,说明正常情况下是可以很快结束的。结束不了是因为触发了某种死锁。

使用 jps 得知应用的进程 ID 是 88346,然后用 jstack 输出堆栈,发现所有线程居然都是 RUNNABLE 的:
Pasted image 20240831144110.png 既然大家都是 RUNNABLE,那为什么程序停滞不前呢? Pasted image 20240831113115.png

Runnable 的线程真的 Runnable 吗?

JStack 输出的线程状态有下面几种:

  • RUNNABLE:正在执行,或者一旦有 CPU 资源,就可以执行的线程
  • BLOCKED:未能抢占到锁,被阻塞住
  • WAITING: 等待被唤醒,包括
    • 调用了 Object.wait 方法,还没有被其他线程唤醒
    • 被 concurrent 包中的锁阻塞,基本都是这个状态
  • TIMED_WAITING: 带超时的阻塞,包括 sleep

它们之间的状态转移图如下: Pasted image 20240831144504.png

不过这些状态的含义没有它字面上的那么简单。 当 JVM 在执行 Native 方法时,因为无法感知其中的线程状态,所以统一展示为 RUNNABLE。

Native 方法是指用 C/C++ 实现的方法。因为不是用 Java 语言实现,所以状态脱离了虚拟机的管理。

比如 socketRead0,socketWrite0,accept0 等网络读写方法也都展示为 RUNNABLE。比如下面 Tomcat 在等待新连接的主进程,显然不可能正在执行的,但是 JStack 中显示的状态却是 RUNNABLE:

Pasted image 20240831145758.png

从栈顶的代码 socketAccept 可以看出,线程在等待网络连接,并且该方法还是一个 Native 方法

而类加载,也是一段 native 代码,所以即使被阻塞了,状态也会展示为 RUNNABLE。

类加载死锁

为什么类加载的过程也会阻塞呢?

这就和 Java 的类加载机制有关了。

首先 Java 类不是在应用启动时加载的,而是在第一次使用的时候加载,比如新建对象,调用它的某个静态方法,或者直接 .class 获得这个类。

在多线程环境下,如果有多个线程同时去初始化同一个类,那么JVM会确保只有一个线程能够真正执行这个类的初始化代码块(<clinit>()方法)

因而在 JStack 中看到的现象就是:

  • 一个线程的堆栈执行到了 cinit 方法
  • 其他线程都停在了需要加载该类的这一行(但是处于 RUNNABLE)

Pasted image 20240831154603.png

线程编号堆栈代码
线程1Pasted image 20240831154757.pngstatic 代码块的内部代码,说明线程1成功进入了类加载过程
线程2Pasted image 20240831154823.pngnew My()
线程3Pasted image 20240831155008.pngClass.forName

可见在类加载的过程中,其实是有一把隐藏的锁,把 线程2 和 线程3 给阻塞住了。如果有两把这样的锁存在,就可能导致死锁。

线程2,线程3 这种线程非常干扰问题排查,因为从表面上看,线程根本不可能停在这一行。不过当我们发现大量线程停顿的点都涉及同一个类的时候,就有理由认为是发生类加载相关的死锁了

类加载死锁(class loading deadlock)其实已经是 Java 中比较经典的死锁问题了, JDK 的开发者也发现 JStack 的这个状态显示有些不友好(相关issue)。如果应用运行的 JDK 环境是 JDK13 或者更高,虽然还是 RUNNABLE 状态,但是在栈顶会清晰地标注 “等待类加载”,并且还会告知在等待哪个类的加载:

老版本的 JStack 虽然没有这个提示,也可以通过阅读源码,知道正在加载的类

Pasted image 20240831161648.png 从这里之前理解的原理,和 JStack 的输出中,就很明显是类加载死锁了: Pasted image 20240831162555.png
线上实际遇到的问题其实更加复杂。死锁是由两个锁点组成,上面案例中,两个锁点刚好都是类加载锁,现实中可能存在有一个锁点是同步锁,而另一个是类加载锁的情况,因此需要灵活判断:

Pasted image 20240831163438.png

启示录

如何才能避免这个问题呢?

项目采用了工厂模式,但是因为偷懒,将工厂的功能直接放在了抽象父类中,导致了类之间的循环依赖:

  • 子类需要泛化父类
  • 父类为了实现工厂功能,需要引用所有子类

Pasted image 20240831165644.png

按照标准的工厂模式,将抽象父类与工厂的功能分离,就能解掉这个循环依赖:

Pasted image 20240831172143.png

在使用静态代码块时,要注意梳理类之间的依赖关系,避免其中包含这样的循环依赖。