跳到主要内容

类初始化死锁与版本冲突排查

类加载故障常在启动、第一次请求或热部署时出现。排查时先区分“没有找到定义”“找到了不兼容定义”“初始化执行失败”和“初始化没有进展”,再检查实际类来源与定义加载器。

1. 类初始化由 JVM 串行保护

每个类或接口都有独立的初始化锁。同一时刻只有一个线程执行它的类初始化方法,其他主动使用该类的线程会等待初始化完成。

这保证静态状态不会被初始化两次,但也意味着静态代码里的阻塞会卡住所有首次使用者:

final class Registry {
static final Map<String, Handler> HANDLERS = loadFromRemote();
}

loadFromRemote 等待网络、线程池或另一个尚未初始化的类,故障表现可能是启动停住,而不是立即抛异常。

2. 两个类可以形成初始化死锁

final class A {
static final Object VALUE = B.VALUE;
}

final class B {
static final Object VALUE = A.VALUE;
}

单线程访问只会得到初始化顺序带来的中间默认值;若两个线程分别开始初始化 A 和 B,并在各自持有初始化锁时等待对方完成,就可能停止前进。

线程转储中不一定以常规 Java monitor deadlock 的形式直接报告。应查找多个线程停在类初始化或 <clinit> 相关栈帧,并结合类加载日志确认相互依赖。

避免方法包括:

  • 静态初始化只建立内存内、可快速完成的值。
  • 不在静态代码中启动并等待线程。
  • 把外部 I/O 和复杂依赖改为显式生命周期步骤。
  • 消除双向静态依赖,或集中到单一初始化顺序。

3. 初始化异常和缺少定义要分开

ExceptionInInitializerError
Caused by: IllegalStateException: missing endpoint

第一次失败时,根因通常在 Caused by。随后访问同一类可能只看到:

NoClassDefFoundError: Could not initialize class com.example.Config

这不代表 Class 文件消失。需要回到进程最早的初始化异常,而不是继续修改 classpath。

显式查找一个从未存在的类通常是 ClassNotFoundException。区分错误类型可以大幅缩小排查范围。

4. 二进制版本冲突有稳定的错误形态

应用按新版本依赖编译,却在运行时加载旧版本,常见错误包括:

错误常见含义
NoSuchMethodError运行时类型没有编译期调用的方法描述符
NoSuchFieldError运行时类型没有编译期引用的字段
AbstractMethodError调用方预期实现存在,但实际实现类没有该方法
IncompatibleClassChangeError类/接口或静态/实例形态发生不兼容变化
UnsupportedClassVersionError运行 JDK 不支持该 Class 文件版本

这类 LinkageError 说明源码可能编译成功,但运行时解析到另一份二进制定义。重新编译调用方有时能暴露问题,但根本修复是统一运行时依赖和兼容策略。

5. 先确认“谁从哪里加载了这个类”

java -Xlog:class+load=info -jar app.jar
jcmd <pid> VM.classloaders
jcmd <pid> VM.classloader_stats

在可修改代码时:

Class<?> type = SomeLibrary.class;
System.out.println(type.getProtectionDomain().getCodeSource());
System.out.println(type.getClassLoader());

然后检查构建依赖图:

mvn dependency:tree
./gradlew dependencies

依赖图说明打包选择,类加载日志说明运行事实。容器挂载、启动脚本、应用服务器共享库和 shaded JAR 都可能让两者不同。

6. ClassCastException 也可能是加载器冲突

错误看似是“X cannot be cast to X”时,通常有两个加载器分别定义了同名 X。打印两边 Class 的加载器、模块和代码来源:

static void describe(Class<?> type) {
System.out.printf("%s loader=%s module=%s source=%s%n",
type.getName(),
type.getClassLoader(),
type.getModule(),
type.getProtectionDomain().getCodeSource());
}

修复方向通常是把共享接口提升到共同父加载器,或避免跨隔离边界直接传递实现对象。

7. 一条可重复的排查顺序

  1. 保留最早异常和完整 cause chain。
  2. 判断是查找、链接、初始化还是等待问题。
  3. 记录目标类的加载器、模块和代码来源。
  4. 打开类加载日志复现,确认候选 JAR 的选择顺序。
  5. 对照构建依赖图、最终产物和真实启动命令。
  6. 最小化重复依赖或静态初始化链,再验证冷启动与热部署。

8. 常见问题

8.1 为什么重启后问题暂时消失

类加载顺序、首次请求和并发初始化时序可能改变;热部署遗留的旧加载器也会被进程重启清除。重启只改变现场,仍应保留重启前线程转储、类加载日志和首个异常。

8.2 为什么依赖树只有一个版本,运行时仍冲突

依赖树不包含所有运行来源。应用服务器共享目录、手工复制的 JAR、容器基础镜像、Java Agent 和运行时生成类都可能引入额外定义。

8.3 初始化失败后能否在同一加载器中重试

不能让同一个 Class 重新初始化。可以显式设计可重试的业务初始化,或者创建新的类加载器重新定义类型,但后者只适合插件、热部署等明确架构。

9. 面试题

9.1 ClassNotFoundExceptionNoClassDefFoundError 有什么区别

出现公司:字节跳动、阿里巴巴

考察重点

  • 显式加载失败与 JVM 使用定义失败。
  • 初始化失败也可能导致 NoClassDefFoundError
  • 根据最早异常定位根因。

相关内容:第 3 节“初始化异常和缺少定义要分开”和第 4 节“二进制版本冲突有稳定的错误形态”。

参考回答

ClassNotFoundExceptionClass.forNameloadClass 等显式查找 API 找不到类时抛出的受检异常。NoClassDefFoundError 是 JVM 在链接或使用某个编译期存在的定义时无法得到它,可能因为运行时缺少 Class,也可能因为该类之前初始化失败。

排查时先找最早错误和 cause chain,再用类加载日志确认来源;看到 Could not initialize class 时不应只查 classpath。

9.2 线上出现 NoSuchMethodError 应该怎样排查

出现公司:阿里巴巴

考察重点

  • 编译期和运行时依赖不一致。
  • 方法描述符包含参数与返回类型。
  • 构建依赖图和类加载日志的证据范围。

相关内容:第 4 节“二进制版本冲突有稳定的错误形态”至第 7 节“一条可重复的排查顺序”。

参考回答

它通常表示调用方按一个版本的方法描述符编译,运行时却加载了没有该方法的另一版本。先保留完整报错,打印目标类的代码来源和加载器,开启 -Xlog:class+load 确认实际 JAR,再对照 Maven 或 Gradle 依赖图、打包产物和启动环境。

修复要统一运行时版本或恢复二进制兼容,不能只通过改变偶然的 classpath 顺序掩盖冲突。