跳到主要内容
约 9 分钟4 张卡

类加载故障

类加载故障包括找不到类、二进制定义不兼容,以及初始化失败或持续等待。它们常在启动、第一次请求或热部署时出现。本文先解释初始化中的等待与失败,再说明版本冲突,最后结合异常和类来源定位原因。

1. 面试核心​

先记住这几条,再读正文
  • 查找与定义失败。 ClassNotFoundException 表示显式查找类失败,NoClassDefFoundError 可由运行时缺少依赖或此前初始化失败触发。卡 · 初始化失败与缺少类定义
  • 首次初始化异常。 静态初始化首次抛出非 Error 异常时,会包装为 ExceptionInInitializerError,原异常为 Error 时则直接传播。
  • 错误状态。 初始化失败的类会进入错误状态,再次主动使用同一个 Class 时抛出 NoClassDefFoundError。卡 · 初始化失败与缺少类定义
  • 方法链接错误。 NoSuchMethodError 表示运行时解析不到调用方所需的方法名和描述符,描述符包含参数与返回类型。卡 · 二进制版本冲突
  • 初始化死锁。 两个线程分别初始化两个类,又各自等待对方完成时,会形成类初始化死锁。卡 · 类初始化死锁

2. 类初始化锁​

多个线程第一次主动使用同一个类时,JVM 需要让它的静态初始化只执行一次。为此,每个类或接口都有独立的初始化锁,用来协调谁负责初始化,以及其他线程何时可以继续。

一个线程负责初始化后,其他需要该类完成初始化的线程会等待结果。正常完成时,静态初始化的结果对后续使用者可见。因此,静态代码中的阻塞也会让这些使用者一起等待:

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

示例中的 loadFromRemote() 如果一直等待网络,首次使用 Registry 的线程也会一直等待。类似情况还包括等待线程池任务,或等待另一个尚未完成初始化的类。故障这时表现为启动停住,而不是立即抛出异常。

静态初始化适合建立能快速完成的内存状态。外部 I/O 和复杂依赖应放进显式的启动步骤,以便单独设置超时、记录失败和重试。

3. 类初始化死锁​

两个类互相读取静态字段时,结果取决于由哪个线程负责初始化。下面这段代码在单线程中会读到默认值,在特定并发时序下则会死锁:

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

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

单线程先访问 A 时,会进入 B 的初始化,再从 B 访问 A。JVM 发现 A 正由当前线程初始化,就允许这次递归请求继续。此时 A.VALUE 尚未完成赋值,所以 B 读到 null,本例两个字段最终都是 null。

并发死锁需要形成下面的跨线程等待关系:

  1. 线程一开始初始化 A,线程二开始初始化 B。
  2. 线程一读取 B.VALUE,等待线程二完成 B 的初始化。
  3. 线程二读取 A.VALUE,等待线程一完成 A 的初始化。双方都无法继续,因此形成等待环。

<clinit> 是静态字段赋值和静态代码块对应的类初始化方法。执行它之前,负责初始化的线程已经释放初始化锁。这里的死锁来自等待对方完成初始化的依赖环,而非双方一直持有初始化锁。

常规 Java monitor 死锁检测未必会直接报告这种等待。排查时,要对照多个线程栈中的 <clinit> 和初始化等待,再结合类加载日志还原依赖关系。

消除这类等待,需要调整静态初始化中的依赖:

  • 静态初始化只建立能快速完成的内存状态。
  • 将启动线程并等待它结束的逻辑移到显式启动步骤中。
  • 把外部 I/O 和复杂依赖移出静态代码,单独管理它们的执行过程。
  • 消除双向静态依赖,或集中安排单一初始化顺序。

4. 初始化失败与缺少类定义​

同一个类第一次初始化失败,与后续再次使用它,抛出的错误可以不同。首次执行静态初始化时,如果抛出的是非 Error 异常,JVM 会将它包装为 ExceptionInInitializerError:

ExceptionInInitializerError
Caused by: IllegalStateException: missing endpoint

示例中的根因是 IllegalStateException,可以从 Caused by 中找到。若原异常已经是 Error,首次调用会直接传播它。若创建包装异常时内存不足,则改为抛出 OutOfMemoryError。

无论属于哪一种失败,该类都会进入错误状态。后续再次主动使用同一个 Class 时,会得到:

NoClassDefFoundError: Could not initialize class com.example.Config

这时 Class 文件仍然可以存在,失败的是该类的初始化状态。同一个 Class 的初始化不会自动重试。因此,定位要回到最早异常和完整异常原因链,也就是沿 Caused by 找到最初抛错的位置。

还要区分显式查找失败与 JVM 加载依赖失败。Class.forName、ClassLoader.loadClass 等 API 找不到类时,会抛出受检异常 ClassNotFoundException。JVM 加载所需依赖失败时,也能抛出 NoClassDefFoundError。所以,判断后者的原因要一起看错误消息与首次异常。

5. 二进制版本冲突​

源码能编译通过,运行时仍可能加载到不兼容的依赖。比如,应用按新版本编译,部署时却拿到了旧版本。JVM 在解析或调用成员时,就会发现二进制定义与调用方的要求不一致:

错误含义
NoSuchMethodError解析时找不到目标方法描述符。
NoSuchFieldError解析时找不到目标字段。
AbstractMethodError调用到缺少有效具体实现的方法。
IncompatibleClassChangeError类、接口或成员形态发生不兼容变化。
UnsupportedClassVersionError运行 JDK 不支持该 Class 版本。

这些错误属于 LinkageError。排查方法冲突时,要比较完整的方法名和描述符,描述符包含参数类型与返回类型。因此,只看到同名方法存在,还需要继续核对参数和返回类型。

