类加载与双亲委派:同名的类为什么不能互相转换
在 JVM 里,一个类的身份是「类名 + 定义它的类加载器」。双亲委派让核心类型只有一份;SPI、应用服务器和插件系统又有意调整委派方向。理解了这两点,
ClassCastException: X cannot be cast to X、ServiceLoader找不到实现、热部署后元空间上涨,都能找到原因。
一个插件系统上线后报了一个奇怪的错:class com.example.Plugin cannot be cast to class com.example.Plugin。两边的类名一模一样,代码也来自同一个 jar。原因在错误信息的后半句:一个来自 loader 'plugin-a',一个来自 loader 'plugin-b'。
本文在 JDK 21 上把类加载中最常踩的几件事逐一复现:类型身份、初始化时机、SPI 与线程上下文类加载器、父优先与子优先,以及类加载器为什么回收不掉。实验程序在运行时把几组源码编译到不同目录,再用不同的 URLClassLoader 加载。
一、先说结论
- 类型身份 = 类名 + 定义它的加载器:同一份字节码被两个加载器加载,是两个不兼容的类型,转换时抛
ClassCastException。 - 不是所有访问都会触发初始化:读取编译期常量、创建数组、
Class.forName(name, false, loader)都不触发;读取static final Integer会触发。初始化过程中还能读到字段在准备阶段的零值。 - SPI 靠线程上下文类加载器找到下层实现:接口在父加载器、实现在子加载器时,用接口自己的加载器找不到实现,用上下文类加载器才能找到。
- 子优先是插件隔离的常见做法,但
java.*包永远不能由自定义加载器定义。 - 类加载器泄漏来自外部引用:插件对象被父加载器里的静态集合、长期存活线程的
ThreadLocal引用时,整个插件加载器都回收不掉;清理这些引用后可以回收。 NoSuchMethodError通常是编译期和运行期版本不一致,不是代码写错。
二、类型身份:类名加上加载器
两个 URLClassLoader 指向同一个目录,各自加载 com.example.Plugin:
两个加载器加载同一份 com.example.Plugin:名称相同=true,Class 对象相同=false
class com.example.Plugin cannot be cast to class com.example.Plugin
(com.example.Plugin is in unnamed module of loader 'plugin-b' @<hash>;
com.example.Plugin is in unnamed module of loader 'plugin-a' @<hash>)新版本 JDK 的 ClassCastException 信息会带上加载器名称(本文为 JDK 21),遇到「同名不能转换」时,先看这里的两个加载器是不是同一个。常见的来源有:同一个 jar 同时出现在应用和容器的 classpath 里、插件把共享接口也打进了自己的包、热部署后旧对象还被缓存着。
这也是双亲委派存在的意义:加载器收到请求时先交给父加载器,父加载器能加载就用父加载器的版本。这样 java.lang.String、共享的 API 接口在整个应用里只有一份定义,不同模块传递对象时类型才能对得上。
三、加载、链接与初始化
一个类从字节码到可用,经历加载、链接(验证、准备、解析)和初始化:
| 阶段 | 做什么 |
|---|---|
| 加载 | 找到字节码,创建 Class 对象 |
| 验证 | 检查格式与类型安全 |
| 准备 | 为静态字段分配空间,设为零值 |
| 解析 | 把符号引用换成直接引用,可以延迟到第一次使用时 |
| 初始化 | 执行静态字段赋值和静态代码块 |
初始化只在「主动使用」时发生。实验里的类:
public class Config {
public static final int CONSTANT = 7; // 编译期常量
public static final Integer BOXED = 8; // 不是编译期常量
static int early = readLate(); // 初始化时读 late
static int late = 10;
static { System.out.println("初始化:early=" + early + ",late=" + late); }
static int readLate() { return late; }
}按顺序执行几种访问:
读取编译期常量 Config.CONSTANT=7
创建 Config[3] 数组,长度 3
Class.forName(name, false, loader) 得到 Config
Config 的静态初始化执行了;early=0,late=10
读取 Config.BOXED=8- 编译期常量在编译时就被写进了调用方的字节码,读取它根本不会访问
Config类; - 创建数组和
forName(..., false, ...)只加载、不初始化; - 读取
Integer类型的常量要执行静态初始化,此时early按代码顺序先执行,读到的late还是准备阶段的零值 0。
静态字段之间的初始化顺序依赖,是「为什么这个静态配置是 0」「为什么单例里某个字段是 null」这类问题的常见来源。编译期常量被内联也有副作用:修改常量后只重新编译定义它的类,调用方仍然使用旧值。
四、SPI:父加载器怎样找到子加载器里的实现
ServiceLoader 是 Java 的服务发现机制:接口由平台定义,实现在 META-INF/services/<接口全名> 里登记。问题是,接口通常由父加载器加载,而实现在应用自己的 jar 里,父加载器看不到子加载器的类。
实验里,接口 api.Greeter 在父加载器,实现和登记文件在子加载器。接口里有两种查找写法:
ServiceLoader.load(Greeter.class); // 使用线程上下文类加载器
ServiceLoader.load(Greeter.class, Greeter.class.getClassLoader()); // 使用接口自己的加载器上下文类加载器为父加载器时找到 0 个,为子加载器时找到 1 个;用接口自己的加载器查找找到 0 个线程上下文类加载器(TCCL)是 Java 为这类「上层查找下层」预留的通道:由调用方把能看到实现的加载器放在当前线程上。JDBC 驱动、JNDI、各种框架的扩展点都依赖它。这不是破坏了双亲委派,而是在委派之外另开的一条路。
这条路有一个副作用:线程创建时会继承父线程的上下文类加载器。
在上下文为 app 时创建的线程池,调用方切回原加载器后,工作线程的上下文类加载器仍是 app线程池的工作线程会一直记着创建时的加载器。插件卸载后,这些线程仍然引用着插件的加载器,这是下面第六节泄漏的一种来源。
五、父优先与子优先
应用服务器、插件框架需要让不同的插件使用同一个库的不同版本。做法是让插件的加载器子优先:先在自己的目录里找,找不到再交给父加载器。
父加载器有 lib.Version 1.0、插件目录有 2.0:父优先得到 1.0,子优先得到 2.0子优先的边界通常是:
- JDK 自己的类永远由父加载器提供。实验里的子优先加载器尝试自己定义
java.lang.Hacked,被 JVM 拒绝:SecurityException: Prohibited package name: java.lang; - 插件与宿主之间通信用的共享接口必须由父加载器加载,否则就会遇到第二节的「同名不能转换」;
- 插件的实现和私有依赖由插件自己的加载器加载,彼此隔离。
六、类加载器为什么回收不掉
一个类加载器,连同它加载的所有类、类的静态字段,只有在没有任何对象引用它们时才能被回收。实验让插件加载一个带 1MB 数组的对象,然后在三种情况下丢弃加载器,最多调用 20 次 System.gc():
丢弃加载器与实例后:加载器已被回收
实例登记在父加载器的静态列表里:加载器仍然存活
实例放进长期存活线程的 ThreadLocal:加载器仍然存活
在同一线程里 remove 之后:加载器已被回收一个对象就能留住整个加载器:对象 → 它的类 → 类的加载器 → 这个加载器加载过的所有类。热部署、插件卸载之后元空间持续上涨,通常就是这样一条引用链。常见的「钉子」:
- 宿主里的静态缓存、注册表、监听器列表持有插件对象;
- 线程池工作线程的
ThreadLocal或上下文类加载器; DriverManager里注册的 JDBC 驱动;- 插件自己启动、没有停止的线程或定时器。
卸载插件时要把这些都清理掉,然后用堆转储确认加载器确实不再可达,排查方法见 JVM 调优与线上排查。
七、NoSuchMethodError:编译时和运行时不是同一个版本
调用方按 API 2.0 编译,用到了新增的 call(int timeoutMs);运行时 classpath 上却是 1.0:
NoSuchMethodError: 'java.lang.String api.Client.call(int)'编译通过、运行失败,是因为方法的解析发生在运行时。依赖冲突时 Maven 按「最近者优先」选择版本,不一定是最新的那个;排查时用 mvn dependency:tree 看实际被选中的版本,或者在运行时打印 Client.class.getProtectionDomain().getCodeSource() 看类来自哪个 jar。Maven 各个 scope 在编译期与运行期的可见性差异,见 Maven Scope。
八、常见误区
- 「类名相同就是同一个类」:还要看定义它的加载器。
- 「类加载了就初始化了」:加载、链接和初始化是分开的,数组、常量、
forName(..., false, ...)都不触发初始化。 - 「SPI 破坏了双亲委派所以不安全」:它通过上下文类加载器提供了一条受控的反向查找路径,核心类仍然只有一份。
- 「System.gc() 之后类就卸载了」:只要还有一条引用链指向加载器,它和它加载的所有类都会留在元空间里。
- 「NoSuchMethodError 是代码写错了」:代码能编译,说明编译时方法存在,问题在运行时加载的版本。
小结
类加载的问题大多可以归结为一句话:找到定义这个类的加载器。转换失败时看错误信息里的加载器名称;SPI 找不到实现时看上下文类加载器;插件隔离时决定哪些类共享、哪些类隔离;卸载回收不掉时找出那条引用链。双亲委派保证了核心类型只有一份,调整委派方向时要清楚自己打破的是哪一部分保证。
配套实验
- codesphere-labs/java/class-loading-and-spi:同名类不同加载器、初始化时机与准备阶段零值、
ServiceLoader与上下文类加载器、父优先与子优先、类加载器回收、编译期与运行期版本不一致(验证记录)
参考资料