类加载器
类加载器决定服务实现从哪里查找,也决定插件之间怎样共享类型。本文从 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 提供者的实例化时机
提供者在遍历时按需创建,使用 stream() 还可以先检查类型,再调用 get()。构造器应保持轻量,避免发现服务时就执行耗时的外部操作。一个提供者创建失败时,调用方还要决定如何处理其他提供者,避免整轮服务发现被意外中断。
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()。