成员从静态改为实例,或类型从类改为接口,也会改变二进制调用方式。重新编译调用方可以提前暴露部分问题,但最终仍需统一运行时依赖,或恢复新旧版本之间的二进制兼容。

6. 类来源证据​

确定版本冲突后,下一步是确认运行时究竟加载了哪个类。JDK 9 及以后可以用类加载日志记录来源,再用 jcmd 查看加载器:

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());

getCodeSource() 返回的来源可以为空,这时要结合加载日志继续判断。然后查看构建依赖图,并对照最终打包产物:

mvn dependency:tree
./gradlew dependencies

依赖图说明构建时选了哪些版本,加载日志说明运行时实际用了哪一份类。以下运行来源会让两者不同:

  • 启动脚本可以添加额外的类路径,改变类的查找顺序。
  • 容器挂载和应用服务器共享库可以提供打包产物之外的 JAR。
  • shaded JAR 会把依赖类合并到另一个 JAR 中,检查时也要查看其中实际包含的类。

7. 加载器类型冲突​

两个加载器分别定义同名类时,JVM 会把它们视为不同类型。强制转换因此能够报出“X cannot be cast to X”。要确认这种情况,需要分别打印对象实际类型与目标类型的加载器、模块和来源:

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());
}

共享 API 和跨边界传递的类型应由共同父加载器定义,隔离的实现类型留在各自加载器内,见类加载器。

8. 排查顺序​

排查时,先保留首次失败的现场,再逐步确认错误阶段与运行来源。可以按以下证据顺序推进:

  1. 保留最早异常和完整异常原因链。发生等待时,同时保存多个线程的转储。
  2. 判断故障属于查找失败、链接冲突、初始化异常,还是等待其他线程完成初始化。
  3. 记录目标类的加载器、模块和代码来源,确认实际使用的类型。
  4. 开启类加载日志复现,对照构建依赖图与最终产物,确认候选 JAR 的选择顺序。
  5. 核对真实启动命令、容器挂载与服务器共享库,找出构建之外的类来源。
  6. 消除重复依赖或循环初始化链,再验证冷启动、发生错误的调用路径与热部署。

9. 常见问题​

重启后的表现和构建依赖图都只能提供部分线索。判断是否修复,需要回到实际加载的类与初始化状态。

9.1 为什么重启后问题暂时消失?​

进程重启会清除热部署留下的旧加载器,也会改变类加载、首次请求和并发初始化的时序。因此,同一个循环依赖可以在某次启动中触发,在另一次启动中暂时没有出现。重启前应保留线程转储、首个异常和类加载日志,用来定位原来的原因。

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

9.3 初始化失败后能在同一加载器中重试吗?​

10 · 面试题卡片​

10.1 ClassNotFoundException 与 NoClassDefFoundError 有什么区别​

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

考察重点:区分显式类查找失败、运行时依赖缺失与初始化错误,并能根据首次异常判断原因。

相关内容:初始化失败与缺少类定义和二进制版本冲突。

参考回答

ClassNotFoundException 表示显式查找 API 找不到类,NoClassDefFoundError 表示 JVM 加载或使用所需类定义失败。

前者是受检异常,常见于 Class.forName 和 ClassLoader.loadClass。后者属于 LinkageError,运行时缺少依赖,或该类此前初始化失败,都能触发它。

看到 Could not initialize class 时,应回到最早的初始化异常和完整原因链。再结合类加载日志确认来源,就能区分缺少 Class 文件与类已经进入错误状态。

10.2 线上出现 NoSuchMethodError 应该怎样排查​

出现公司:阿里巴巴

考察重点:根据参数、返回类型和实际类来源定位版本冲突,并区分依赖图与加载日志各能说明什么。

相关内容:二进制版本冲突至排查顺序。

参考回答

先核对报错要求的方法名和描述符,再确认运行时加载了哪份类,定位编译与运行依赖的差异。

这类错误表示运行时解析不到调用方要求的方法。描述符包含参数和返回类型,因此要保留完整报错,并按以下证据顺序检查:

  1. 打印目标类的代码来源和加载器,用 -Xlog:class+load=info 确认实际 JAR。
  2. 对照 Maven 或 Gradle 依赖图、打包产物和启动脚本,找出版本从哪里发生变化。
  3. 统一运行时依赖或恢复二进制兼容,再验证发生错误的调用路径。

如果只调整类路径顺序,仍会留下多份不兼容定义。修复应消除这种版本冲突,才能让后续发布保持稳定。

10.3 静态初始化首次抛出 Error 后,再次使用会怎样?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:说明首次 Error 如何传播、类如何进入错误状态,以及后续主动使用为什么持续失败。

相关内容:初始化失败与缺少类定义。

参考回答

首次调用直接传播原 Error,再次主动使用同一个 Class 时会抛出 NoClassDefFoundError。

这是因为首次失败后,JVM 已将该类标记为错误状态。后续使用只会看到失败状态,同一类的初始化不会自动重试。因此,需要重试的外部操作应移出静态初始化,用显式方法管理。

10.4 两个类互相引用静态字段,为何单线程成功而并发死锁?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:区分同一线程的递归初始化与跨线程等待,并解释默认值和死锁分别怎样出现。

相关内容:类初始化死锁。

参考回答

单线程递归请求自己正在初始化的类时,JVM 允许继续;两个线程则会等待对方负责的类完成初始化。

单线程中,尚未执行赋值的静态字段仍是默认值,因此示例会读到 null。并发时,如果两个线程分别开始初始化 A 和 B,再各自读取对方的字段,就会形成等待环。这里等待的是对方完成初始化,执行 <clinit> 时初始化锁已经释放。

11. 参考资料​