类加载故障
类加载故障包括找不到类、二进制定义不兼容,以及初始化失败或持续等待。它们常在启动、第一次请求或热部署时出现。本文先解释初始化中的等待与失败,再说明版本冲突,最后结合异常和类来源定位原因。
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。
并发死锁需要形成下面的跨线程等待关系:
- 线程一开始初始化 A,线程二开始初始化 B。
- 线程一读取
B.VALUE,等待线程二完成 B 的初始化。 - 线程二读取
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. 排查顺序
排查时,先保留首次失败的现场,再逐步确认错误阶段与运行来源。可以按以下证据顺序推进:
- 保留最早异常和完整异常原因链。发生等待时,同时保存多个线程的转储。
- 判断故障属于查找失败、链接冲突、初始化异常,还是等待其他线程完成初始化。
- 记录目标类的加载器、模块和代码来源,确认实际使用的类型。
- 开启类加载日志复现,对照构建依赖图与最终产物,确认候选 JAR 的选择顺序。
- 核对真实启动命令、容器挂载与服务器共享库,找出构建之外的类来源。
- 消除重复依赖或循环初始化链,再验证冷启动、发生错误的调用路径与热部署。
9. 常见问题
重启后的表现和构建依赖图都只能提供部分线索。判断是否修复,需要回到实际加载的类与初始化状态。
9.1 为什么重启后问题暂时消失?
进程重启会清除热部署留下的旧加载器,也会改变类加载、首次请求和并发初始化的时序。因此,同一个循环依赖可以在某次启动中触发,在另一次启动中暂时没有出现。重启前应保留线程转储、首个异常和类加载日志,用来定位原来的原因。
9.2 依赖树只有一个版本,为什么运行时仍有冲突?
依赖树只描述构建依赖,运行时还可以从其他位置获得类。需要补查以下额外类来源:
- 检查应用服务器共享目录和手工复制的 JAR,看是否存在另一份依赖。
- 检查容器基础镜像和挂载目录中的库,确认部署时额外带入了哪些类。
- 检查 Java Agent,也就是启动或运行时接入 JVM 的代理程序,看它是否提供了额外定义。
- 检查运行时生成的类,确认生成器使用了哪些接口与依赖版本。
这些来源要与实际加载日志对应起来,才能确认哪份定义参与了冲突。
9.3 初始化失败后能在同一加载器中重试吗?
同一个 Class 进入错误状态后,后续主动使用会持续失败,无法重新执行它的初始化。需要重试的业务操作应放在显式初始化方法中,独立管理状态和重试。插件或热部署架构也可以创建新加载器重新定义类型,但这时得到的是另一个 Class。
10 · 面试题卡片
10.1 ClassNotFoundException 与 NoClassDefFoundError 有什么区别
出现公司:字节跳动、阿里巴巴
考察重点:区分显式类查找失败、运行时依赖缺失与初始化错误,并能根据首次异常判断原因。
相关内容:初始化失败与缺少类定义和二进制版本冲突。
参考回答
ClassNotFoundException 表示显式查找 API 找不到类,NoClassDefFoundError 表示 JVM 加载或使用所需类定义失败。
前者是受检异常,常见于 Class.forName 和 ClassLoader.loadClass。后者属于 LinkageError,运行时缺少依赖,或该类此前初始化失败,都能触发它。
看到 Could not initialize class 时,应回到最早的初始化异常和完整原因链。再结合类加载日志确认来源,就能区分缺少 Class 文件与类已经进入错误状态。
10.2 线上出现 NoSuchMethodError 应该怎样排查
出现公司:阿里巴巴
考察重点:根据参数、返回类型和实际类来源定位版本冲突,并区分依赖图与加载日志各能说明什么。
参考回答
先核对报错要求的方法名和描述符,再确认运行时加载了哪份类,定位编译与运行依赖的差异。
这类错误表示运行时解析不到调用方要求的方法。描述符包含参数和返回类型,因此要保留完整报错,并按以下证据顺序检查:
- 打印目标类的代码来源和加载器,用
-Xlog:class+load=info确认实际 JAR。 - 对照 Maven 或 Gradle 依赖图、打包产物和启动脚本,找出版本从哪里发生变化。
- 统一运行时依赖或恢复二进制兼容,再验证发生错误的调用路径。
如果只调整类路径顺序,仍会留下多份不兼容定义。修复应消除这种版本冲突,才能让后续发布保持稳定。
10.3 静态初始化首次抛出 Error 后,再次使用会怎样?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:说明首次 Error 如何传播、类如何进入错误状态,以及后续主动使用为什么持续失败。
相关内容:初始化失败与缺少类定义。
参考回答
首次调用直接传播原 Error,再次主动使用同一个 Class 时会抛出 NoClassDefFoundError。
这是因为首次失败后,JVM 已将该类标记为错误状态。后续使用只会看到失败状态,同一类的初始化不会自动重试。因此,需要重试的外部操作应移出静态初始化,用显式方法管理。
10.4 两个类互相引用静态字段,为何单线程成功而并发死锁?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:区分同一线程的递归初始化与跨线程等待,并解释默认值和死锁分别怎样出现。
相关内容:类初始化死锁。
参考回答
单线程递归请求自己正在初始化的类时,JVM 允许继续;两个线程则会等待对方负责的类完成初始化。
单线程中,尚未执行赋值的静态字段仍是默认值,因此示例会读到 null。并发时,如果两个线程分别开始初始化 A 和 B,再各自读取对方的字段,就会形成等待环。这里等待的是对方完成初始化,执行 <clinit> 时初始化锁已经释放。