跳到主要内容
约 7 分钟3 张卡

类加载器

类加载器决定服务实现从哪里查找,也决定插件之间怎样共享类型。本文从 SPI 服务发现入手,依次说明线程上下文类加载器、插件依赖隔离,以及插件停止后的类卸载条件。

1. 面试核心​

先记住这几条,再读正文
  • 上下文加载器。 ServiceLoader.load(service) 使用当前线程的上下文类加载器,让框架按应用的可见范围发现服务实现。卡 · ServiceLoader
  • 按需发现。 ServiceLoader 根据服务声明按需发现并实例化提供者,因此发现错误可以延后到遍历时出现。卡 · 懒加载与发现失败
  • 插件隔离。 宿主与插件共享的 API 由共同父加载器定义,每个插件用独立加载器定义实现和私有依赖。卡 · 插件隔离
  • 类卸载。 普通类的卸载要求其定义加载器可被回收。线程上下文、静态集合或其他长寿命引用保留加载器时,也会阻止这批类释放。

2. ServiceLoader​

SPI(服务提供者接口)让调用方依赖约定的接口,在运行时发现实现类。负责提供服务实现的类称为提供者。比如,支付框架可以先定义统一接口:

public interface PaymentProvider {
String name();
PaymentResult charge(PaymentCommand command);
}

调用方再通过 ServiceLoader 遍历提供者,按名称登记服务:

for (PaymentProvider provider : ServiceLoader.load(PaymentProvider.class)) {
providers.put(provider.name(), provider);
}

在类路径部署中,提供者要写入 META-INF/services/<接口全名> 文件。这个 UTF-8 文件按行列出提供者的全名,ServiceLoader 据此查找实现类。此方式通过公开无参构造器创建实例,所以提供者需要具备这样的构造器。

模块化部署时,使用方在模块描述符中用 uses 声明服务接口。提供方用 provides ... with ... 声明实现。显式模块还支持公开静态无参的 provider() 方法,由它返回服务类型的对象。自动模块则仍采用构造器方式。

调用方与提供者需要共享同一份服务接口、参数类型和返回类型,才能互相调用。本例在遍历时按需创建提供者,随后把实例存入 providers。

2.1 懒加载与发现失败​

服务发现采用懒加载,也就是用到提供者时才继续查找和创建实例。因此,ServiceLoader.load() 成功只说明创建了服务加载对象,发现错误仍可在遍历时出现。

调用迭代器的 hasNext() 或 next() 时,配置格式错误、实现类不可见或构造器异常都能引发失败。它们会以 ServiceConfigurationError 报告,具体原因需要读取异常链。

若想先筛选类型,可以通过 stream() 获取 Provider 的类型信息,再调用 Provider.get() 取得实例。这样可以先判断实现是否适用,再承担实例化成本。

ServiceLoader 会缓存已经加载的提供者。reload() 会清除内部缓存,但调用方保存的服务实例仍需自行管理。同一个 ServiceLoader 对象也不支持多线程并发访问。

3. 线程上下文类加载器​

服务接口和框架可以由父加载器定义,而实现放在应用或插件中。默认父委派向上查找,父加载器无法自然地向下发现这些实现。

线程上下文类加载器(TCCL)是保存在当前线程上的类加载器。ServiceLoader.load(service) 使用它查找服务,让框架能够按应用或插件的可见范围发现实现:

ClassLoader original = Thread.currentThread().getContextClassLoader();
try {
Thread.currentThread().setContextClassLoader(pluginLoader);
ServiceLoader<PaymentProvider> loader =
ServiceLoader.load(PaymentProvider.class);
// 在此范围内使用 loader。
} finally {
Thread.currentThread().setContextClassLoader(original);
}

本例在 finally 中恢复原值,因为 TCCL 是可变的线程状态。在线程池里,后续任务会复用同一个线程,遗留的 TCCL 会让它查找错误应用的类。长期存活的线程还会因此持有插件加载器,阻止插件卸载。

也可直接调用 ServiceLoader.load(service, loader)。这种方式无需修改线程状态。

4. 插件隔离​

插件需要使用不同版本的依赖时,可以为每个插件建立独立的类加载器。宿主与插件之间传递的 API 和协议模型则由共同父加载器定义:

共同父加载器:插件 API、公共协议模型
├── 插件 A 加载器:A 实现与私有依赖
└── 插件 B 加载器:B 实现与私有依赖

私有依赖可以采用 child-first,也就是先在插件自己的路径中查找,再委派给父加载器。这样,插件 A 和 B 就能分别使用自己的依赖版本。共享 API 与 Java 平台类型保持父优先,确保双方使用同一类型。

若插件再次定义一份 API,同名接口就会成为另一个类型。实现类实现的是插件自己的接口,宿主将它转换为宿主接口时就会失败。所以,跨边界传参也需要使用共享类型。

隔离依赖后,JNI 库和系统属性等进程资源仍需要统一管理。JNI 是 Java 调用本地代码的接口,相关资源边界见类隔离与进程资源。

