《Java 高级编程》3.1 类加载器与双亲委派:从平台加载器到插件加载器

libraryd 的 ISBN 校验规则要允许客户以 jar 形式追加,不能每次改规则都重新打包整个服务。本节从三层类加载器与双亲委派讲起,实现 PluginClassLoader 的 findClass 与 findResources,用 META-INF/services 和 ServiceLoader 真正加载一次插件 jar,并记录类加载器身份。

本节目标:实现一个能加载外部 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 之后,类加载器的层次是:

  1. Bootstrap ClassLoader:加载 java.base 等核心模块,Object.class.getClassLoader() 返回 null。
  2. Platform ClassLoader:加载 JDK 的平台模块,ClassLoader.getPlatformClassLoader() 可以拿到。
  3. 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) 做三件事:

  1. 用给定的加载器找所有 META-INF/services/com.plume.library.domain.IsbnValidator 资源。
  2. 逐行读实现类名,用同一个加载器加载。
  3. 检查实现类是否实现了接口、是否有 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发