qinghuan
← 回到知识探索

后端现场 / 可下载小项目

ServiceLoader.load 没报错,为什么真正使用插件时才失败?

用单文件生成服务描述元数据,观察首次实例化、重复迭代缓存、reload 后的新对象,以及延迟出现的 provider 构造异常。

展开本文目录
  1. 下载完整练习包
  2. 接口、实现和描述文件分别负责什么
  3. load 返回的是查找器,第一次 next 才创建本例对象
  4. 同一个查找器的下一轮迭代会复用已创建实例
  5. reload 清缓存,却不撤销你已经拿到的引用
  6. 提供者构造失败,可能在迭代时才变成可见错误
  7. 保存单文件,实跑并核对每项观察
  8. 让元数据、查找器和服务实例各有结束步骤
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 能读懂 Java 接口、实现类、公开无参构造器和 Iterator。
  • 了解 ClassLoader 用于查找类与资源;不需要 Maven、Spring 或模块系统。
  • 使用完整 JDK 17 或更新版本,允许在临时目录写入本程序的服务描述文件。
  • 需要补基础时:先分清类加载与初始化
遇到不熟悉的词,先看这 2 个解释
服务提供者
能实现某个服务接口的具体实现。ServiceLoader 按服务描述信息找到并在需要时创建它。
提供者缓存
同一个 ServiceLoader 记住已经得到的提供者;本例 iterator 再次遍历会返回已缓存实例。
打开完整术语小抄 ↗

启动日志显示 ServiceLoader.load 已经返回,程序于是宣布插件就绪。直到第一个请求要使用插件时,构造器才抛出错误。问题不一定是运行中配置变了,也可能是“创建查找器”和“真正创建提供者”本来就不在同一步。

这个项目把接口、成功实现、失败实现和观察代码都放在一个 Java 文件里。运行时自动建立 META-INF/services 文件,不需要你手工复制资源;最后按计数核对对象何时出现、何时复用和何时关闭。

把例子带到自己的电脑上

下载完整练习包

需要 JDK 17 或更新版本,使用 Java 标准库;制作时实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • ServiceLoaderLifecycleDemo.java:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 ServiceLoaderLifecycleDemo.java
java -cp . ServiceLoaderLifecycleDemo

解压到独立练习文件夹,在含 ServiceLoaderLifecycleDemo.java 的目录打开终端运行。单线程类路径ServiceLoader,程序自动生成/删除两个服务描述文件;缓存身份、reload、旧iterator失效、显式close和构造失败cause;JDK17/21各12项检查。未测试模块路径、并发使用、外部JAR或类卸载。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

接口、实现和描述文件分别负责什么

Greeting 是调用方需要的接口,Good 是公开且具备公开无参构造器的实现类。服务描述文件的文件名是接口的二进制名字,文件内容是实现类的二进制名字。因为示例使用嵌套类,名字里会出现美元符号;代码用 getName 自动取得,避免手写漏掉这一点。

程序在自己的临时目录建立 META-INF/services,并创建 URLClassLoader 读取那里资源。实现类仍由父加载器提供;这个额外加载器只补充本地服务元数据,所以没有编译下载来的插件,也没有把两份同名接口分别加载。

服务发现的这层约定和反射路由扫描不同。你不必在入口处直接 new Good,也不用扫描所有注解方法,但描述文件、实现类型和构造条件仍然需要正确。当前实验采用类路径形式,不展开模块路径的 uses/provides 规则。

load 返回的是查找器,第一次 next 才创建本例对象

Good 的构造器每次运行都会增加 created。调用 ServiceLoader.load 后计数为零,拿到 iterator 后仍为零;第一次调用 next,计数才变成一,并得到 hello-1。这些数字来自实际输出,而不是从方法名推测。

这说明仅仅保存一个 ServiceLoader 引用,不能证明服务提供者已经能正常构造。如果应用要求启动阶段就确认配置可用,就需要明确触发所要求范围内的查找与实例化,并决定发现错误时如何报告。

这里使用 iterator 路径,不使用 stream().map(Provider::get)。两者都有延迟行为,但 API 对缓存和实例获取的描述不同,不能把当前实例身份断言未经检查就挪到另一种调用方式。

同一个查找器的下一轮迭代会复用已创建实例

第一次 next 后,再从同一个 loader 获取新的 iterator 并调用 next,拿到的仍是刚才那个对象,created 也仍为一。程序用引用相同做检查,不是仅比较 hello 字符串恰好相等。

缓存属于这个 ServiceLoader 的行为范围。它并不证明整个 JVM 对这个服务永远只有一个实例,也没有给实现类添加线程安全保证。若多个使用者共享同一可变 provider,仍需要说明状态与并发访问约定。

本例只在主线程顺序执行,以免把缓存观察和并发竞争混在一起。ServiceLoader 实例本身不保证可被多个线程安全地同时使用,不能因为它内部有缓存就自动当成全局并发注册表。