5. JPMS 模块层​

JPMS 是 Java 平台模块系统,用于声明模块之间的依赖和包的可见性。它回答哪些模块可以读取其他模块,以及哪些包允许对外访问。

ModuleLayer 表示一组模块及其依赖关系,可以配合一个或多个加载器定义类型。需要按模块层发现服务时,可以使用:

ServiceLoader<PaymentProvider> providers =
ServiceLoader.load(layer, PaymentProvider.class);

这条调用在指定层及其父层中发现服务。模块规则控制访问权限,类加载器决定由谁查找和定义类型,所以两套规则需要同时满足。

6. 资源查找​

框架除了加载类,还会读取配置、模板和服务声明。多个 JAR 可以包含同名资源,因此需要区分查找一个资源和枚举全部资源:

Enumeration<URL> resources =
loader.getResources("META-INF/services/com.example.PaymentProvider");

getResource() 返回一个匹配项,getResources() 则枚举多个 JAR 中的同名资源。本例可用来检查哪些 JAR 提供了支付服务声明。

自定义 child-first 时,还要让资源查找与类查找采用一致的优先级。否则会出现类来自插件私有库、配置却来自父加载器中另一版本的情况。

7. 类的卸载​

普通插件类要卸载,首先需要让定义它的加载器可以被回收。插件实例、Class 对象和仍在执行的调用都会影响可达性,所以只清空加载器变量还不够。

排查卸载失败时,要沿着以下持有来源检查:

  • 长期存活的线程及其 TCCL 会持有插件加载器。
  • 未停止的执行器、定时任务和驱动线程会继续使用插件对象。
  • 父加载器中类的静态集合会保存插件实例或 Class 对象。
  • ThreadLocal 值、日志缓存、MBean 和 JDBC Driver 注册会保留插件引用。MBean 是供监控管理使用的注册对象。

因此,停止插件时要取消任务、停止线程并注销全局注册,再清理上下文和其他引用。URLClassLoader.close() 只关闭加载器打开的文件资源,类的卸载仍要等待回收条件满足。

Metaspace 保存类元数据。若它在每次热部署后增长,应同时观察加载器数量,确认旧加载器是否仍被持有。

Java 17、21 中的隐藏类有特殊生命周期,这类类型供框架生成并在运行时使用。默认弱关联的隐藏类可以早于定义加载器卸载。使用 ClassOption.STRONG 则将生命周期关联到加载器,普通插件类仍按前述条件分析。

8. 常见问题​

8.1 服务接口与实现的可见范围​

接口由父加载器定义、实现位于应用层时,只用接口的加载器就看不到下层实现。因此,调用方可以通过 TCCL 或显式的 loader 参数指定发现范围。

8.2 提供者的实例化时机​

8.3 类隔离与进程资源​

9 · 面试题卡片​

9.1 Java SPI 为什么常使用线程上下文类加载器​

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

考察重点:解释父加载器为何看不到应用层实现,以及线程池任务为何需要恢复 TCCL。

相关内容:ServiceLoader和线程上下文类加载器。

参考回答

TCCL 让框架按调用方的可见范围查找服务实现。

框架和接口由父加载器定义、实现位于应用层时,默认父委派只能向上查找。使用当前线程的 TCCL,就能让 ServiceLoader.load(service) 找到应用层提供者。

临时修改 TCCL 后应在 finally 中恢复,避免线程池后续任务查找错误应用的类。恢复也能避免线程长期持有插件加载器。调用方若已有明确的加载器,还可直接传给 ServiceLoader.load(service, loader)。

9.2 插件系统怎样允许不同版本依赖共存​

出现公司:阿里巴巴

考察重点:说明共享 API 与私有依赖分别由谁定义,以及卸载时需要清理哪些引用。

相关内容:插件隔离至类的卸载。

参考回答

每个插件使用独立加载器定义实现和私有依赖,就可以让不同版本依赖共存。

私有包采用子优先,宿主 API、协议模型和平台类型保持父优先。这样既隔离依赖,又保证跨边界传参时类型一致。

配置和服务声明的查找也要采用对应策略,防止类与配置来自不同版本。插件停止时,要停止线程和定时任务,注销驱动、MBean 与静态注册,并恢复 TCCL。释放这些引用后,定义加载器才有机会被回收,普通插件类才能卸载。

9.3 ServiceLoader.load 成功后,为何遍历仍会失败?​

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

考察重点:解释懒发现为何让错误延后到遍历阶段,并根据异常链区分配置与实例化失败。

相关内容:懒加载与发现失败。

参考回答

因为提供者在遍历时才按需发现和创建,load() 成功时还未验证全部实现。

迭代中的配置错误、实现类不可见或构造器异常,仍会引发 ServiceConfigurationError。

排查时保留完整异常链,再核对服务声明、所用加载器和提供者构造器。若只想先筛选实现类型,可以使用 stream(),把实例化延后到 Provider.get()。

10. 参考资料​