Skip to content

类加载与双亲委派:同名的类为什么不能互相转换 ​

在 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:

text
两个加载器加载同一份 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 接口在整个应用里只有一份定义,不同模块传递对象时类型才能对得上。

Bootstrap / Platformjava.* 等核心类应用加载器共享 API、宿主代码先委派给父加载器 plugin-a定义 com.example.Plugin加载器 plugin-b定义 com.example.Plugin不能互相转换名称相同=true,Class 对象相同=false
图 1 · 共享接口由父加载器加载,整个应用只有一份;两个插件加载器各自定义了 com.example.Plugin,它们是两个类型,互相转换抛 ClassCastException

三、加载、链接与初始化 ​

一个类从字节码到可用,经历加载、链接(验证、准备、解析)和初始化:

阶段做什么
加载找到字节码,创建 Class 对象
验证检查格式与类型安全
准备为静态字段分配空间,设为零值
解析把符号引用换成直接引用,可以延迟到第一次使用时
初始化执行静态字段赋值和静态代码块

初始化只在「主动使用」时发生。实验里的类:

java
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; }
}

按顺序执行几种访问:

text
读取编译期常量 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 在父加载器,实现和登记文件在子加载器。接口里有两种查找写法:

java
ServiceLoader.load(Greeter.class);                                    // 使用线程上下文类加载器
ServiceLoader.load(Greeter.class, Greeter.class.getClassLoader());    // 使用接口自己的加载器
text
上下文类加载器为父加载器时找到 0 个,为子加载器时找到 1 个;用接口自己的加载器查找找到 0 个

线程上下文类加载器(TCCL)是 Java 为这类「上层查找下层」预留的通道:由调用方把能看到实现的加载器放在当前线程上。JDBC 驱动、JNDI、各种框架的扩展点都依赖它。这不是破坏了双亲委派,而是在委派之外另开的一条路。

这条路有一个副作用:线程创建时会继承父线程的上下文类加载器。

text
在上下文为 app 时创建的线程池,调用方切回原加载器后,工作线程的上下文类加载器仍是 app

线程池的工作线程会一直记着创建时的加载器。插件卸载后,这些线程仍然引用着插件的加载器,这是下面第六节泄漏的一种来源。

五、父优先与子优先 ​

应用服务器、插件框架需要让不同的插件使用同一个库的不同版本。做法是让插件的加载器子优先:先在自己的目录里找,找不到再交给父加载器。

text
父加载器有 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():

text
丢弃加载器与实例后:加载器已被回收
实例登记在父加载器的静态列表里:加载器仍然存活
实例放进长期存活线程的 ThreadLocal:加载器仍然存活
在同一线程里 remove 之后:加载器已被回收

一个对象就能留住整个加载器:对象 → 它的类 → 类的加载器 → 这个加载器加载过的所有类。热部署、插件卸载之后元空间持续上涨,通常就是这样一条引用链。常见的「钉子」:

  • 宿主里的静态缓存、注册表、监听器列表持有插件对象;
  • 线程池工作线程的 ThreadLocal 或上下文类加载器;
  • DriverManager 里注册的 JDBC 驱动;
  • 插件自己启动、没有停止的线程或定时器。

卸载插件时要把这些都清理掉,然后用堆转储确认加载器确实不再可达,排查方法见 JVM 调优与线上排查。

静态注册表父加载器中的类ThreadLocal线程池的工作线程插件对象插件的类插件类加载器+ 它加载的所有类实测:没有外部引用 → 回收;静态列表或 ThreadLocal → 仍存活;remove 之后 → 回收
图 2 · 父加载器里的静态列表、长期存活线程的 ThreadLocal 引用了插件对象,对象引用它的类,类引用加载器,加载器又引用它加载过的所有类;实测清理引用后加载器被回收

七、NoSuchMethodError:编译时和运行时不是同一个版本 ​

调用方按 API 2.0 编译,用到了新增的 call(int timeoutMs);运行时 classpath 上却是 1.0:

text
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 找不到实现时看上下文类加载器;插件隔离时决定哪些类共享、哪些类隔离;卸载回收不掉时找出那条引用链。双亲委派保证了核心类型只有一份,调整委派方向时要清楚自己打破的是哪一部分保证。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权