本节目标:实现一个能加载外部 jar、并通过
ServiceLoader发现校验器的插件类加载器,同时说清双亲委派和类身份。
适用版本:JDK 25(Temurin 25.0.5),javac --release 25。
承接的问题
libraryd 的 ISBN 校验目前写死在 Book 的构造校验里:必须是 13 位数字,最后一位按 ISBN-13 规则计算。不同客户对 ISBN 的接受范围不一样:有的要求只接受已注册前缀,有的要先清洗旧书条码,有的希望接第三方书目服务。每次改规则都重新编译、发布整个 libraryd-app,代价太高。
目标是把校验规则做成插件:客户把一个 isbn-validator-plugin.jar 放进 plugins/ 目录,libraryd 启动时发现它,调用它的 isValid,不需要重新打包主程序。
这带来三个问题:
- 插件 jar 里的类由谁加载,和主程序的
IsbnValidator接口是不是同一个类。 - 主程序怎样在不写死实现类名的情况下发现插件。
- 插件和主程序各自带了一份依赖时,谁先被加载。
第一个和第三个问题由类加载器回答,第二个由 ServiceLoader 回答。这一节会真正构建一个插件 jar,用自定义加载器加载,并把加载器身份打印出来。
三层类加载器与双亲委派
JDK 9 之后,类加载器的层次是:
- Bootstrap ClassLoader:加载
java.base等核心模块,Object.class.getClassLoader()返回null。 - Platform ClassLoader:加载 JDK 的平台模块,
ClassLoader.getPlatformClassLoader()可以拿到。 - Application ClassLoader:加载 classpath 上的应用类,
LoaderProbe.class.getClassLoader()可以拿到。
本机探针输出:
app=jdk.internal.loader.ClassLoaders$AppClassLoader@7a8c5397
appParent=jdk.internal.loader.ClassLoaders$PlatformClassLoader@1dbd16a6
platformParent=null
bootstrap=null
platformIsParent=true
app.getParent() 是平台加载器,平台加载器的 getParent() 是 null(代表 bootstrap)。应用加载器的父不是 bootstrap 直接对象,而是平台加载器;平台加载器再把请求转给 bootstrap。这个三层结构是双亲委派的物理基础。
双亲委派的规则是:loadClass(name) 先委托父加载器,父加载器找不到才调用自己的 findClass。默认实现大致是:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (getParent() != null) {
c = getParent().loadClass(name);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException ignored) {
}
if (c == null) {
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
findLoadedClass 先查缓存,避免同一个加载器重复定义同名类。getParent().loadClass(name) 是委派,父加载器会继续往上委派,直到 bootstrap。只有整条父链都找不到,当前加载器才调用 findClass。
双亲委派带来的关键性质是类身份由「类名 + 定义它的加载器」共同决定。同一个 IsbnValidator 接口,如果被应用加载器和插件加载器各定义一次,就是两个不同的 Class;把插件实现强转成应用加载器的 IsbnValidator 会抛 ClassCastException。所以插件加载器的父必须是应用加载器,让接口从父链解析,插件只定义自己的实现类。
自定义 PluginClassLoader
PluginClassLoader 继承 ClassLoader,只覆盖 findClass 和 findResources,不覆盖 loadClass,这样保留默认的双亲委派:
package com.plume.library.plugin;
import com.plume.library.domain.IsbnValidator;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.ServiceLoader;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Stream;
public final class PluginClassLoader extends ClassLoader implements Closeable {
private final List<JarFile> jars;
public PluginClassLoader(Path pluginDir, ClassLoader parent) throws IOException {
super("plugin", parent);
try (Stream<Path> files = Files.list(pluginDir)) {
this.jars = files
.filter(path -> path.getFileName().toString().endsWith(".jar"))
.sorted()
.map(PluginClassLoader::openJar)
.toList();
}
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String resource = name.replace('.', '/') + ".class";
for (JarFile jar : jars) {
JarEntry entry = jar.getJarEntry(resource);
if (entry == null) {
continue;
}
try (InputStream in = jar.getInputStream(entry)) {
byte[] bytes = in.readAllBytes();
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
throw new ClassNotFoundException(name);
}
@Override
protected Enumeration<URL> findResources(String name) throws IOException {
List<URL> urls = new ArrayList<>();
for (JarFile jar : jars) {
if (jar.getJarEntry(name) != null) {
URL url = URI.create(
"jar:" + Path.of(jar.getName()).toUri() + "!/" + name).toURL();
urls.add(url);
}
}
return Collections.enumeration(urls);
}
public List<IsbnValidator> loadValidators() {
return ServiceLoader.load(IsbnValidator.class, this).stream()
.map(ServiceLoader.Provider::get)
.toList();
}
@Override
public void close() throws IOException {
for (JarFile jar : jars) {
jar.close();
}
}
}
几个细节:
super("plugin", parent)给加载器一个名字,线程转储和日志里能看到;第二个参数是父加载器,必须是应用加载器。findClass按二进制名转成资源路径,在 jar 里找.class,读到字节后调用defineClass。不要自己实现loadClass里的父委派,否则很容易把双亲委派写反。findResources是ServiceLoader能找到META-INF/services的前提。只覆盖findClass不够,ServiceLoader读的是资源,不是类。JarFile保持打开,避免每次查找都重新打开;close()由调用方在插件生命周期结束时调用。JarFile不关会泄漏文件描述符,在 macOS 上表现为「Too many open files」。
插件 jar 的结构:
META-INF/
META-INF/MANIFEST.MF
com/example/validator/Isbn13Validator.class
META-INF/services/com.plume.library.domain.IsbnValidator
META-INF/services 文件名是接口的二进制名,内容是实现类的二进制名,一行一个,# 开头是注释。这个文件是 ServiceLoader 的契约,不是 libraryd 自定义的。
本机的构建命令:
javac --release 25 -d domain/out $(find domain/src -name '*.java')
javac --release 25 -cp domain/out -d plugin/out $(find plugin/src -name '*.java')
jar --create --file plugins/isbn-validator-plugin.jar \
-C plugin/out . -C plugin/resources .
jar --list --file plugins/isbn-validator-plugin.jar
本机 jar --list 输出:
META-INF/
META-INF/MANIFEST.MF
com/
com/example/
com/example/validator/
com/example/validator/Isbn13Validator.class
META-INF/services/
META-INF/services/com.plume.library.domain.IsbnValidator
插件实现只依赖 com.plume.library.domain.IsbnValidator,编译时把 domain/out 放在 classpath 上,但不要把接口打进插件 jar。接口由主程序提供,插件只带实现。这是避免类身份冲突的第一条规则。
ServiceLoader 如何发现插件
ServiceLoader.load(IsbnValidator.class, loader) 做三件事:
- 用给定的加载器找所有
META-INF/services/com.plume.library.domain.IsbnValidator资源。 - 逐行读实现类名,用同一个加载器加载。
- 检查实现类是否实现了接口、是否有 public 无参构造器,然后实例化。
loadValidators() 用 stream() 而不是 iterator(),可以直接拿到 Provider 并转成 List。Provider::get 会实例化;如果某个实现构造失败,会抛 ServiceConfigurationError。
本机运行:
java -cp domain/out:app/out com.plume.library.plugin.PluginMain plugins
本机输出:
validators=1
name=isbn13-check-digit
implClass=com.example.validator.Isbn13Validator
implLoader=com.plume.library.plugin.PluginClassLoader@6b95977
parent=jdk.internal.loader.ClassLoaders$AppClassLoader@7a8c5397
interfaceLoader=jdk.internal.loader.ClassLoaders$AppClassLoader@7a8c5397
valid13=true
validBad=false
三行关键信息:
implLoader是PluginClassLoader,说明实现类由插件加载器定义。parent是应用加载器,说明插件加载器的父链正确。interfaceLoader是应用加载器,说明IsbnValidator接口没有被插件重复定义。implLoader和interfaceLoader不同,但实现类仍然能赋值给接口,因为接口来自父链。
再用 -verbose:class 确认类的来源:
java -verbose:class -cp domain/out:app/out \
com.plume.library.plugin.PluginMain plugins | rg 'Isbn13Validator|IsbnValidator'
本机输出:
[0.194s][info][class,load] com.plume.library.domain.IsbnValidator source: file:/private/tmp/vol3-a/ch3/domain/out/
[0.208s][info][class,load] com.example.validator.Isbn13Validator source: __JVM_DefineClass__
IsbnValidator 的来源是 file:.../domain/out/,由应用加载器从 classpath 读取;Isbn13Validator 的来源是 __JVM_DefineClass__,说明它由 PluginClassLoader.findClass 调用 defineClass 定义,而不是从 classpath 读取。两行输出把「接口走父链、实现走插件」的分工坐实了。
在 libraryd 里落地
装配处把插件目录、父加载器和失败降级写在一起:
import com.plume.library.domain.IsbnValidator;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public record PluginValidators(List<IsbnValidator> validators,
PluginClassLoader loader) implements AutoCloseable {
public static PluginValidators load(Path pluginDir, ClassLoader parent) {
if (!Files.isDirectory(pluginDir)) {
return new PluginValidators(List.of(), null);
}
try {
PluginClassLoader loader = new PluginClassLoader(pluginDir, parent);
return new PluginValidators(loader.loadValidators(), loader);
} catch (IOException e) {
throw new IllegalStateException("加载插件目录失败: " + pluginDir, e);
}
}
@Override
public void close() throws IOException {
if (loader != null) {
loader.close();
}
}
}
这里没有在 load 返回时关闭加载器。插件类虽然已经定义,但插件实现引用的其他类可能要到第一次调用时才加载;如果 JarFile 已经关闭,后续加载会失败。加载器应该和插件生命周期一样长,在应用关闭时再 close()。
parent 传 PluginValidators.class.getClassLoader(),也就是应用加载器。不要传 null,也不要用 bootstrap;否则插件里的 IsbnValidator 会解析不到,ServiceLoader 会报 ServiceConfigurationError。
第二个落点是多个插件的排序和去重。插件目录可能同时有 isbn13-plugin.jar 和 legacy-plugin.jar,loadValidators() 返回的顺序由 jar 文件名排序和 ServiceLoader 的发现顺序共同决定,不承诺业务优先级。需要固定顺序时,在配置里维护插件名白名单,加载后按白名单排序:
List<String> order = List.of("legacy", "isbn13-check-digit");
validators.sort(Comparator.comparingInt(
validator -> order.indexOf(validator.name())));
不要依赖文件名排序做业务决策,文件名是运维细节,不是接口契约;也不要在共享的 IsbnValidator 接口上临时加排序字段,接口应该只表达校验能力。
第三个落点是失败隔离。一个插件抛异常不应该让整个服务起不来:
for (IsbnValidator validator : validators) {
try {
if (!validator.isValid(isbn)) {
return false;
}
} catch (RuntimeException e) {
log.warn("validator {} failed, skipped", validator.name(), e);
}
}
跳过失败插件还是整体失败,是产品决策。如果校验是强制的(比如合规要求),失败插件应该让启动失败;如果是增强规则,可以跳过并记录。无论哪种,都要在日志里写清插件名和异常,不要让插件静默失效。
常见错误与边界
「插件加载器和应用加载器各有一份 IsbnValidator。」 插件 jar 如果把 com.plume.library.domain.IsbnValidator 也打进去,而加载器又覆盖了 loadClass 做 child-first,就会出现两个接口类。ServiceLoader 可能仍然能实例化,但把结果赋给主程序的 List<IsbnValidator> 时抛:
java.lang.ClassCastException: class com.example.validator.Isbn13Validator
cannot be cast to class com.plume.library.domain.IsbnValidator
修复方式不是加 try/catch,而是确保接口只由父加载器提供,插件 jar 不打包接口和领域类型。
「只覆盖 findClass,不覆盖 findResources。」 插件类能加载,但 ServiceLoader 找不到实现,validators=0。因为 ServiceLoader 通过 getResources("META-INF/services/...") 找配置,默认的 findResources 返回空。必须同时覆盖 findResources,把 jar 里的服务文件暴露成 URL。
「JarFile 用完不关。」 每次请求都新建 PluginClassLoader 而不关闭,文件描述符会持续增长。插件加载器应该是长生命周期对象,启动时创建一次,关闭时统一 close();不要在请求路径里反复创建。
「父加载器传错。」 传 null 会让插件加载器直接走 bootstrap,找不到应用类路径上的 IsbnValidator;传一个无关的加载器会让接口解析到另一份类。父加载器必须是「定义接口的那个加载器」,通常是应用加载器。
「类加载器泄漏。」 插件加载器被线程、ThreadLocal、静态字段或 ServiceLoader 缓存引用时,即使插件已经卸载,加载器和它加载的所有类都无法回收。热部署场景要在卸载时清理线程池、注销 JDBC 驱动、关闭 JarFile,并用 JFR 或堆转储确认没有残留引用。
小结
- JDK 9 之后是 bootstrap、platform、application 三层加载器,应用加载器的父是平台加载器,平台加载器的父是
null。 - 双亲委派先查缓存、再委托父加载器、最后才
findClass;类身份由类名和定义加载器共同决定。 PluginClassLoader只覆盖findClass和findResources,保留默认loadClass的父委派。- 插件 jar 的
META-INF/services/<接口二进制名>是ServiceLoader的契约,插件只带实现,不带接口。 -verbose:class能看到接口来自 classpath、实现来自__JVM_DefineClass__,这是加载器分工的直接证据。- 插件失败要按产品规则选择跳过或启动失败,并在日志里保留插件名和异常。
类加载器解决了「类从哪里来」,但插件 jar 里的字节码长什么样、主程序怎样在不改源码的情况下改写它,还没有回答。下一节从 javap 的输出开始,读常量池和指令,再用 ASM 与 Byte Buddy 做第一次字节码操作。
阅读导航:上一节:2.3 动态代理:给仓储加事务与重试 · 下一节:3.2 字节码入门:javap、常量池与 ASM/Byte Buddy 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。