类加载问题与SPI
类加载问题在业务开发中很常见,尤其是在大型项目、应用服务器、插件系统、微服务依赖复杂的场景中。SPI 则是理解“为什么有时要打破双亲委派”的经典例子。
# 1. ClassNotFoundException
ClassNotFoundException 是受检异常,通常发生在显式加载类时。
Class.forName("com.example.User");
如果 classpath 中没有这个类,就会抛出该异常。
常见原因:
- 依赖没有引入。
- 运行环境和编译环境不一致。
- 打包遗漏。
- 类名写错。
- 使用了错误的类加载器。
# 2. NoClassDefFoundError
NoClassDefFoundError 是 Error,通常表示编译时存在,运行时找不到或初始化失败。
常见场景:
- 编译时依赖存在,运行时依赖缺失。
- 类静态初始化失败。
- 依赖版本冲突。
它和 ClassNotFoundException 的区别是:前者常发生在 JVM 被动解析依赖时,后者常发生在主动按名称加载时。
# 3. ClassCastException: A cannot be cast to A
这种看似荒诞的错误通常来自类加载器隔离。
com.example.User cannot be cast to com.example.User
原因是两个 User 类由不同类加载器加载。对 JVM 来说,它们不是同一个类型。
常见于:
- 插件系统。
- Web 容器。
- 热部署。
- 多版本依赖隔离。
# 4. SPI 是什么
SPI(Service Provider Interface)是一种服务发现机制。
典型使用方式:
ServiceLoader<MyService> loader = ServiceLoader.load(MyService.class);
for (MyService service : loader) {
service.execute();
}
服务提供方在资源目录中声明实现类:
META-INF/services/com.example.MyService
文件内容:
com.example.MyServiceImpl
# 5. SPI 与线程上下文类加载器
SPI 的接口可能由核心类库加载,但实现类在应用 classpath 中。
如果完全使用父加载器向下加载,核心类加载器无法看到应用类路径中的实现类。
因此 Java 引入线程上下文类加载器:
Thread.currentThread().getContextClassLoader();
它允许父层代码反向使用应用层类加载器加载服务实现。
JDBC 驱动加载就是典型例子。
# 6. 排查类加载问题的方法
常用方法:
- 查看依赖树。
- 使用
-verbose:class或统一日志查看类加载。 - 打印
clazz.getClassLoader()。 - 检查 fat jar 或部署包内容。
- 检查容器类加载隔离规则。
- 排查同名不同版本依赖。
示例:
System.out.println(User.class.getClassLoader());
System.out.println(Thread.currentThread().getContextClassLoader());
# 7. SPI 加载流程图
调用方代码
ServiceLoader.load(接口)
│
▼
读取 META-INF/services/接口全限定名
│
▼
得到实现类全限定名
│
▼
使用线程上下文类加载器加载实现类
│
▼
创建实现类实例并返回
SPI 的关键是:接口和实现可以来自不同层级。父层框架定义接口,应用层提供实现,线程上下文类加载器让父层代码能“向下”看到应用层实现。
# 8. JDBC 驱动为什么能自动发现
现代 JDBC 驱动 jar 中通常包含:
META-INF/services/java.sql.Driver
内容类似:
com.mysql.cj.jdbc.Driver
DriverManager 通过 SPI 发现并加载驱动。这里就涉及父层 Java SQL API 和应用 classpath 中具体驱动实现之间的关系。
# 9. 类加载排查清单
遇到类加载问题,可以按下面顺序排查:
| 步骤 | 要看什么 |
|---|---|
| 1 | 报错是 ClassNotFoundException、NoClassDefFoundError 还是 LinkageError |
| 2 | 编译依赖和运行依赖是否一致 |
| 3 | 最终 jar/war 中是否真的包含目标类 |
| 4 | 是否有多个版本依赖 |
| 5 | 目标类由哪个 ClassLoader 加载 |
| 6 | 是否存在静态初始化失败 |
| 7 | 容器或插件系统是否做了类隔离 |
# 10. 开发中常见问题
# 10.1 NoClassDefFoundError 不一定是类不存在
类静态初始化失败后,再次使用也可能表现为 NoClassDefFoundError。
所以要回看第一次异常栈,找到最早的初始化失败原因。
# 10.2 SPI 文件写错很难发现
SPI 文件常见错误:
- 文件路径写错。
- 文件名不是接口全限定名。
- 实现类名写错。
- 实现类没有无参构造。
- 实现类依赖缺失。
# 11. Tips 快问快答
Q:SPI 为什么要用线程上下文类加载器?
A:因为父层框架代码需要加载应用层实现,普通双亲委派方向做不到,线程上下文类加载器提供了反向加载入口。
Q:ClassNotFoundException 和 NoClassDefFoundError 最大区别是什么?
A:前者通常是主动按名称加载失败,后者通常是运行时解析或初始化依赖失败。
Q:怎么打印类由谁加载?
A:SomeClass.class.getClassLoader()。
Q:为什么插件系统容易出现类冲突?
A:插件和主应用可能持有不同版本依赖,需要合理设计父子加载器和依赖隔离边界。
# 12. 总结
类加载问题往往不是“类真的不存在”这么简单,而是依赖、版本、加载器、初始化和容器隔离共同作用的结果。SPI 是理解线程上下文类加载器和双亲委派调整的最佳入口。