reload 清缓存,却不撤销你已经拿到的引用

调用 reload 后 created 仍为一,说明它没有立刻再构造一遍。接下来使用新 iterator,才得到第二个 Good,计数变成二。之前的 iterator 已经失效,继续 hasNext 在本例抛出 ConcurrentModificationException。

更容易遗漏的是旧实例:主程序仍握着第一个 Good,它依然可以返回 hello-1。Good 还实现了 AutoCloseable,但 reload 并没有调用它的 close;closed 计数仍为零。程序最后由拥有这些实例的代码显式关闭两者。

因此重新发现提供者、停止使用旧实例、关闭旧实例拥有的资源以及卸载类,并不是一个按钮的四种说法。当前例子只清除一个查找器的缓存,不替换 class 字节,也不声称已经实现插件热升级。

提供者构造失败,可能在迭代时才变成可见错误

BrokenService 使用另一份独立描述文件,指向构造器必定抛 IllegalStateException 的 Broken。创建其 ServiceLoader 时 attempts 仍为零;调用 next 才实际尝试构造,并抛出 ServiceConfigurationError。

实验同时核对异常原因仍是那一个 IllegalStateException,避免日志只留下笼统的“插件加载失败”。ServiceConfigurationError 继承 Error,普通 catch(Exception) 不会接住它;示例只在预期的实验位置捕获这个具体类型。

描述文件损坏、实现类不可见和构造器失败并不是同一种配置问题,也不保证都在同一行触发。生产诊断应保留服务接口、提供者和 cause,再根据启动契约决定终止或降级,不能在无穷循环里吞掉错误继续试。

保存单文件,实跑并核对每项观察

把代码保存为 ServiceLoaderLifecycleDemo.java,在这个文件所在目录执行下方命令。需要完整 JDK 17 或更新版本;只有 java 而没有 javac 的环境还不能完成这里的编译步骤。无需 Maven、外部依赖或 IDE 插件。

2026 年 10 月 17 日已使用 Microsoft OpenJDK 17.0.20.1 和 21.0.12,各自在新建临时目录中以 --release 17 编译并执行。两版输出相同,每次完成 12 项检查。下方保留实际输出,失败时不会打印 PASS。

展开收起代码 · 小实验java · 104 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.ConcurrentModificationException;
import java.util.ServiceConfigurationError;
import java.util.ServiceLoader;

/** Single source: the program creates and removes its own META-INF/services files. */
public class ServiceLoaderLifecycleDemo {
    private static int checks;
    public interface Greeting { String hello(); }
    public interface BrokenService { }

    public static final class Good implements Greeting, AutoCloseable {
        static int created;
        static int closed;
        private final int id;
        private boolean isClosed;
        public Good() { id = ++created; }
        public String hello() {
            if (isClosed) throw new IllegalStateException("closed");
            return "hello-" + id;
        }
        public void close() { if (!isClosed) { isClosed = true; closed++; } }
    }

    public static final class Broken implements BrokenService {
        static int attempts;
        public Broken() { attempts++; throw new IllegalStateException("provider constructor failed"); }
    }

    private static void descriptor(Path root, Class<?> service, Class<?> provider) throws IOException {
        Path directory = root.resolve("META-INF").resolve("services");
        Files.createDirectories(directory);
        Files.writeString(directory.resolve(service.getName()), provider.getName() + "\n", StandardCharsets.UTF_8);
    }

