周五晚上改了一行 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 的:
既然大家都是 RUNNABLE,那为什么程序停滞不前呢?
Runnable 的线程真的 Runnable 吗?
JStack 输出的线程状态有下面几种:
- RUNNABLE:正在执行,或者一旦有 CPU 资源,就可以执行的线程
- BLOCKED:未能抢占到锁,被阻塞住
- WAITING: 等待被唤醒,包括
- 调用了 Object.wait 方法,还没有被其他线程唤醒
- 被 concurrent 包中的锁阻塞,基本都是这个状态
- TIMED_WAITING: 带超时的阻塞,包括 sleep
它们之间的状态转移图如下:
不过这些状态的含义没有它字面上的那么简单。 当 JVM 在执行 Native 方法时,因为无法感知其中的线程状态,所以统一展示为 RUNNABLE。
Native 方法是指用 C/C++ 实现的方法。因为不是用 Java 语言实现,所以状态脱离了虚拟机的管理。
比如 socketRead0,socketWrite0,accept0 等网络读写方法也都展示为 RUNNABLE。比如下面 Tomcat 在等待新连接的主进程,显然不可能正在执行的,但是 JStack 中显示的状态却是 RUNNABLE:
从栈顶的代码 socketAccept 可以看出,线程在等待网络连接,并且该方法还是一个 Native 方法
而类加载,也是一段 native 代码,所以即使被阻塞了,状态也会展示为 RUNNABLE。
类加载死锁
为什么类加载的过程也会阻塞呢?
这就和 Java 的类加载机制有关了。
首先 Java 类不是在应用启动时加载的,而是在第一次使用的时候加载,比如新建对象,调用它的某个静态方法,或者直接 .class
获得这个类。
在多线程环境下,如果有多个线程同时去初始化同一个类,那么JVM会确保只有一个线程能够真正执行这个类的初始化代码块(<clinit>()
方法)
因而在 JStack 中看到的现象就是:
- 一个线程的堆栈执行到了 cinit 方法
- 其他线程都停在了需要加载该类的这一行(但是处于 RUNNABLE)
线程编号 | 堆栈 | 代码 |
---|---|---|
线程1 | static 代码块的内部代码,说明线程1成功进入了类加载过程 | |
线程2 | new My() | |
线程3 | Class.forName |
可见在类加载的过程中,其实是有一把隐藏的锁,把 线程2 和 线程3 给阻塞住了。如果有两把这样的锁存在,就可能导致死锁。
线程2,线程3 这种线程非常干扰问题排查,因为从表面上看,线程根本不可能停在这一行。不过当我们发现大量线程停顿的点都涉及同一个类的时候,就有理由认为是发生类加载相关的死锁了
类加载死锁(class loading deadlock)其实已经是 Java 中比较经典的死锁问题了, JDK 的开发者也发现 JStack 的这个状态显示有些不友好(相关issue)。如果应用运行的 JDK 环境是 JDK13 或者更高,虽然还是 RUNNABLE 状态,但是在栈顶会清晰地标注 “等待类加载”,并且还会告知在等待哪个类的加载:
老版本的 JStack 虽然没有这个提示,也可以通过阅读源码,知道正在加载的类
从这里之前理解的原理,和 JStack 的输出中,就很明显是类加载死锁了:
线上实际遇到的问题其实更加复杂。死锁是由两个锁点组成,上面案例中,两个锁点刚好都是类加载锁,现实中可能存在有一个锁点是同步锁,而另一个是类加载锁的情况,因此需要灵活判断:
启示录
如何才能避免这个问题呢?
项目采用了工厂模式,但是因为偷懒,将工厂的功能直接放在了抽象父类中,导致了类之间的循环依赖:
- 子类需要泛化父类
- 父类为了实现工厂功能,需要引用所有子类
按照标准的工厂模式,将抽象父类与工厂的功能分离,就能解掉这个循环依赖:
在使用静态代码块时,要注意梳理类之间的依赖关系,避免其中包含这样的循环依赖。