    private static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
        checks++;
    }

    private static void removeOwnedDirectory(Path directory) throws IOException {
        try (var paths = Files.walk(directory)) {
            for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
        }
    }

    public static void main(String[] args) throws Exception {
        Path directory = Files.createTempDirectory("qinghuan-services-");
        Good first = null, second = null;
        try {
            descriptor(directory, Greeting.class, Good.class);
            descriptor(directory, BrokenService.class, Broken.class);
            // Provider classes stay in the parent loader; this loader supplies only local metadata.
            try (URLClassLoader resources = new URLClassLoader(new URL[]{directory.toUri().toURL()},
                    ServiceLoaderLifecycleDemo.class.getClassLoader())) {
                ServiceLoader<Greeting> loader = ServiceLoader.load(Greeting.class, resources);
                check(Good.created == 0, "load must not construct the provider yet");
                var oldIterator = loader.iterator();
                check(Good.created == 0, "creating an iterator does not construct provider");
                first = (Good) oldIterator.next();
                check(Good.created == 1 && first.hello().equals("hello-1"), "first demand creates provider");
                check(loader.iterator().next() == first && Good.created == 1, "second iteration reuses cached instance");
                System.out.println("after_load=0; after_first_next=1; second_iterator_same_instance=true");

                loader.reload();
                check(Good.created == 1, "reload clears cache without immediate construction");
                boolean stale = false;
                try { oldIterator.hasNext(); }
                catch (ConcurrentModificationException expected) { stale = true; }
                check(stale, "discard iterators made before reload");
                second = (Good) loader.iterator().next();
                check(second != first && Good.created == 2, "new iteration constructs new instance");
                check(first.hello().equals("hello-1") && Good.closed == 0, "reload did not close or revoke old instance");
                System.out.println("after_reload_before_next=1; after_new_next=2; old_iterator=ConcurrentModificationException");
                System.out.println("old_instance_still_usable=true; automatically_closed=0");
                first.close(); second.close();
                check(Good.closed == 2, "caller explicitly closes owned providers");

                ServiceLoader<BrokenService> broken = ServiceLoader.load(BrokenService.class, resources);
                check(Broken.attempts == 0, "broken provider not constructed by load");
                boolean causePreserved = false;
                try { broken.iterator().next(); }
                catch (ServiceConfigurationError expected) {
                    causePreserved = expected.getCause() instanceof IllegalStateException
                            && expected.getCause().getMessage().equals("provider constructor failed");
                }
                check(causePreserved && Broken.attempts == 1, "iteration exposes provider construction failure");
                System.out.println("broken: load_returned=true; next=ServiceConfigurationError; cause=IllegalStateException");
            }
        } finally {
            if (first != null) first.close();
            if (second != null) second.close();
            removeOwnedDirectory(directory);
        }
        check(!Files.exists(directory), "temporary descriptors removed");
        System.out.println("PASS checks=" + checks + "; providers_closed=2; temporary_files_cleaned=true");
    }
}

怎样运行:在源码所在目录,先单独执行 javac -encoding UTF-8 --release 17 ServiceLoaderLifecycleDemo.java;编译成功后,再执行 java -cp . ServiceLoaderLifecycleDemo。两条命令分别回车。2026-10-17 已在 Windows、JDK 17.0.20.1 / 21.0.12 编译运行此份源码并通过检查。单线程类路径ServiceLoader,程序自动生成/删除两个服务描述文件;缓存身份、reload、旧iterator失效、显式close和构造失败cause;JDK17/21各12项检查。未测试模块路径、并发使用、外部JAR或类卸载。

运行后对照结果
after_load=0; after_first_next=1; second_iterator_same_instance=true
after_reload_before_next=1; after_new_next=2; old_iterator=ConcurrentModificationException
old_instance_still_usable=true; automatically_closed=0
broken: load_returned=true; next=ServiceConfigurationError; cause=IllegalStateException
PASS checks=12; providers_closed=2; temporary_files_cleaned=true

让元数据、查找器和服务实例各有结束步骤

程序在 finally 中关闭已创建的 Good,URLClassLoader 使用 try-with-resources,最后删除自己生成的临时资源目录。没有外部 JAR,没有网络路径,也没有留给读者补齐的服务文件。

这次只检查服务发现与实例生命周期。Good 的 close 用一个计数表示释放动作,并没有真实文件句柄或连接池,因此只能证明 reload 没有替你调用关闭方法,不能拿它当作任何插件资源都已正确回收的性能或泄漏测试。

读完后应能列出这条顺序:描述文件可找到、提供者可创建、实例被调用、实例停止使用、资源被关闭。每一步都有自己可观察的结果,前一步成功不能自动替下一步签字。

记住这三点

  • ServiceLoader 的发现与实例化会延迟发生,load 返回不等于提供者构造成功。
  • 同一 loader 的 iterator 会复用已缓存实例;reload 后要丢弃旧 iterator。
  • reload 不关闭或撤销调用方已持有的实例,资源所有者必须明确。

别把例子用过头

  • 只在 load 外层捕获异常,却没有观察实际迭代和构造。
  • 把 reload 当成关闭全部旧插件、替换类定义或保证类卸载。
  • 用字符串相等代替实例身份比较。
  • 假定 catch(Exception) 会捕获 ServiceConfigurationError,或把共享 loader 当成并发安全。

自己试一小步

若两个提供者都能处理同一种任务,你如何做出可验证的选择,而不是碰巧用第一个?

  1. 在单文件副本中增加第二个合法提供者,让接口暴露你设计的能力或优先级信息。
  2. 描述文件仍由程序生成;明确选择规则、冲突时的处理和不适配时的结果。
  3. 分别测试一名适配者、多名适配者和没有适配者,记录实际构造了哪些实例。
  4. 在选择、使用与 reload 之后明确关闭哪些已取得实例,提交计数和资源所有权说明。

做到这些,就算完成

  • 选择规则不依赖文件或类路径碰巧的先后顺序。
  • 优先级相同或能力冲突有明确处理,没有静默覆盖。
  • 记录实际创建和关闭的实例数量,不把发现元数据等同于已调用服务。
  • 练习只使用自己定义的实现与临时描述文件,并保留异常原因。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。