WR Blog 加载中...
返回文章
JavaJava 25 LTSSPI

Java SPI 与 ServiceLoader:发现、模块化、隔离和插件架构

Java SPI 与 ServiceLoader:发现、模块化、隔离和插件架构封面

Java 基础体系 · 第 54/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java SPI 与 ServiceLoader:发现、模块化、隔离和插件架构

SPI(Service Provider Interface,服务提供者接口)是一种“由调用方定义扩展契约、由实现方在运行时提供实现、由框架按契约发现实现”的架构机制。

它至少包含四个角色:

  1. 服务接口(service interface):调用方依赖的稳定抽象。
  2. 服务提供者(service provider):实现服务接口的具体类,或者返回服务实现的工厂方法。
  3. 服务发现机制(service discovery):根据类路径、模块描述符或模块层找到提供者。
  4. 服务加载器(service loader):负责读取提供者声明、创建对象并向调用方暴露迭代接口。

Java 标准库中的 java.util.ServiceLoader 实现了这套机制。SPI 本身是一种架构约定,ServiceLoader 则是 Java 平台提供的具体发现工具;二者不是同义词。


一、SPI 要解决什么问题

直接依赖实现类时,代码通常是这样的:

PaymentProcessor processor = new AlipayPaymentProcessor();

调用方同时依赖了:

  • PaymentProcessor 抽象;
  • AlipayPaymentProcessor 具体类;
  • 具体实现所在的包;
  • 具体实现的构造方式。

如果希望新增微信支付、测试支付或第三方支付,而不修改调用方,就需要把依赖关系改成:

PaymentProcessor processor = discoverPaymentProcessor();

调用方只知道:

public interface PaymentProcessor {
    String name();

    PaymentResult pay(PaymentRequest request);
}

实现方独立提供:

public final class AlipayPaymentProcessor implements PaymentProcessor {
    @Override
    public String name() {
        return "alipay";
    }

    @Override
    public PaymentResult pay(PaymentRequest request) {
        return new PaymentResult(true, "alipay accepted");
    }
}

SPI 的关键因果关系是:

调用方依赖接口实现方依赖接口\text{调用方依赖接口} \quad \land \quad \text{实现方依赖接口}

而不是:

调用方依赖具体实现\text{调用方依赖具体实现}

因此,SPI 降低的是编译期耦合。它并不自动解决版本兼容、权限隔离、进程隔离、数据隔离或插件卸载问题。


二、ServiceLoader 的发现模型

假设服务接口的二进制名称是:

com.example.spi.Greeter

在传统类路径模式下,提供者通过资源文件声明:

META-INF/services/com.example.spi.Greeter

文件内容是提供者类的二进制名称:

com.example.provider.EnglishGreeter
com.example.provider.ChineseGreeter

这里的二进制名称不是源文件路径。嵌套类应使用 $,例如:

com.example.Outer$InnerProvider

ServiceLoader 的基本发现过程可以抽象为:

D(S,L)=rR(S,L)P(r)D(S, L) = \bigcup_{r \in R(S,L)} P(r)

其中:

  • SS 是服务类型,例如 Greeter.class
  • LL 是用于发现资源的类加载器;
  • R(S,L)R(S,L) 是类加载器 LL 找到的服务配置资源集合;
  • P(r)P(r) 是资源 rr 中解析出的提供者类名集合;
  • D(S,L)D(S,L) 是最终候选提供者集合。

这个集合只表示“发现了哪些候选者”,不表示已经实例化了哪些对象。ServiceLoader 默认采用延迟加载,真正创建对象通常发生在迭代时。

1. 配置文件的解析规则

META-INF/services/<服务接口二进制名> 文件具有以下特征:

  • 每行一个提供者类名;
  • # 开始的内容是注释;
  • 空白行被忽略;
  • 类名必须是合法的二进制名称;
  • 同一配置资源中重复的类名会被忽略;
  • 多个 JAR 可以同时提供同名服务文件;
  • 类加载器看到的资源集合会共同参与发现。

提供者的发现顺序不应当被业务逻辑依赖。即使当前 JDK 或某个类加载器实现表现出稳定顺序,规范也不应被理解为提供了业务优先级保证。

2. 一个可运行的类路径示例

目录结构如下:

spi-demo/
├── api/
│   └── com/example/spi/Greeter.java
├── provider/
│   ├── com/example/provider/EnglishGreeter.java
│   └── META-INF/services/com.example.spi.Greeter
└── app/
    └── com/example/app/Main.java

服务接口:

package com.example.spi;

public interface Greeter {
    String language();

    String greet(String name);
}

提供者:

package com.example.provider;

import com.example.spi.Greeter;

public final class EnglishGreeter implements Greeter {
    public EnglishGreeter() {
    }

    @Override
    public String language() {
        return "en";
    }

    @Override
    public String greet(String name) {
        return "Hello, " + name;
    }
}

服务配置文件:

com.example.provider.EnglishGreeter

调用方:

package com.example.app;

import com.example.spi.Greeter;

import java.util.ServiceLoader;

public final class Main {
    public static void main(String[] args) {
        ServiceLoader<Greeter> loader =
                ServiceLoader.load(Greeter.class);

        for (Greeter greeter : loader) {
            System.out.println(greeter.language()
                    + ": " + greeter.greet("Ada"));
        }
    }
}

在 Linux 或 macOS 上,可以使用如下命令编译:

mkdir -p out/api out/provider out/app

javac -d out/api \
  api/com/example/spi/Greeter.java

javac -cp out/api -d out/provider \
  provider/com/example/provider/EnglishGreeter.java

mkdir -p out/provider/META-INF/services
printf '%s\n' \
  com.example.provider.EnglishGreeter \
  > out/provider/META-INF/services/com.example.spi.Greeter

javac -cp out/api:out/provider -d out/app \
  app/com/example/app/Main.java

java -cp out/api:out/provider:out/app \
  com.example.app.Main

预期输出:

en: Hello, Ada

这个结果依赖四个前置条件:

  1. Greeter.class 能被调用方的类加载器加载;
  2. META-INF/services/com.example.spi.Greeter 位于该类加载器可见的类路径中;
  3. 配置文件中的类能够被同一个类加载器加载;
  4. 提供者符合实例化要求,并且实现了 Greeter

如果只编译了 EnglishGreeter.class,却没有复制 META-INF/services 文件,程序不会报错,而是得到一个空的 ServiceLoader。这正是 SPI 常见的部署错误:代码存在不等于服务已注册


三、延迟实例化、缓存和错误时机

下面的代码不会立即构造所有提供者:

ServiceLoader<Greeter> loader = ServiceLoader.load(Greeter.class);
System.out.println("loader created");

调用 load 主要创建加载器对象并保存发现上下文。提供者通常在以下操作发生时才被解析和实例化:

for (Greeter greeter : loader) {
    // 迭代到某个提供者时,才可能发生类加载和构造
}

因此错误可能出现在迭代期间,而不是 ServiceLoader.load 调用期间。

典型错误包括:

  • 配置文件格式错误;
  • 提供者类不存在;
  • 提供者不是服务接口的实现;
  • 提供者没有可用的构造方式;
  • 构造函数抛出异常;
  • 提供者依赖的类缺失;
  • 模块声明不完整。

这些问题通常通过 ServiceConfigurationError 报告。它是 Error,不是普通的业务异常,表示服务配置或服务提供者加载过程出现严重问题。

例如:

try {
    for (Greeter greeter : ServiceLoader.load(Greeter.class)) {
        System.out.println(greeter.greet("Ada"));
    }
} catch (ServiceConfigurationError error) {
    System.err.println("SPI configuration failed: "
            + error.getMessage());
    error.printStackTrace();
}

捕获错误并不意味着可以安全忽略它。如果某个提供者是必需组件,应该让启动失败;如果某个提供者是可选扩展,则可以记录明确日志并跳过该扩展,但不能把所有 ServiceConfigurationError 静默吞掉。

1. 缓存行为

ServiceLoader 会缓存已经成功加载的提供者。重复迭代同一个加载器时,已经发现的提供者不会被无条件重新创建。

如果希望重新发现提供者,可以调用:

loader.reload();

reload() 会清空已经缓存的提供者。它不会:

  • 重新加载已经被 JVM 定义的类;
  • 强制关闭旧提供者;
  • 停止提供者创建的线程;
  • 撤销提供者对外部资源的引用;
  • 自动卸载插件类加载器。

因此,reload()重新发现和重新实例化的入口,不是完整的插件热卸载机制。

2. 提供者流和 Provider<T>

如果只想查看提供者类型,或者需要在实例化前进行筛选,可以使用 stream()

ServiceLoader<Greeter> loader =
        ServiceLoader.load(Greeter.class);

loader.stream()
      .filter(provider -> provider.type().getName()
              .contains("English"))
      .map(ServiceLoader.Provider::get)
      .forEach(greeter ->
              System.out.println(greeter.greet("Ada")));

这里:

  • provider.type() 返回提供者类型;
  • provider.get() 才创建或取得服务实例;
  • stream() 本身仍然是惰性的;
  • 只有执行终端操作并调用 get(),才会触发实例化。

这对提供者较多、需要读取注解或类名后再选择的场景有用。但 provider.type() 也可能触发提供者类的加载和校验,不能把它理解成完全没有类加载成本的元数据查询。


四、提供者的实例化约束

在类路径服务配置模式下,提供者通常需要:

  • 是服务接口的实现类,或者服务类型的子类;
  • 具有可访问的无参构造器;
  • 能被用于发现服务的类加载器加载。

例如,下面的提供者不适合作为传统类路径服务提供者:

public final class BadGreeter implements Greeter {
    private BadGreeter(String config) {
    }

    @Override
    public String language() {
        return "bad";
    }

    @Override
    public String greet(String name) {
        return name;
    }
}

因为 ServiceLoader 没有办法知道应当向构造器传入什么 config

如果提供者需要复杂初始化,可以让无参构造器只建立轻量状态,把外部资源初始化放在显式生命周期方法中:

public interface ManagedGreeter extends Greeter, AutoCloseable {
    void start();

    @Override
    void close();
}

ServiceLoader 只负责发现和创建,不会自动调用 start()close()。应用程序必须自己定义并执行生命周期协议。

这也是 SPI 和完整插件框架的区别之一:SPI 提供发现机制,插件框架还需要管理配置、启动、停止、健康检查、版本协商和隔离。


五、模块化之后的 SPI

Java 模块系统(JPMS)为 SPI 增加了显式的模块声明。模块化 SPI 通常涉及两个指令:

uses com.example.spi.Greeter;

和:

provides com.example.spi.Greeter
    with com.example.provider.EnglishGreeter;

1. 消费方模块

消费方模块:

module com.example.app {
    requires com.example.spi;

    uses com.example.spi.Greeter;
}

uses 表示:

该模块会通过服务机制使用某个服务类型。

它不是普通的 requiresrequires 表示编译期和运行期的模块依赖;uses 表示服务发现关系,允许模块系统把服务提供者作为服务实现解析和管理。

调用代码仍然可以是:

ServiceLoader<Greeter> loader =
        ServiceLoader.load(Greeter.class);

2. 服务接口模块

服务接口可以单独放在一个稳定的 API 模块中:

module com.example.spi {
    exports com.example.spi;
}

服务接口所在包必须对消费方可访问。通常这意味着需要 exports

3. 提供者模块

提供者模块:

module com.example.provider {
    requires com.example.spi;

    provides com.example.spi.Greeter
        with com.example.provider.EnglishGreeter;
}

提供者实现类:

package com.example.provider;

import com.example.spi.Greeter;

public final class EnglishGreeter implements Greeter {
    public EnglishGreeter() {
    }

    @Override
    public String language() {
        return "en";
    }

    @Override
    public String greet(String name) {
        return "Hello, " + name;
    }
}

提供者实现包不必为了 ServiceLoader 而导出。模块系统可以通过 provides 记录服务实现,即使该实现包不是普通 API。这样可以避免调用方直接依赖实现类。

这体现了一个重要区别:

  • exports 控制普通 Java 代码是否可以访问包中的公开类型;
  • provides ... with ... 声明某个类型作为服务实现;
  • uses 声明某个模块需要发现服务。

4. 模块化示例

目录结构:

module-demo/
├── spi/
│   ├── module-info.java
│   └── com/example/spi/Greeter.java
├── provider/
│   ├── module-info.java
│   └── com/example/provider/EnglishGreeter.java
└── app/
    ├── module-info.java
    └── com/example/app/Main.java

编译:

mkdir -p out

javac -d out \
  spi/module-info.java \
  spi/com/example/spi/Greeter.java

javac --module-path out -d out \
  provider/module-info.java \
  provider/com/example/provider/EnglishGreeter.java

javac --module-path out -d out \
  app/module-info.java \
  app/com/example/app/Main.java

运行:

java --module-path out \
  --module com.example.app/com.example.app.Main

如果运行时没有解析提供者模块,可能出现“找不到服务实现”的结果。模块路径中存在 JAR 或目录,并不自动等于该模块已经进入当前模块图。

在命令行启动时,可以显式增加根模块:

java --module-path out \
  --add-modules com.example.provider \
  --module com.example.app/com.example.app.Main

实际应用中,模块是否被解析还取决于启动方式、根模块、模块依赖和运行时配置。诊断模块图可以使用:

java --show-module-resolution \
  --module-path out \
  --module com.example.app/com.example.app.Main

该命令会显示模块解析过程,有助于区分“服务声明错误”和“提供者模块根本未进入模块图”。


六、模块化提供者方法

模块化服务提供者不一定必须把提供者类本身作为服务对象。模块可以声明一个静态提供者方法:

module com.example.provider {
    requires com.example.spi;

    provides com.example.spi.Greeter
        with com.example.provider.GreeterFactory;
}
package com.example.provider;

import com.example.spi.Greeter;

final class GreeterFactory {
    private GreeterFactory() {
    }

    public static Greeter provider() {
        return new EnglishGreeter();
    }
}

这里 GreeterFactory 不需要实现 Greeter。它通过名为 provider 的公共静态无参方法返回服务实例。

这种方式适合:

  • 提供者构造过程需要工厂逻辑;
  • 提供者实现类不希望暴露为模块 API;
  • 希望根据模块内部配置构造对象;
  • 希望隐藏具体实现类型。

但是工厂方法也会增加运行时失败点。以下情况都会导致服务配置错误:

  • provider() 不是 public static
  • 参数列表不是空参数;
  • 返回类型不兼容服务类型;
  • 方法执行时抛出异常;
  • 返回 null
  • 工厂依赖的模块或资源不可用。

模块化服务声明和类路径 META-INF/services 是两种不同的注册方式。不要因为模块中写了 provides,就认为任何任意类加载器都能通过类路径资源发现它;也不要把模块化应用中的服务配置文件当作唯一事实来源。


七、ServiceLoader 与类加载器

ServiceLoader 的核心不是“扫描整个 JVM”,而是“在特定可见性范围内发现服务”。

常用调用方式:

ServiceLoader<Greeter> loader =
        ServiceLoader.load(Greeter.class);

这个重载通常使用当前线程的上下文类加载器(Thread Context ClassLoader,简称 TCCL)进行服务发现。

也可以显式指定类加载器:

ClassLoader pluginLoader = ...;

ServiceLoader<Greeter> loader =
        ServiceLoader.load(Greeter.class, pluginLoader);

显式传入类加载器通常更适合容器、应用服务器和插件系统,因为 TCCL 可能由框架、线程池或第三方库修改,隐式依赖它会导致环境相关的发现差异。

1. 为什么同名类也可能不是同一个类型

Java 类型身份不只由类的二进制名称决定,还与定义该类的类加载器有关。可以近似表示为:

TypeIdentity=(binary name,defining loader)\text{TypeIdentity} = (\text{binary name}, \text{defining loader})

因此,下面两个类型即使都叫 com.example.spi.Greeter,也可能不是同一个 Java 类型:

(com.example.spi.Greeter, AppClassLoader)
(com.example.spi.Greeter, PluginClassLoader)

如果宿主使用父类加载器加载了 SPI 接口,而插件又在自己的类路径中打包了一份相同的 SPI API,插件实现可能无法被识别为宿主的 Greeter。常见错误包括:

ServiceConfigurationError:
... not a subtype

或者:

ClassCastException:
class ... cannot be cast to class ...

插件架构通常应把 SPI API 放在宿主和插件共同可见的父层,插件不要私自携带另一份不兼容的接口类。


八、ServiceLoader 不等于隔离

标题中的“隔离”必须区分至少三种含义。

1. 类可见性隔离

不同类加载器可以控制哪些类和资源对插件可见。典型结构是:

宿主 ClassLoader
    ├── SPI API
    └── 宿主代码
        └── Plugin ClassLoader
            ├── plugin-a.jar
            └── plugin-b.jar

宿主加载 SPI 接口,插件类加载器加载插件实现。宿主通过:

ServiceLoader.load(Greeter.class, pluginLoader)

在插件类加载器的资源范围内发现实现。

2. 模块封装隔离

JPMS 可以限制模块之间的包访问,并让服务提供者不必导出实现包。它改善了编译和运行时的封装性,但它不是完整的安全沙箱。

一个服务实现仍然可能:

  • 访问允许它访问的文件;
  • 创建线程;
  • 占用内存和 CPU;
  • 连接网络;
  • 修改共享系统属性;
  • 通过宿主暴露的对象影响宿主状态。

3. 进程和安全隔离

ServiceLoader 不提供进程隔离,也不提供资源配额。把不可信代码作为 JAR 放进插件目录,然后调用 ServiceLoader 加载,并不能构成安全执行环境。

在 Java 25 中,不能把传统 SecurityManager 当成新系统的通用插件沙箱方案。需要强隔离时,应考虑:

  • 独立 JVM 进程;
  • 操作系统用户和权限;
  • 容器或虚拟机;
  • IPC 或 RPC;
  • 明确的资源限制和超时协议。

类加载器解决的是类命名空间和可见性问题,不是恶意代码防护。


九、用模块层实现插件发现

模块层(ModuleLayer)可以把一组解析后的模块加载到独立的模块层中。它适合“插件使用模块描述符、需要独立模块图、并且希望由宿主动态装载”的场景。

其概念流程是:

flowchart LR
    A[插件模块路径] --> B[ModuleFinder]
    B --> C[Configuration.resolve]
    C --> D[ModuleLayer.defineModulesWithOneLoader]
    D --> E[插件模块层]
    E --> F[ServiceLoader.load(layer, Service.class)]
    F --> G[Provider 实例]

关键步骤如下:

ModuleFinder finder =
        ModuleFinder.of(pluginPath);

Configuration parent =
        ModuleLayer.boot().configuration();

Configuration configuration =
        parent.resolve(
                finder,
                ModuleFinder.of(),
                Set.of("com.example.plugin"));

ModuleLayer layer =
        ModuleLayer.boot()
                   .defineModulesWithOneLoader(
                           configuration,
                           ClassLoader.getSystemClassLoader());

ServiceLoader<Greeter> loader =
        ServiceLoader.load(layer, Greeter.class);

这里:

  • pluginPath 是插件模块目录或模块化 JAR 所在路径;
  • ModuleFinder 负责查找模块;
  • resolve 根据根模块和依赖解析模块图;
  • defineModulesWithOneLoader 创建模块层并为其定义类;
  • ServiceLoader.load(layer, Greeter.class) 在该层及其可见父层中查找服务。

创建模块层不会自动完成插件生命周期管理。宿主仍需决定:

  • 插件何时加载;
  • 插件是否允许多个版本并存;
  • 插件配置如何传入;
  • 插件失败是否影响主程序;
  • 插件停止时如何释放线程和资源。

1. ModuleLayer 与类加载器的关系

模块层和类加载器不是同一个概念:

  • 模块层描述模块图和模块可见性;
  • 类加载器负责定义和查找类;
  • 一个模块层可以使用一个或多个类加载器;
  • 服务发现最终仍然受到模块解析结果和类加载器可见性的共同影响。

如果插件模块没有被解析进配置,或者插件模块没有正确声明:

provides com.example.spi.Greeter
    with com.example.plugin.PluginGreeter;

那么即使插件 JAR 位于 pluginPathServiceLoader 也不会凭空发现它。


十、一个插件架构的完整数据流

以宿主加载一个插件为例,完整路径可以表示为:

插件 JAR
  ↓
类加载器或模块层读取插件
  ↓
读取 META-INF/services 或 module-info
  ↓
得到候选提供者类型
  ↓
按需加载类型
  ↓
校验服务类型兼容性
  ↓
调用无参构造器或 provider() 方法
  ↓
返回服务对象
  ↓
宿主执行统一接口

失败路径则可能发生在每一步:

JAR 不可读
  → 找不到模块或资源

模块未解析
  → 服务集合为空

服务文件名称错误
  → 服务集合为空

提供者类名错误
  → ServiceConfigurationError

类型不可见或重复 API
  → not a subtype / ClassCastException

构造器失败
  → ServiceConfigurationError

插件业务初始化失败
  → 需要宿主自己的生命周期错误处理

其中“服务集合为空”和“配置错误”必须区分:

  • 没有任何提供者,通常只是迭代器没有元素;
  • 配置文件存在但内容非法,通常抛出 ServiceConfigurationError
  • 模块存在但未解析,可能表现为没有发现提供者;
  • 提供者被发现但初始化失败,错误往往延迟到迭代或 Provider.get()

十一、选择提供者:不要依赖发现顺序

假设发现了多个支付实现:

public interface PaymentProcessor {
    String name();

    PaymentResult pay(PaymentRequest request);
}

不应写成:

PaymentProcessor processor =
        ServiceLoader.load(PaymentProcessor.class)
                     .iterator()
                     .next();

这段代码隐含了两个危险假设:

  1. 一定存在提供者;
  2. 第一个提供者就是正确提供者。

更明确的方式是定义选择规则:

public final class PaymentProcessors {
    private PaymentProcessors() {
    }

    public static PaymentProcessor find(String name) {
        for (PaymentProcessor processor :
                ServiceLoader.load(PaymentProcessor.class)) {
            if (processor.name().equals(name)) {
                return processor;
            }
        }

        throw new IllegalArgumentException(
                "No payment processor: " + name);
    }
}

如果多个提供者声明了相同的逻辑名称,应该在宿主层检测并拒绝歧义:

Map<String, PaymentProcessor> processors = new HashMap<>();

for (PaymentProcessor processor :
        ServiceLoader.load(PaymentProcessor.class)) {
    PaymentProcessor previous =
            processors.putIfAbsent(processor.name(), processor);

    if (previous != null) {
        throw new IllegalStateException(
                "Duplicate processor name: " + processor.name());
    }
}

ServiceLoader 负责发现类,不负责理解 name() 的业务唯一性。优先级、冲突检测和默认实现都属于应用协议。


十二、并发语义和生命周期

ServiceLoader 实例不是线程安全的。不要让多个线程无同步地共享同一个加载器并同时迭代:

// 不安全的共享方式
private static final ServiceLoader<Greeter> LOADER =
        ServiceLoader.load(Greeter.class);

可选方案包括:

  • 每次调用创建自己的 ServiceLoader
  • 初始化阶段单线程加载并复制成不可变集合;
  • 使用外部锁保护迭代和 reload()
  • 让应用缓存最终的服务对象,而不是缓存可变的 ServiceLoader

例如,启动时加载一次:

List<Greeter> greeters =
        ServiceLoader.load(Greeter.class)
                     .stream()
                     .map(ServiceLoader.Provider::get)
                     .toList();

此后只读使用 greeters。但这个方案有明确取舍:

  • 好处是运行时访问简单且稳定;
  • 代价是所有提供者会在启动阶段初始化;
  • 某个提供者初始化失败可能阻止整个启动;
  • 之后新增的类路径资源不会自动进入该列表。

插件卸载还要处理类加载器引用链。只要以下对象仍然引用插件类、插件实例或插件类加载器,插件类就可能无法被垃圾回收:

  • 宿主缓存;
  • 静态字段;
  • 线程上下文类加载器;
  • 未结束的线程;
  • 定时任务;
  • JMX 注册;
  • JDBC 驱动注册;
  • 日志框架或事件总线监听器。

因此,插件停止流程至少应有自己的 close()stop() 协议,并清理插件创建的外部资源。调用 ServiceLoader.reload() 不能替代这些清理步骤。


十三、常见误解和实际表现

误解一:ServiceLoader.load 会立即加载所有实现

实际情况通常是延迟发现、延迟实例化。错误可能在第一次迭代时出现:

ServiceLoader<Greeter> loader =
        ServiceLoader.load(Greeter.class);

// 这里没有异常,不代表所有提供者都正确
System.out.println("created");

for (Greeter greeter : loader) {
    // 这里才可能抛出 ServiceConfigurationError
}

诊断时不能只检查加载器创建位置,还要检查迭代、stream() 终端操作和 Provider.get()

误解二:实现类在 JAR 中就会自动成为服务

不会。类路径模式必须有正确位置和正确名称的:

META-INF/services/<service-binary-name>

Maven、Gradle、打包插件或阴影 JAR 工具如果错误合并资源文件,可能导致某些提供者声明丢失。构建后应直接检查 JAR:

jar tf provider.jar | grep 'META-INF/services'
jar xf provider.jar META-INF/services/com.example.spi.Greeter
cat META-INF/services/com.example.spi.Greeter

Windows 环境可以使用 jar tf 查看条目,并用解压工具检查文件内容。

误解三:模块化后仍然只需要 META-INF/services

模块化提供者通常使用:

provides Service with Provider;

消费方使用:

uses Service;

如果缺少 uses,或者提供者模块没有进入运行时模块图,服务发现可能失败。模块化应用需要同时检查:

java --show-module-resolution ...

以及模块描述符中的 usesprovidesrequires

误解四:服务发现会按配置文件顺序返回稳定优先级

不能依赖这一点。多个提供者的选择规则必须由应用定义,例如:

  • 显式配置提供者名称;
  • 提供者声明优先级;
  • 根据能力协商;
  • 发现多个默认提供者时启动失败。

误解五:SPI 可以自动隔离不可信插件

不能。类加载器隔离主要解决命名空间和可见性问题,模块封装主要解决包访问问题;二者都不等价于安全沙箱或资源隔离。


十四、诊断方法

面对“发现不到服务”时,可以按以下因果顺序检查。

1. 检查服务类型

确认调用方使用的服务接口与提供者编译时使用的是同一个 API 版本,并且没有被不同类加载器分别定义:

System.out.println(Greeter.class);
System.out.println(Greeter.class.getClassLoader());

2. 检查服务资源

类路径模式下:

jar tf provider.jar

应当能看到:

META-INF/services/com.example.spi.Greeter

再检查内容是否为正确的二进制类名。

3. 检查提供者是否可独立加载

可以暂时用显式类加载测试:

Class<?> type = Class.forName(
        "com.example.provider.EnglishGreeter",
        true,
        Thread.currentThread().getContextClassLoader());

System.out.println(type);
System.out.println(Greeter.class.isAssignableFrom(type));

如果 isAssignableFrom 返回 false,重点检查重复 API 和类加载器边界。

4. 检查模块解析

模块化应用应检查:

java --show-module-resolution \
  --module-path mods \
  --module com.example.app/com.example.app.Main

确认提供者模块已经解析,并且模块声明包含正确的:

uses ...
provides ... with ...

5. 检查延迟错误

不要只包围 ServiceLoader.load

try {
    ServiceLoader<Greeter> loader =
            ServiceLoader.load(Greeter.class);

    for (ServiceLoader.Provider<Greeter> provider :
            loader.stream().toList()) {
        System.out.println(provider.type());
        System.out.println(provider.get());
    }
} catch (ServiceConfigurationError error) {
    error.printStackTrace();
}

真实代码中应根据业务需求决定是启动失败、跳过单个可选插件,还是降级到内置实现。


十五、SPI 与反射、依赖注入、插件框架的边界

ServiceLoader 使用了 Java 的类加载和服务配置机制,但它不是通用依赖注入容器。

它不会自动完成:

  • 构造器参数注入;
  • 生命周期回调;
  • 配置绑定;
  • 依赖图排序;
  • 条件装配;
  • 事务管理;
  • 健康检查;
  • 远程服务发现;
  • 插件权限控制。

如果服务实现需要依赖,可以让服务接口暴露显式工厂协议:

public interface PluginProvider {
    String id();

    Plugin create(PluginContext context);
}

调用方先发现 PluginProvider,再把宿主控制的 PluginContext 传给它。这样依赖关系是显式的,也避免了让 ServiceLoader 猜测构造参数。

PluginContext 本身必须谨慎设计。它暴露的每个对象都是插件可以使用的能力边界。把数据库连接、文件系统对象或宿主内部服务直接放进上下文,会扩大插件对宿主状态的影响范围。


十六、何时适合使用 ServiceLoader

ServiceLoader 适合以下情况:

  • 服务接口稳定,提供者数量不固定;
  • 实现通过 JAR 或模块部署;
  • 需要在运行时发现可选组件;
  • 提供者初始化规则简单;
  • 类加载器或模块边界已经明确;
  • 不要求不可信代码的安全隔离。

它不适合直接承担以下职责:

  • 复杂依赖注入;
  • 强版本协商;
  • 高级插件生命周期;
  • 不可信代码执行;
  • 需要独立资源配额的扩展;
  • 必须支持安全热卸载的插件系统。

一种常见的分层方式是:

稳定 SPI 接口
    ↓
ServiceLoader 发现提供者
    ↓
宿主校验版本、能力和名称
    ↓
宿主创建并管理插件生命周期
    ↓
必要时通过独立进程实现安全隔离

这样可以保持机制边界清晰:ServiceLoader 负责“找到谁”,应用协议负责“谁能用、怎么用、何时停”,运行环境负责“插件能影响什么”。


十七、规范保证、实现行为与工程选择

最后需要明确三类结论。

Java API 和模块系统保证的内容包括:

  • ServiceLoader 按服务类型发现提供者;
  • 类路径服务可通过 META-INF/services 声明;
  • 模块可通过 usesprovides 声明服务关系;
  • 服务加载支持延迟实例化;
  • ServiceLoader.Provider 可以在实例化前暴露提供者类型;
  • reload() 会清除加载器缓存。

不应当当作规范保证的内容包括:

  • 多个提供者的业务优先级;
  • 所有环境中的发现顺序;
  • 提供者构造是否只执行一次;
  • 不同框架对 TCCL 的设置方式;
  • 插件类加载器是否具备 child-first 行为;
  • 重新加载后旧实例和旧类加载器是否能够回收。

必须由工程设计决定的内容包括:

  • 服务缺失是启动失败还是降级;
  • 多个实现如何选择;
  • 插件是否允许动态加载和停止;
  • SPI API 如何版本化;
  • 插件如何获得配置和宿主能力;
  • 是否需要模块层;
  • 是否需要独立进程隔离。

理解 ServiceLoader 的关键,不是记住一个 for 循环,而是掌握它完整的边界:

声明可见性发现延迟实例化业务选择生命周期与隔离\text{声明} \rightarrow \text{可见性} \rightarrow \text{发现} \rightarrow \text{延迟实例化} \rightarrow \text{业务选择} \rightarrow \text{生命周期与隔离}

其中前四步由 Java SPI 机制提供,后两步必须由应用和运行环境明确设计。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
WR Blog 加载中...
返回文章
JavaJava 25 LTSSPI

Java SPI 与 ServiceLoader:发现、模块化、隔离和插件架构

Java SPI 与 ServiceLoader:发现、模块化、隔离和插件架构封面

Java 基础体系 · 第 54/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java SPI 与 ServiceLoader:发现、模块化、隔离和插件架构

SPI(Service Provider Interface,服务提供者接口)是一种“由调用方定义扩展契约、由实现方在运行时提供实现、由框架按契约发现实现”的架构机制。

它至少包含四个角色:

  1. 服务接口(service interface):调用方依赖的稳定抽象。
  2. 服务提供者(service provider):实现服务接口的具体类,或者返回服务实现的工厂方法。
  3. 服务发现机制(service discovery):根据类路径、模块描述符或模块层找到提供者。
  4. 服务加载器(service loader):负责读取提供者声明、创建对象并向调用方暴露迭代接口。

Java 标准库中的 java.util.ServiceLoader 实现了这套机制。SPI 本身是一种架构约定,ServiceLoader 则是 Java 平台提供的具体发现工具;二者不是同义词。


一、SPI 要解决什么问题

直接依赖实现类时,代码通常是这样的:

PaymentProcessor processor = new AlipayPaymentProcessor();

调用方同时依赖了:

  • PaymentProcessor 抽象;
  • AlipayPaymentProcessor 具体类;
  • 具体实现所在的包;
  • 具体实现的构造方式。

如果希望新增微信支付、测试支付或第三方支付,而不修改调用方,就需要把依赖关系改成:

PaymentProcessor processor = discoverPaymentProcessor();

调用方只知道:

public interface PaymentProcessor {
    String name();

    PaymentResult pay(PaymentRequest request);
}

实现方独立提供:

public final class AlipayPaymentProcessor implements PaymentProcessor {
    @Override
    public String name() {
        return "alipay";
    }

    @Override
    public PaymentResult pay(PaymentRequest request) {
        return new PaymentResult(true, "alipay accepted");
    }
}

SPI 的关键因果关系是:

调用方依赖接口实现方依赖接口\text{调用方依赖接口} \quad \land \quad \text{实现方依赖接口}

而不是:

调用方依赖具体实现\text{调用方依赖具体实现}

因此,SPI 降低的是编译期耦合。它并不自动解决版本兼容、权限隔离、进程隔离、数据隔离或插件卸载问题。


二、ServiceLoader 的发现模型

假设服务接口的二进制名称是:

com.example.spi.Greeter

在传统类路径模式下,提供者通过资源文件声明:

META-INF/services/com.example.spi.Greeter

文件内容是提供者类的二进制名称:

com.example.provider.EnglishGreeter
com.example.provider.ChineseGreeter

这里的二进制名称不是源文件路径。嵌套类应使用 $,例如:

com.example.Outer$InnerProvider

ServiceLoader 的基本发现过程可以抽象为:

D(S,L)=rR(S,L)P(r)D(S, L) = \bigcup_{r \in R(S,L)} P(r)

其中:

  • SS 是服务类型,例如 Greeter.class
  • LL 是用于发现资源的类加载器;
  • R(S,L)R(S,L) 是类加载器 LL 找到的服务配置资源集合;
  • P(r)P(r) 是资源 rr 中解析出的提供者类名集合;
  • D(S,L)D(S,L) 是最终候选提供者集合。

这个集合只表示“发现了哪些候选者”,不表示已经实例化了哪些对象。ServiceLoader 默认采用延迟加载,真正创建对象通常发生在迭代时。

1. 配置文件的解析规则

META-INF/services/<服务接口二进制名> 文件具有以下特征:

  • 每行一个提供者类名;
  • # 开始的内容是注释;
  • 空白行被忽略;
  • 类名必须是合法的二进制名称;
  • 同一配置资源中重复的类名会被忽略;
  • 多个 JAR 可以同时提供同名服务文件;
  • 类加载器看到的资源集合会共同参与发现。

提供者的发现顺序不应当被业务逻辑依赖。即使当前 JDK 或某个类加载器实现表现出稳定顺序,规范也不应被理解为提供了业务优先级保证。

2. 一个可运行的类路径示例

目录结构如下:

spi-demo/
├── api/
│   └── com/example/spi/Greeter.java
├── provider/
│   ├── com/example/provider/EnglishGreeter.java
│   └── META-INF/services/com.example.spi.Greeter
└── app/
    └── com/example/app/Main.java

服务接口:

package com.example.spi;

public interface Greeter {
    String language();

    String greet(String name);
}

提供者:

package com.example.provider;

import com.example.spi.Greeter;

public final class EnglishGreeter implements Greeter {
    public EnglishGreeter() {
    }

    @Override
    public String language() {
        return "en";
    }

    @Override
    public String greet(String name) {
        return "Hello, " + name;
    }
}

服务配置文件:

com.example.provider.EnglishGreeter

调用方:

package com.example.app;

import com.example.spi.Greeter;

import java.util.ServiceLoader;

public final class Main {
    public static void main(String[] args) {
        ServiceLoader<Greeter> loader =
                ServiceLoader.load(Greeter.class);

        for (Greeter greeter : loader) {
            System.out.println(greeter.language()
                    + ": " + greeter.greet("Ada"));
        }
    }
}

在 Linux 或 macOS 上,可以使用如下命令编译:

mkdir -p out/api out/provider out/app

javac -d out/api \
  api/com/example/spi/Greeter.java

javac -cp out/api -d out/provider \
  provider/com/example/provider/EnglishGreeter.java

mkdir -p out/provider/META-INF/services
printf '%s\n' \
  com.example.provider.EnglishGreeter \
  > out/provider/META-INF/services/com.example.spi.Greeter

javac -cp out/api:out/provider -d out/app \
  app/com/example/app/Main.java

java -cp out/api:out/provider:out/app \
  com.example.app.Main

预期输出:

en: Hello, Ada

这个结果依赖四个前置条件:

  1. Greeter.class 能被调用方的类加载器加载;
  2. META-INF/services/com.example.spi.Greeter 位于该类加载器可见的类路径中;
  3. 配置文件中的类能够被同一个类加载器加载;
  4. 提供者符合实例化要求,并且实现了 Greeter

如果只编译了 EnglishGreeter.class,却没有复制 META-INF/services 文件,程序不会报错,而是得到一个空的 ServiceLoader。这正是 SPI 常见的部署错误:代码存在不等于服务已注册


三、延迟实例化、缓存和错误时机

下面的代码不会立即构造所有提供者:

ServiceLoader<Greeter> loader = ServiceLoader.load(Greeter.class);
System.out.println("loader created");

调用 load 主要创建加载器对象并保存发现上下文。提供者通常在以下操作发生时才被解析和实例化:

for (Greeter greeter : loader) {
    // 迭代到某个提供者时,才可能发生类加载和构造
}

因此错误可能出现在迭代期间,而不是 ServiceLoader.load 调用期间。

典型错误包括:

  • 配置文件格式错误;
  • 提供者类不存在;
  • 提供者不是服务接口的实现;
  • 提供者没有可用的构造方式;
  • 构造函数抛出异常;
  • 提供者依赖的类缺失;
  • 模块声明不完整。

这些问题通常通过 ServiceConfigurationError 报告。它是 Error,不是普通的业务异常,表示服务配置或服务提供者加载过程出现严重问题。

例如:

try {
    for (Greeter greeter : ServiceLoader.load(Greeter.class)) {
        System.out.println(greeter.greet("Ada"));
    }
} catch (ServiceConfigurationError error) {
    System.err.println("SPI configuration failed: "
            + error.getMessage());
    error.printStackTrace();
}

捕获错误并不意味着可以安全忽略它。如果某个提供者是必需组件,应该让启动失败;如果某个提供者是可选扩展,则可以记录明确日志并跳过该扩展,但不能把所有 ServiceConfigurationError 静默吞掉。

1. 缓存行为

ServiceLoader 会缓存已经成功加载的提供者。重复迭代同一个加载器时,已经发现的提供者不会被无条件重新创建。

如果希望重新发现提供者,可以调用:

loader.reload();

reload() 会清空已经缓存的提供者。它不会:

  • 重新加载已经被 JVM 定义的类;
  • 强制关闭旧提供者;
  • 停止提供者创建的线程;
  • 撤销提供者对外部资源的引用;
  • 自动卸载插件类加载器。

因此,reload()重新发现和重新实例化的入口,不是完整的插件热卸载机制。

2. 提供者流和 Provider<T>

如果只想查看提供者类型,或者需要在实例化前进行筛选,可以使用 stream()

ServiceLoader<Greeter> loader =
        ServiceLoader.load(Greeter.class);

loader.stream()
      .filter(provider -> provider.type().getName()
              .contains("English"))
      .map(ServiceLoader.Provider::get)
      .forEach(greeter ->
              System.out.println(greeter.greet("Ada")));

这里:

  • provider.type() 返回提供者类型;
  • provider.get() 才创建或取得服务实例;
  • stream() 本身仍然是惰性的;
  • 只有执行终端操作并调用 get(),才会触发实例化。

这对提供者较多、需要读取注解或类名后再选择的场景有用。但 provider.type() 也可能触发提供者类的加载和校验,不能把它理解成完全没有类加载成本的元数据查询。


四、提供者的实例化约束

在类路径服务配置模式下,提供者通常需要:

  • 是服务接口的实现类,或者服务类型的子类;
  • 具有可访问的无参构造器;
  • 能被用于发现服务的类加载器加载。

例如,下面的提供者不适合作为传统类路径服务提供者:

public final class BadGreeter implements Greeter {
    private BadGreeter(String config) {
    }

    @Override
    public String language() {
        return "bad";
    }

    @Override
    public String greet(String name) {
        return name;
    }
}

因为 ServiceLoader 没有办法知道应当向构造器传入什么 config

如果提供者需要复杂初始化,可以让无参构造器只建立轻量状态,把外部资源初始化放在显式生命周期方法中:

public interface ManagedGreeter extends Greeter, AutoCloseable {
    void start();

    @Override
    void close();
}

ServiceLoader 只负责发现和创建,不会自动调用 start()close()。应用程序必须自己定义并执行生命周期协议。

这也是 SPI 和完整插件框架的区别之一:SPI 提供发现机制,插件框架还需要管理配置、启动、停止、健康检查、版本协商和隔离。


五、模块化之后的 SPI

Java 模块系统(JPMS)为 SPI 增加了显式的模块声明。模块化 SPI 通常涉及两个指令:

uses com.example.spi.Greeter;

和:

provides com.example.spi.Greeter
    with com.example.provider.EnglishGreeter;

1. 消费方模块

消费方模块:

module com.example.app {
    requires com.example.spi;

    uses com.example.spi.Greeter;
}

uses 表示:

该模块会通过服务机制使用某个服务类型。

它不是普通的 requiresrequires 表示编译期和运行期的模块依赖;uses 表示服务发现关系,允许模块系统把服务提供者作为服务实现解析和管理。

调用代码仍然可以是:

ServiceLoader<Greeter> loader =
        ServiceLoader.load(Greeter.class);

2. 服务接口模块

服务接口可以单独放在一个稳定的 API 模块中:

module com.example.spi {
    exports com.example.spi;
}

服务接口所在包必须对消费方可访问。通常这意味着需要 exports

3. 提供者模块

提供者模块:

module com.example.provider {
    requires com.example.spi;

    provides com.example.spi.Greeter
        with com.example.provider.EnglishGreeter;
}

提供者实现类:

package com.example.provider;

import com.example.spi.Greeter;

public final class EnglishGreeter implements Greeter {
    public EnglishGreeter() {
    }

    @Override
    public String language() {
        return "en";
    }

    @Override
    public String greet(String name) {
        return "Hello, " + name;
    }
}

提供者实现包不必为了 ServiceLoader 而导出。模块系统可以通过 provides 记录服务实现,即使该实现包不是普通 API。这样可以避免调用方直接依赖实现类。

这体现了一个重要区别:

  • exports 控制普通 Java 代码是否可以访问包中的公开类型;
  • provides ... with ... 声明某个类型作为服务实现;
  • uses 声明某个模块需要发现服务。

4. 模块化示例

目录结构:

module-demo/
├── spi/
│   ├── module-info.java
│   └── com/example/spi/Greeter.java
├── provider/
│   ├── module-info.java
│   └── com/example/provider/EnglishGreeter.java
└── app/
    ├── module-info.java
    └── com/example/app/Main.java

编译:

mkdir -p out

javac -d out \
  spi/module-info.java \
  spi/com/example/spi/Greeter.java

javac --module-path out -d out \
  provider/module-info.java \
  provider/com/example/provider/EnglishGreeter.java

javac --module-path out -d out \
  app/module-info.java \
  app/com/example/app/Main.java

运行:

java --module-path out \
  --module com.example.app/com.example.app.Main

如果运行时没有解析提供者模块,可能出现“找不到服务实现”的结果。模块路径中存在 JAR 或目录,并不自动等于该模块已经进入当前模块图。

在命令行启动时,可以显式增加根模块:

java --module-path out \
  --add-modules com.example.provider \
  --module com.example.app/com.example.app.Main

实际应用中,模块是否被解析还取决于启动方式、根模块、模块依赖和运行时配置。诊断模块图可以使用:

java --show-module-resolution \
  --module-path out \
  --module com.example.app/com.example.app.Main

该命令会显示模块解析过程,有助于区分“服务声明错误”和“提供者模块根本未进入模块图”。


六、模块化提供者方法

模块化服务提供者不一定必须把提供者类本身作为服务对象。模块可以声明一个静态提供者方法:

module com.example.provider {
    requires com.example.spi;

    provides com.example.spi.Greeter
        with com.example.provider.GreeterFactory;
}
package com.example.provider;

import com.example.spi.Greeter;

final class GreeterFactory {
    private GreeterFactory() {
    }

    public static Greeter provider() {
        return new EnglishGreeter();
    }
}

这里 GreeterFactory 不需要实现 Greeter。它通过名为 provider 的公共静态无参方法返回服务实例。

这种方式适合:

  • 提供者构造过程需要工厂逻辑;
  • 提供者实现类不希望暴露为模块 API;
  • 希望根据模块内部配置构造对象;
  • 希望隐藏具体实现类型。

但是工厂方法也会增加运行时失败点。以下情况都会导致服务配置错误:

  • provider() 不是 public static
  • 参数列表不是空参数;
  • 返回类型不兼容服务类型;
  • 方法执行时抛出异常;
  • 返回 null
  • 工厂依赖的模块或资源不可用。

模块化服务声明和类路径 META-INF/services 是两种不同的注册方式。不要因为模块中写了 provides,就认为任何任意类加载器都能通过类路径资源发现它;也不要把模块化应用中的服务配置文件当作唯一事实来源。


七、ServiceLoader 与类加载器

ServiceLoader 的核心不是“扫描整个 JVM”,而是“在特定可见性范围内发现服务”。

常用调用方式:

ServiceLoader<Greeter> loader =
        ServiceLoader.load(Greeter.class);

这个重载通常使用当前线程的上下文类加载器(Thread Context ClassLoader,简称 TCCL)进行服务发现。

也可以显式指定类加载器:

ClassLoader pluginLoader = ...;

ServiceLoader<Greeter> loader =
        ServiceLoader.load(Greeter.class, pluginLoader);

显式传入类加载器通常更适合容器、应用服务器和插件系统,因为 TCCL 可能由框架、线程池或第三方库修改,隐式依赖它会导致环境相关的发现差异。

1. 为什么同名类也可能不是同一个类型

Java 类型身份不只由类的二进制名称决定,还与定义该类的类加载器有关。可以近似表示为:

TypeIdentity=(binary name,defining loader)\text{TypeIdentity} = (\text{binary name}, \text{defining loader})

因此,下面两个类型即使都叫 com.example.spi.Greeter,也可能不是同一个 Java 类型:

(com.example.spi.Greeter, AppClassLoader)
(com.example.spi.Greeter, PluginClassLoader)

如果宿主使用父类加载器加载了 SPI 接口,而插件又在自己的类路径中打包了一份相同的 SPI API,插件实现可能无法被识别为宿主的 Greeter。常见错误包括:

ServiceConfigurationError:
... not a subtype

或者:

ClassCastException:
class ... cannot be cast to class ...

插件架构通常应把 SPI API 放在宿主和插件共同可见的父层,插件不要私自携带另一份不兼容的接口类。


八、ServiceLoader 不等于隔离

标题中的“隔离”必须区分至少三种含义。

1. 类可见性隔离

不同类加载器可以控制哪些类和资源对插件可见。典型结构是:

宿主 ClassLoader
    ├── SPI API
    └── 宿主代码
        └── Plugin ClassLoader
            ├── plugin-a.jar
            └── plugin-b.jar

宿主加载 SPI 接口,插件类加载器加载插件实现。宿主通过:

ServiceLoader.load(Greeter.class, pluginLoader)

在插件类加载器的资源范围内发现实现。

2. 模块封装隔离

JPMS 可以限制模块之间的包访问,并让服务提供者不必导出实现包。它改善了编译和运行时的封装性,但它不是完整的安全沙箱。

一个服务实现仍然可能:

  • 访问允许它访问的文件;
  • 创建线程;
  • 占用内存和 CPU;
  • 连接网络;
  • 修改共享系统属性;
  • 通过宿主暴露的对象影响宿主状态。

3. 进程和安全隔离

ServiceLoader 不提供进程隔离,也不提供资源配额。把不可信代码作为 JAR 放进插件目录,然后调用 ServiceLoader 加载,并不能构成安全执行环境。

在 Java 25 中,不能把传统 SecurityManager 当成新系统的通用插件沙箱方案。需要强隔离时,应考虑:

  • 独立 JVM 进程;
  • 操作系统用户和权限;
  • 容器或虚拟机;
  • IPC 或 RPC;
  • 明确的资源限制和超时协议。

类加载器解决的是类命名空间和可见性问题,不是恶意代码防护。


九、用模块层实现插件发现

模块层(ModuleLayer)可以把一组解析后的模块加载到独立的模块层中。它适合“插件使用模块描述符、需要独立模块图、并且希望由宿主动态装载”的场景。

其概念流程是:

flowchart LR
    A[插件模块路径] --> B[ModuleFinder]
    B --> C[Configuration.resolve]
    C --> D[ModuleLayer.defineModulesWithOneLoader]
    D --> E[插件模块层]
    E --> F[ServiceLoader.load(layer, Service.class)]
    F --> G[Provider 实例]

关键步骤如下:

ModuleFinder finder =
        ModuleFinder.of(pluginPath);

Configuration parent =
        ModuleLayer.boot().configuration();

Configuration configuration =
        parent.resolve(
                finder,
                ModuleFinder.of(),
                Set.of("com.example.plugin"));

ModuleLayer layer =
        ModuleLayer.boot()
                   .defineModulesWithOneLoader(
                           configuration,
                           ClassLoader.getSystemClassLoader());

ServiceLoader<Greeter> loader =
        ServiceLoader.load(layer, Greeter.class);

这里:

  • pluginPath 是插件模块目录或模块化 JAR 所在路径;
  • ModuleFinder 负责查找模块;
  • resolve 根据根模块和依赖解析模块图;
  • defineModulesWithOneLoader 创建模块层并为其定义类;
  • ServiceLoader.load(layer, Greeter.class) 在该层及其可见父层中查找服务。

创建模块层不会自动完成插件生命周期管理。宿主仍需决定:

  • 插件何时加载;
  • 插件是否允许多个版本并存;
  • 插件配置如何传入;
  • 插件失败是否影响主程序;
  • 插件停止时如何释放线程和资源。

1. ModuleLayer 与类加载器的关系

模块层和类加载器不是同一个概念:

  • 模块层描述模块图和模块可见性;
  • 类加载器负责定义和查找类;
  • 一个模块层可以使用一个或多个类加载器;
  • 服务发现最终仍然受到模块解析结果和类加载器可见性的共同影响。

如果插件模块没有被解析进配置,或者插件模块没有正确声明:

provides com.example.spi.Greeter
    with com.example.plugin.PluginGreeter;

那么即使插件 JAR 位于 pluginPathServiceLoader 也不会凭空发现它。


十、一个插件架构的完整数据流

以宿主加载一个插件为例,完整路径可以表示为:

插件 JAR
  ↓
类加载器或模块层读取插件
  ↓
读取 META-INF/services 或 module-info
  ↓
得到候选提供者类型
  ↓
按需加载类型
  ↓
校验服务类型兼容性
  ↓
调用无参构造器或 provider() 方法
  ↓
返回服务对象
  ↓
宿主执行统一接口

失败路径则可能发生在每一步:

JAR 不可读
  → 找不到模块或资源

模块未解析
  → 服务集合为空

服务文件名称错误
  → 服务集合为空

提供者类名错误
  → ServiceConfigurationError

类型不可见或重复 API
  → not a subtype / ClassCastException

构造器失败
  → ServiceConfigurationError

插件业务初始化失败
  → 需要宿主自己的生命周期错误处理

其中“服务集合为空”和“配置错误”必须区分:

  • 没有任何提供者,通常只是迭代器没有元素;
  • 配置文件存在但内容非法,通常抛出 ServiceConfigurationError
  • 模块存在但未解析,可能表现为没有发现提供者;
  • 提供者被发现但初始化失败,错误往往延迟到迭代或 Provider.get()

十一、选择提供者:不要依赖发现顺序

假设发现了多个支付实现:

public interface PaymentProcessor {
    String name();

    PaymentResult pay(PaymentRequest request);
}

不应写成:

PaymentProcessor processor =
        ServiceLoader.load(PaymentProcessor.class)
                     .iterator()
                     .next();

这段代码隐含了两个危险假设:

  1. 一定存在提供者;
  2. 第一个提供者就是正确提供者。

更明确的方式是定义选择规则:

public final class PaymentProcessors {
    private PaymentProcessors() {
    }

    public static PaymentProcessor find(String name) {
        for (PaymentProcessor processor :
                ServiceLoader.load(PaymentProcessor.class)) {
            if (processor.name().equals(name)) {
                return processor;
            }
        }

        throw new IllegalArgumentException(
                "No payment processor: " + name);
    }
}

如果多个提供者声明了相同的逻辑名称,应该在宿主层检测并拒绝歧义:

Map<String, PaymentProcessor> processors = new HashMap<>();

for (PaymentProcessor processor :
        ServiceLoader.load(PaymentProcessor.class)) {
    PaymentProcessor previous =
            processors.putIfAbsent(processor.name(), processor);

    if (previous != null) {
        throw new IllegalStateException(
                "Duplicate processor name: " + processor.name());
    }
}

ServiceLoader 负责发现类,不负责理解 name() 的业务唯一性。优先级、冲突检测和默认实现都属于应用协议。


十二、并发语义和生命周期

ServiceLoader 实例不是线程安全的。不要让多个线程无同步地共享同一个加载器并同时迭代:

// 不安全的共享方式
private static final ServiceLoader<Greeter> LOADER =
        ServiceLoader.load(Greeter.class);

可选方案包括:

  • 每次调用创建自己的 ServiceLoader
  • 初始化阶段单线程加载并复制成不可变集合;
  • 使用外部锁保护迭代和 reload()
  • 让应用缓存最终的服务对象,而不是缓存可变的 ServiceLoader

例如,启动时加载一次:

List<Greeter> greeters =
        ServiceLoader.load(Greeter.class)
                     .stream()
                     .map(ServiceLoader.Provider::get)
                     .toList();

此后只读使用 greeters。但这个方案有明确取舍:

  • 好处是运行时访问简单且稳定;
  • 代价是所有提供者会在启动阶段初始化;
  • 某个提供者初始化失败可能阻止整个启动;
  • 之后新增的类路径资源不会自动进入该列表。

插件卸载还要处理类加载器引用链。只要以下对象仍然引用插件类、插件实例或插件类加载器,插件类就可能无法被垃圾回收:

  • 宿主缓存;
  • 静态字段;
  • 线程上下文类加载器;
  • 未结束的线程;
  • 定时任务;
  • JMX 注册;
  • JDBC 驱动注册;
  • 日志框架或事件总线监听器。

因此,插件停止流程至少应有自己的 close()stop() 协议,并清理插件创建的外部资源。调用 ServiceLoader.reload() 不能替代这些清理步骤。


十三、常见误解和实际表现

误解一:ServiceLoader.load 会立即加载所有实现

实际情况通常是延迟发现、延迟实例化。错误可能在第一次迭代时出现:

ServiceLoader<Greeter> loader =
        ServiceLoader.load(Greeter.class);

// 这里没有异常,不代表所有提供者都正确
System.out.println("created");

for (Greeter greeter : loader) {
    // 这里才可能抛出 ServiceConfigurationError
}

诊断时不能只检查加载器创建位置,还要检查迭代、stream() 终端操作和 Provider.get()

误解二:实现类在 JAR 中就会自动成为服务

不会。类路径模式必须有正确位置和正确名称的:

META-INF/services/<service-binary-name>

Maven、Gradle、打包插件或阴影 JAR 工具如果错误合并资源文件,可能导致某些提供者声明丢失。构建后应直接检查 JAR:

jar tf provider.jar | grep 'META-INF/services'
jar xf provider.jar META-INF/services/com.example.spi.Greeter
cat META-INF/services/com.example.spi.Greeter

Windows 环境可以使用 jar tf 查看条目,并用解压工具检查文件内容。

误解三:模块化后仍然只需要 META-INF/services

模块化提供者通常使用:

provides Service with Provider;

消费方使用:

uses Service;

如果缺少 uses,或者提供者模块没有进入运行时模块图,服务发现可能失败。模块化应用需要同时检查:

java --show-module-resolution ...

以及模块描述符中的 usesprovidesrequires

误解四:服务发现会按配置文件顺序返回稳定优先级

不能依赖这一点。多个提供者的选择规则必须由应用定义,例如:

  • 显式配置提供者名称;
  • 提供者声明优先级;
  • 根据能力协商;
  • 发现多个默认提供者时启动失败。

误解五:SPI 可以自动隔离不可信插件

不能。类加载器隔离主要解决命名空间和可见性问题,模块封装主要解决包访问问题;二者都不等价于安全沙箱或资源隔离。


十四、诊断方法

面对“发现不到服务”时,可以按以下因果顺序检查。

1. 检查服务类型

确认调用方使用的服务接口与提供者编译时使用的是同一个 API 版本,并且没有被不同类加载器分别定义:

System.out.println(Greeter.class);
System.out.println(Greeter.class.getClassLoader());

2. 检查服务资源

类路径模式下:

jar tf provider.jar

应当能看到:

META-INF/services/com.example.spi.Greeter

再检查内容是否为正确的二进制类名。

3. 检查提供者是否可独立加载

可以暂时用显式类加载测试:

Class<?> type = Class.forName(
        "com.example.provider.EnglishGreeter",
        true,
        Thread.currentThread().getContextClassLoader());

System.out.println(type);
System.out.println(Greeter.class.isAssignableFrom(type));

如果 isAssignableFrom 返回 false,重点检查重复 API 和类加载器边界。

4. 检查模块解析

模块化应用应检查:

java --show-module-resolution \
  --module-path mods \
  --module com.example.app/com.example.app.Main

确认提供者模块已经解析,并且模块声明包含正确的:

uses ...
provides ... with ...

5. 检查延迟错误

不要只包围 ServiceLoader.load

try {
    ServiceLoader<Greeter> loader =
            ServiceLoader.load(Greeter.class);

    for (ServiceLoader.Provider<Greeter> provider :
            loader.stream().toList()) {
        System.out.println(provider.type());
        System.out.println(provider.get());
    }
} catch (ServiceConfigurationError error) {
    error.printStackTrace();
}

真实代码中应根据业务需求决定是启动失败、跳过单个可选插件,还是降级到内置实现。


十五、SPI 与反射、依赖注入、插件框架的边界

ServiceLoader 使用了 Java 的类加载和服务配置机制,但它不是通用依赖注入容器。

它不会自动完成:

  • 构造器参数注入;
  • 生命周期回调;
  • 配置绑定;
  • 依赖图排序;
  • 条件装配;
  • 事务管理;
  • 健康检查;
  • 远程服务发现;
  • 插件权限控制。

如果服务实现需要依赖,可以让服务接口暴露显式工厂协议:

public interface PluginProvider {
    String id();

    Plugin create(PluginContext context);
}

调用方先发现 PluginProvider,再把宿主控制的 PluginContext 传给它。这样依赖关系是显式的,也避免了让 ServiceLoader 猜测构造参数。

PluginContext 本身必须谨慎设计。它暴露的每个对象都是插件可以使用的能力边界。把数据库连接、文件系统对象或宿主内部服务直接放进上下文,会扩大插件对宿主状态的影响范围。


十六、何时适合使用 ServiceLoader

ServiceLoader 适合以下情况:

  • 服务接口稳定,提供者数量不固定;
  • 实现通过 JAR 或模块部署;
  • 需要在运行时发现可选组件;
  • 提供者初始化规则简单;
  • 类加载器或模块边界已经明确;
  • 不要求不可信代码的安全隔离。

它不适合直接承担以下职责:

  • 复杂依赖注入;
  • 强版本协商;
  • 高级插件生命周期;
  • 不可信代码执行;
  • 需要独立资源配额的扩展;
  • 必须支持安全热卸载的插件系统。

一种常见的分层方式是:

稳定 SPI 接口
    ↓
ServiceLoader 发现提供者
    ↓
宿主校验版本、能力和名称
    ↓
宿主创建并管理插件生命周期
    ↓
必要时通过独立进程实现安全隔离

这样可以保持机制边界清晰:ServiceLoader 负责“找到谁”,应用协议负责“谁能用、怎么用、何时停”,运行环境负责“插件能影响什么”。


十七、规范保证、实现行为与工程选择

最后需要明确三类结论。

Java API 和模块系统保证的内容包括:

  • ServiceLoader 按服务类型发现提供者;
  • 类路径服务可通过 META-INF/services 声明;
  • 模块可通过 usesprovides 声明服务关系;
  • 服务加载支持延迟实例化;
  • ServiceLoader.Provider 可以在实例化前暴露提供者类型;
  • reload() 会清除加载器缓存。

不应当当作规范保证的内容包括:

  • 多个提供者的业务优先级;
  • 所有环境中的发现顺序;
  • 提供者构造是否只执行一次;
  • 不同框架对 TCCL 的设置方式;
  • 插件类加载器是否具备 child-first 行为;
  • 重新加载后旧实例和旧类加载器是否能够回收。

必须由工程设计决定的内容包括:

  • 服务缺失是启动失败还是降级;
  • 多个实现如何选择;
  • 插件是否允许动态加载和停止;
  • SPI API 如何版本化;
  • 插件如何获得配置和宿主能力;
  • 是否需要模块层;
  • 是否需要独立进程隔离。

理解 ServiceLoader 的关键,不是记住一个 for 循环,而是掌握它完整的边界:

声明可见性发现延迟实例化业务选择生命周期与隔离\text{声明} \rightarrow \text{可见性} \rightarrow \text{发现} \rightarrow \text{延迟实例化} \rightarrow \text{业务选择} \rightarrow \text{生命周期与隔离}

其中前四步由 Java SPI 机制提供,后两步必须由应用和运行环境明确设计。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
,例如:\n\n```text\ncom.example.Outer$InnerProvider\n```\n\n`ServiceLoader` 的基本发现过程可以抽象为:\n\n\\[\nD(S, L) = \\bigcup_{r \\in R(S,L)} P(r)\n\\]\n\n其中:\n\n- \\(S\\) 是服务类型,例如 `Greeter.class`;\n- \\(L\\) 是用于发现资源的类加载器;\n- \\(R(S,L)\\) 是类加载器 \\(L\\) 找到的服务配置资源集合;\n- \\(P(r)\\) 是资源 \\(r\\) 中解析出的提供者类名集合;\n- \\(D(S,L)\\) 是最终候选提供者集合。\n\n这个集合只表示“发现了哪些候选者”,不表示已经实例化了哪些对象。`ServiceLoader` 默认采用延迟加载,真正创建对象通常发生在迭代时。\n\n### 1. 配置文件的解析规则\n\n`META-INF/services/\u003c服务接口二进制名>` 文件具有以下特征:\n\n- 每行一个提供者类名;\n- `#` 开始的内容是注释;\n- 空白行被忽略;\n- 类名必须是合法的二进制名称;\n- 同一配置资源中重复的类名会被忽略;\n- 多个 JAR 可以同时提供同名服务文件;\n- 类加载器看到的资源集合会共同参与发现。\n\n提供者的发现顺序不应当被业务逻辑依赖。即使当前 JDK 或某个类加载器实现表现出稳定顺序,规范也不应被理解为提供了业务优先级保证。\n\n### 2. 一个可运行的类路径示例\n\n目录结构如下:\n\n```text\nspi-demo/\n├── api/\n│ └── com/example/spi/Greeter.java\n├── provider/\n│ ├── com/example/provider/EnglishGreeter.java\n│ └── META-INF/services/com.example.spi.Greeter\n└── app/\n └── com/example/app/Main.java\n```\n\n服务接口:\n\n```java\npackage com.example.spi;\n\npublic interface Greeter {\n String language();\n\n String greet(String name);\n}\n```\n\n提供者:\n\n```java\npackage com.example.provider;\n\nimport com.example.spi.Greeter;\n\npublic final class EnglishGreeter implements Greeter {\n public EnglishGreeter() {\n }\n\n @Override\n public String language() {\n return \"en\";\n }\n\n @Override\n public String greet(String name) {\n return \"Hello, \" + name;\n }\n}\n```\n\n服务配置文件:\n\n```text\ncom.example.provider.EnglishGreeter\n```\n\n调用方:\n\n```java\npackage com.example.app;\n\nimport com.example.spi.Greeter;\n\nimport java.util.ServiceLoader;\n\npublic final class Main {\n public static void main(String[] args) {\n ServiceLoader\u003cGreeter> loader =\n ServiceLoader.load(Greeter.class);\n\n for (Greeter greeter : loader) {\n System.out.println(greeter.language()\n + \": \" + greeter.greet(\"Ada\"));\n }\n }\n}\n```\n\n在 Linux 或 macOS 上,可以使用如下命令编译:\n\n```bash\nmkdir -p out/api out/provider out/app\n\njavac -d out/api \\\n api/com/example/spi/Greeter.java\n\njavac -cp out/api -d out/provider \\\n provider/com/example/provider/EnglishGreeter.java\n\nmkdir -p out/provider/META-INF/services\nprintf '%s\\n' \\\n com.example.provider.EnglishGreeter \\\n > out/provider/META-INF/services/com.example.spi.Greeter\n\njavac -cp out/api:out/provider -d out/app \\\n app/com/example/app/Main.java\n\njava -cp out/api:out/provider:out/app \\\n com.example.app.Main\n```\n\n预期输出:\n\n```text\nen: Hello, Ada\n```\n\n这个结果依赖四个前置条件:\n\n1. `Greeter.class` 能被调用方的类加载器加载;\n2. `META-INF/services/com.example.spi.Greeter` 位于该类加载器可见的类路径中;\n3. 配置文件中的类能够被同一个类加载器加载;\n4. 提供者符合实例化要求,并且实现了 `Greeter`。\n\n如果只编译了 `EnglishGreeter.class`,却没有复制 `META-INF/services` 文件,程序不会报错,而是得到一个空的 `ServiceLoader`。这正是 SPI 常见的部署错误:**代码存在不等于服务已注册**。\n\n---\n\n## 三、延迟实例化、缓存和错误时机\n\n下面的代码不会立即构造所有提供者:\n\n```java\nServiceLoader\u003cGreeter> loader = ServiceLoader.load(Greeter.class);\nSystem.out.println(\"loader created\");\n```\n\n调用 `load` 主要创建加载器对象并保存发现上下文。提供者通常在以下操作发生时才被解析和实例化:\n\n```java\nfor (Greeter greeter : loader) {\n // 迭代到某个提供者时,才可能发生类加载和构造\n}\n```\n\n因此错误可能出现在迭代期间,而不是 `ServiceLoader.load` 调用期间。\n\n典型错误包括:\n\n- 配置文件格式错误;\n- 提供者类不存在;\n- 提供者不是服务接口的实现;\n- 提供者没有可用的构造方式;\n- 构造函数抛出异常;\n- 提供者依赖的类缺失;\n- 模块声明不完整。\n\n这些问题通常通过 `ServiceConfigurationError` 报告。它是 `Error`,不是普通的业务异常,表示服务配置或服务提供者加载过程出现严重问题。\n\n例如:\n\n```java\ntry {\n for (Greeter greeter : ServiceLoader.load(Greeter.class)) {\n System.out.println(greeter.greet(\"Ada\"));\n }\n} catch (ServiceConfigurationError error) {\n System.err.println(\"SPI configuration failed: \"\n + error.getMessage());\n error.printStackTrace();\n}\n```\n\n捕获错误并不意味着可以安全忽略它。如果某个提供者是必需组件,应该让启动失败;如果某个提供者是可选扩展,则可以记录明确日志并跳过该扩展,但不能把所有 `ServiceConfigurationError` 静默吞掉。\n\n### 1. 缓存行为\n\n`ServiceLoader` 会缓存已经成功加载的提供者。重复迭代同一个加载器时,已经发现的提供者不会被无条件重新创建。\n\n如果希望重新发现提供者,可以调用:\n\n```java\nloader.reload();\n```\n\n`reload()` 会清空已经缓存的提供者。它不会:\n\n- 重新加载已经被 JVM 定义的类;\n- 强制关闭旧提供者;\n- 停止提供者创建的线程;\n- 撤销提供者对外部资源的引用;\n- 自动卸载插件类加载器。\n\n因此,`reload()` 是**重新发现和重新实例化的入口**,不是完整的插件热卸载机制。\n\n### 2. 提供者流和 `Provider\u003cT>`\n\n如果只想查看提供者类型,或者需要在实例化前进行筛选,可以使用 `stream()`:\n\n```java\nServiceLoader\u003cGreeter> loader =\n ServiceLoader.load(Greeter.class);\n\nloader.stream()\n .filter(provider -> provider.type().getName()\n .contains(\"English\"))\n .map(ServiceLoader.Provider::get)\n .forEach(greeter ->\n System.out.println(greeter.greet(\"Ada\")));\n```\n\n这里:\n\n- `provider.type()` 返回提供者类型;\n- `provider.get()` 才创建或取得服务实例;\n- `stream()` 本身仍然是惰性的;\n- 只有执行终端操作并调用 `get()`,才会触发实例化。\n\n这对提供者较多、需要读取注解或类名后再选择的场景有用。但 `provider.type()` 也可能触发提供者类的加载和校验,不能把它理解成完全没有类加载成本的元数据查询。\n\n---\n\n## 四、提供者的实例化约束\n\n在类路径服务配置模式下,提供者通常需要:\n\n- 是服务接口的实现类,或者服务类型的子类;\n- 具有可访问的无参构造器;\n- 能被用于发现服务的类加载器加载。\n\n例如,下面的提供者不适合作为传统类路径服务提供者:\n\n```java\npublic final class BadGreeter implements Greeter {\n private BadGreeter(String config) {\n }\n\n @Override\n public String language() {\n return \"bad\";\n }\n\n @Override\n public String greet(String name) {\n return name;\n }\n}\n```\n\n因为 `ServiceLoader` 没有办法知道应当向构造器传入什么 `config`。\n\n如果提供者需要复杂初始化,可以让无参构造器只建立轻量状态,把外部资源初始化放在显式生命周期方法中:\n\n```java\npublic interface ManagedGreeter extends Greeter, AutoCloseable {\n void start();\n\n @Override\n void close();\n}\n```\n\n但 `ServiceLoader` 只负责发现和创建,不会自动调用 `start()` 或 `close()`。应用程序必须自己定义并执行生命周期协议。\n\n这也是 SPI 和完整插件框架的区别之一:SPI 提供发现机制,插件框架还需要管理配置、启动、停止、健康检查、版本协商和隔离。\n\n---\n\n## 五、模块化之后的 SPI\n\nJava 模块系统(JPMS)为 SPI 增加了显式的模块声明。模块化 SPI 通常涉及两个指令:\n\n```java\nuses com.example.spi.Greeter;\n```\n\n和:\n\n```java\nprovides com.example.spi.Greeter\n with com.example.provider.EnglishGreeter;\n```\n\n### 1. 消费方模块\n\n消费方模块:\n\n```java\nmodule com.example.app {\n requires com.example.spi;\n\n uses com.example.spi.Greeter;\n}\n```\n\n`uses` 表示:\n\n> 该模块会通过服务机制使用某个服务类型。\n\n它不是普通的 `requires`。`requires` 表示编译期和运行期的模块依赖;`uses` 表示服务发现关系,允许模块系统把服务提供者作为服务实现解析和管理。\n\n调用代码仍然可以是:\n\n```java\nServiceLoader\u003cGreeter> loader =\n ServiceLoader.load(Greeter.class);\n```\n\n### 2. 服务接口模块\n\n服务接口可以单独放在一个稳定的 API 模块中:\n\n```java\nmodule com.example.spi {\n exports com.example.spi;\n}\n```\n\n服务接口所在包必须对消费方可访问。通常这意味着需要 `exports`。\n\n### 3. 提供者模块\n\n提供者模块:\n\n```java\nmodule com.example.provider {\n requires com.example.spi;\n\n provides com.example.spi.Greeter\n with com.example.provider.EnglishGreeter;\n}\n```\n\n提供者实现类:\n\n```java\npackage com.example.provider;\n\nimport com.example.spi.Greeter;\n\npublic final class EnglishGreeter implements Greeter {\n public EnglishGreeter() {\n }\n\n @Override\n public String language() {\n return \"en\";\n }\n\n @Override\n public String greet(String name) {\n return \"Hello, \" + name;\n }\n}\n```\n\n提供者实现包不必为了 `ServiceLoader` 而导出。模块系统可以通过 `provides` 记录服务实现,即使该实现包不是普通 API。这样可以避免调用方直接依赖实现类。\n\n这体现了一个重要区别:\n\n- `exports` 控制普通 Java 代码是否可以访问包中的公开类型;\n- `provides ... with ...` 声明某个类型作为服务实现;\n- `uses` 声明某个模块需要发现服务。\n\n### 4. 模块化示例\n\n目录结构:\n\n```text\nmodule-demo/\n├── spi/\n│ ├── module-info.java\n│ └── com/example/spi/Greeter.java\n├── provider/\n│ ├── module-info.java\n│ └── com/example/provider/EnglishGreeter.java\n└── app/\n ├── module-info.java\n └── com/example/app/Main.java\n```\n\n编译:\n\n```bash\nmkdir -p out\n\njavac -d out \\\n spi/module-info.java \\\n spi/com/example/spi/Greeter.java\n\njavac --module-path out -d out \\\n provider/module-info.java \\\n provider/com/example/provider/EnglishGreeter.java\n\njavac --module-path out -d out \\\n app/module-info.java \\\n app/com/example/app/Main.java\n```\n\n运行:\n\n```bash\njava --module-path out \\\n --module com.example.app/com.example.app.Main\n```\n\n如果运行时没有解析提供者模块,可能出现“找不到服务实现”的结果。模块路径中存在 JAR 或目录,并不自动等于该模块已经进入当前模块图。\n\n在命令行启动时,可以显式增加根模块:\n\n```bash\njava --module-path out \\\n --add-modules com.example.provider \\\n --module com.example.app/com.example.app.Main\n```\n\n实际应用中,模块是否被解析还取决于启动方式、根模块、模块依赖和运行时配置。诊断模块图可以使用:\n\n```bash\njava --show-module-resolution \\\n --module-path out \\\n --module com.example.app/com.example.app.Main\n```\n\n该命令会显示模块解析过程,有助于区分“服务声明错误”和“提供者模块根本未进入模块图”。\n\n---\n\n## 六、模块化提供者方法\n\n模块化服务提供者不一定必须把提供者类本身作为服务对象。模块可以声明一个静态提供者方法:\n\n```java\nmodule com.example.provider {\n requires com.example.spi;\n\n provides com.example.spi.Greeter\n with com.example.provider.GreeterFactory;\n}\n```\n\n```java\npackage com.example.provider;\n\nimport com.example.spi.Greeter;\n\nfinal class GreeterFactory {\n private GreeterFactory() {\n }\n\n public static Greeter provider() {\n return new EnglishGreeter();\n }\n}\n```\n\n这里 `GreeterFactory` 不需要实现 `Greeter`。它通过名为 `provider` 的公共静态无参方法返回服务实例。\n\n这种方式适合:\n\n- 提供者构造过程需要工厂逻辑;\n- 提供者实现类不希望暴露为模块 API;\n- 希望根据模块内部配置构造对象;\n- 希望隐藏具体实现类型。\n\n但是工厂方法也会增加运行时失败点。以下情况都会导致服务配置错误:\n\n- `provider()` 不是 `public static`;\n- 参数列表不是空参数;\n- 返回类型不兼容服务类型;\n- 方法执行时抛出异常;\n- 返回 `null`;\n- 工厂依赖的模块或资源不可用。\n\n模块化服务声明和类路径 `META-INF/services` 是两种不同的注册方式。不要因为模块中写了 `provides`,就认为任何任意类加载器都能通过类路径资源发现它;也不要把模块化应用中的服务配置文件当作唯一事实来源。\n\n---\n\n## 七、`ServiceLoader` 与类加载器\n\n`ServiceLoader` 的核心不是“扫描整个 JVM”,而是“在特定可见性范围内发现服务”。\n\n常用调用方式:\n\n```java\nServiceLoader\u003cGreeter> loader =\n ServiceLoader.load(Greeter.class);\n```\n\n这个重载通常使用当前线程的上下文类加载器(Thread Context ClassLoader,简称 TCCL)进行服务发现。\n\n也可以显式指定类加载器:\n\n```java\nClassLoader pluginLoader = ...;\n\nServiceLoader\u003cGreeter> loader =\n ServiceLoader.load(Greeter.class, pluginLoader);\n```\n\n显式传入类加载器通常更适合容器、应用服务器和插件系统,因为 TCCL 可能由框架、线程池或第三方库修改,隐式依赖它会导致环境相关的发现差异。\n\n### 1. 为什么同名类也可能不是同一个类型\n\nJava 类型身份不只由类的二进制名称决定,还与定义该类的类加载器有关。可以近似表示为:\n\n\\[\n\\text{TypeIdentity} = (\\text{binary name}, \\text{defining loader})\n\\]\n\n因此,下面两个类型即使都叫 `com.example.spi.Greeter`,也可能不是同一个 Java 类型:\n\n```text\n(com.example.spi.Greeter, AppClassLoader)\n(com.example.spi.Greeter, PluginClassLoader)\n```\n\n如果宿主使用父类加载器加载了 SPI 接口,而插件又在自己的类路径中打包了一份相同的 SPI API,插件实现可能无法被识别为宿主的 `Greeter`。常见错误包括:\n\n```text\nServiceConfigurationError:\n... not a subtype\n```\n\n或者:\n\n```text\nClassCastException:\nclass ... cannot be cast to class ...\n```\n\n插件架构通常应把 SPI API 放在宿主和插件共同可见的父层,插件不要私自携带另一份不兼容的接口类。\n\n---\n\n## 八、`ServiceLoader` 不等于隔离\n\n标题中的“隔离”必须区分至少三种含义。\n\n### 1. 类可见性隔离\n\n不同类加载器可以控制哪些类和资源对插件可见。典型结构是:\n\n```text\n宿主 ClassLoader\n ├── SPI API\n └── 宿主代码\n └── Plugin ClassLoader\n ├── plugin-a.jar\n └── plugin-b.jar\n```\n\n宿主加载 SPI 接口,插件类加载器加载插件实现。宿主通过:\n\n```java\nServiceLoader.load(Greeter.class, pluginLoader)\n```\n\n在插件类加载器的资源范围内发现实现。\n\n### 2. 模块封装隔离\n\nJPMS 可以限制模块之间的包访问,并让服务提供者不必导出实现包。它改善了编译和运行时的封装性,但它不是完整的安全沙箱。\n\n一个服务实现仍然可能:\n\n- 访问允许它访问的文件;\n- 创建线程;\n- 占用内存和 CPU;\n- 连接网络;\n- 修改共享系统属性;\n- 通过宿主暴露的对象影响宿主状态。\n\n### 3. 进程和安全隔离\n\n`ServiceLoader` 不提供进程隔离,也不提供资源配额。把不可信代码作为 JAR 放进插件目录,然后调用 `ServiceLoader` 加载,并不能构成安全执行环境。\n\n在 Java 25 中,不能把传统 `SecurityManager` 当成新系统的通用插件沙箱方案。需要强隔离时,应考虑:\n\n- 独立 JVM 进程;\n- 操作系统用户和权限;\n- 容器或虚拟机;\n- IPC 或 RPC;\n- 明确的资源限制和超时协议。\n\n类加载器解决的是类命名空间和可见性问题,不是恶意代码防护。\n\n---\n\n## 九、用模块层实现插件发现\n\n模块层(`ModuleLayer`)可以把一组解析后的模块加载到独立的模块层中。它适合“插件使用模块描述符、需要独立模块图、并且希望由宿主动态装载”的场景。\n\n其概念流程是:\n\n```mermaid\nflowchart LR\n A[插件模块路径] --\u003e B[ModuleFinder]\n B --\u003e C[Configuration.resolve]\n C --\u003e D[ModuleLayer.defineModulesWithOneLoader]\n D --\u003e E[插件模块层]\n E --\u003e F[ServiceLoader.load(layer, Service.class)]\n F --\u003e G[Provider 实例]\n```\n\n关键步骤如下:\n\n```java\nModuleFinder finder =\n ModuleFinder.of(pluginPath);\n\nConfiguration parent =\n ModuleLayer.boot().configuration();\n\nConfiguration configuration =\n parent.resolve(\n finder,\n ModuleFinder.of(),\n Set.of(\"com.example.plugin\"));\n\nModuleLayer layer =\n ModuleLayer.boot()\n .defineModulesWithOneLoader(\n configuration,\n ClassLoader.getSystemClassLoader());\n\nServiceLoader\u003cGreeter> loader =\n ServiceLoader.load(layer, Greeter.class);\n```\n\n这里:\n\n- `pluginPath` 是插件模块目录或模块化 JAR 所在路径;\n- `ModuleFinder` 负责查找模块;\n- `resolve` 根据根模块和依赖解析模块图;\n- `defineModulesWithOneLoader` 创建模块层并为其定义类;\n- `ServiceLoader.load(layer, Greeter.class)` 在该层及其可见父层中查找服务。\n\n创建模块层不会自动完成插件生命周期管理。宿主仍需决定:\n\n- 插件何时加载;\n- 插件是否允许多个版本并存;\n- 插件配置如何传入;\n- 插件失败是否影响主程序;\n- 插件停止时如何释放线程和资源。\n\n### 1. `ModuleLayer` 与类加载器的关系\n\n模块层和类加载器不是同一个概念:\n\n- 模块层描述模块图和模块可见性;\n- 类加载器负责定义和查找类;\n- 一个模块层可以使用一个或多个类加载器;\n- 服务发现最终仍然受到模块解析结果和类加载器可见性的共同影响。\n\n如果插件模块没有被解析进配置,或者插件模块没有正确声明:\n\n```java\nprovides com.example.spi.Greeter\n with com.example.plugin.PluginGreeter;\n```\n\n那么即使插件 JAR 位于 `pluginPath`,`ServiceLoader` 也不会凭空发现它。\n\n---\n\n## 十、一个插件架构的完整数据流\n\n以宿主加载一个插件为例,完整路径可以表示为:\n\n```text\n插件 JAR\n ↓\n类加载器或模块层读取插件\n ↓\n读取 META-INF/services 或 module-info\n ↓\n得到候选提供者类型\n ↓\n按需加载类型\n ↓\n校验服务类型兼容性\n ↓\n调用无参构造器或 provider() 方法\n ↓\n返回服务对象\n ↓\n宿主执行统一接口\n```\n\n失败路径则可能发生在每一步:\n\n```text\nJAR 不可读\n → 找不到模块或资源\n\n模块未解析\n → 服务集合为空\n\n服务文件名称错误\n → 服务集合为空\n\n提供者类名错误\n → ServiceConfigurationError\n\n类型不可见或重复 API\n → not a subtype / ClassCastException\n\n构造器失败\n → ServiceConfigurationError\n\n插件业务初始化失败\n → 需要宿主自己的生命周期错误处理\n```\n\n其中“服务集合为空”和“配置错误”必须区分:\n\n- 没有任何提供者,通常只是迭代器没有元素;\n- 配置文件存在但内容非法,通常抛出 `ServiceConfigurationError`;\n- 模块存在但未解析,可能表现为没有发现提供者;\n- 提供者被发现但初始化失败,错误往往延迟到迭代或 `Provider.get()`。\n\n---\n\n## 十一、选择提供者:不要依赖发现顺序\n\n假设发现了多个支付实现:\n\n```java\npublic interface PaymentProcessor {\n String name();\n\n PaymentResult pay(PaymentRequest request);\n}\n```\n\n不应写成:\n\n```java\nPaymentProcessor processor =\n ServiceLoader.load(PaymentProcessor.class)\n .iterator()\n .next();\n```\n\n这段代码隐含了两个危险假设:\n\n1. 一定存在提供者;\n2. 第一个提供者就是正确提供者。\n\n更明确的方式是定义选择规则:\n\n```java\npublic final class PaymentProcessors {\n private PaymentProcessors() {\n }\n\n public static PaymentProcessor find(String name) {\n for (PaymentProcessor processor :\n ServiceLoader.load(PaymentProcessor.class)) {\n if (processor.name().equals(name)) {\n return processor;\n }\n }\n\n throw new IllegalArgumentException(\n \"No payment processor: \" + name);\n }\n}\n```\n\n如果多个提供者声明了相同的逻辑名称,应该在宿主层检测并拒绝歧义:\n\n```java\nMap\u003cString, PaymentProcessor> processors = new HashMap\u003c>();\n\nfor (PaymentProcessor processor :\n ServiceLoader.load(PaymentProcessor.class)) {\n PaymentProcessor previous =\n processors.putIfAbsent(processor.name(), processor);\n\n if (previous != null) {\n throw new IllegalStateException(\n \"Duplicate processor name: \" + processor.name());\n }\n}\n```\n\n`ServiceLoader` 负责发现类,不负责理解 `name()` 的业务唯一性。优先级、冲突检测和默认实现都属于应用协议。\n\n---\n\n## 十二、并发语义和生命周期\n\n`ServiceLoader` 实例不是线程安全的。不要让多个线程无同步地共享同一个加载器并同时迭代:\n\n```java\n// 不安全的共享方式\nprivate static final ServiceLoader\u003cGreeter> LOADER =\n ServiceLoader.load(Greeter.class);\n```\n\n可选方案包括:\n\n- 每次调用创建自己的 `ServiceLoader`;\n- 初始化阶段单线程加载并复制成不可变集合;\n- 使用外部锁保护迭代和 `reload()`;\n- 让应用缓存最终的服务对象,而不是缓存可变的 `ServiceLoader`。\n\n例如,启动时加载一次:\n\n```java\nList\u003cGreeter> greeters =\n ServiceLoader.load(Greeter.class)\n .stream()\n .map(ServiceLoader.Provider::get)\n .toList();\n```\n\n此后只读使用 `greeters`。但这个方案有明确取舍:\n\n- 好处是运行时访问简单且稳定;\n- 代价是所有提供者会在启动阶段初始化;\n- 某个提供者初始化失败可能阻止整个启动;\n- 之后新增的类路径资源不会自动进入该列表。\n\n插件卸载还要处理类加载器引用链。只要以下对象仍然引用插件类、插件实例或插件类加载器,插件类就可能无法被垃圾回收:\n\n- 宿主缓存;\n- 静态字段;\n- 线程上下文类加载器;\n- 未结束的线程;\n- 定时任务;\n- JMX 注册;\n- JDBC 驱动注册;\n- 日志框架或事件总线监听器。\n\n因此,插件停止流程至少应有自己的 `close()` 或 `stop()` 协议,并清理插件创建的外部资源。调用 `ServiceLoader.reload()` 不能替代这些清理步骤。\n\n---\n\n## 十三、常见误解和实际表现\n\n### 误解一:`ServiceLoader.load` 会立即加载所有实现\n\n实际情况通常是延迟发现、延迟实例化。错误可能在第一次迭代时出现:\n\n```java\nServiceLoader\u003cGreeter> loader =\n ServiceLoader.load(Greeter.class);\n\n// 这里没有异常,不代表所有提供者都正确\nSystem.out.println(\"created\");\n\nfor (Greeter greeter : loader) {\n // 这里才可能抛出 ServiceConfigurationError\n}\n```\n\n诊断时不能只检查加载器创建位置,还要检查迭代、`stream()` 终端操作和 `Provider.get()`。\n\n### 误解二:实现类在 JAR 中就会自动成为服务\n\n不会。类路径模式必须有正确位置和正确名称的:\n\n```text\nMETA-INF/services/\u003cservice-binary-name>\n```\n\nMaven、Gradle、打包插件或阴影 JAR 工具如果错误合并资源文件,可能导致某些提供者声明丢失。构建后应直接检查 JAR:\n\n```bash\njar tf provider.jar | grep 'META-INF/services'\njar xf provider.jar META-INF/services/com.example.spi.Greeter\ncat META-INF/services/com.example.spi.Greeter\n```\n\nWindows 环境可以使用 `jar tf` 查看条目,并用解压工具检查文件内容。\n\n### 误解三:模块化后仍然只需要 `META-INF/services`\n\n模块化提供者通常使用:\n\n```java\nprovides Service with Provider;\n```\n\n消费方使用:\n\n```java\nuses Service;\n```\n\n如果缺少 `uses`,或者提供者模块没有进入运行时模块图,服务发现可能失败。模块化应用需要同时检查:\n\n```bash\njava --show-module-resolution ...\n```\n\n以及模块描述符中的 `uses`、`provides` 和 `requires`。\n\n### 误解四:服务发现会按配置文件顺序返回稳定优先级\n\n不能依赖这一点。多个提供者的选择规则必须由应用定义,例如:\n\n- 显式配置提供者名称;\n- 提供者声明优先级;\n- 根据能力协商;\n- 发现多个默认提供者时启动失败。\n\n### 误解五:SPI 可以自动隔离不可信插件\n\n不能。类加载器隔离主要解决命名空间和可见性问题,模块封装主要解决包访问问题;二者都不等价于安全沙箱或资源隔离。\n\n---\n\n## 十四、诊断方法\n\n面对“发现不到服务”时,可以按以下因果顺序检查。\n\n### 1. 检查服务类型\n\n确认调用方使用的服务接口与提供者编译时使用的是同一个 API 版本,并且没有被不同类加载器分别定义:\n\n```java\nSystem.out.println(Greeter.class);\nSystem.out.println(Greeter.class.getClassLoader());\n```\n\n### 2. 检查服务资源\n\n类路径模式下:\n\n```bash\njar tf provider.jar\n```\n\n应当能看到:\n\n```text\nMETA-INF/services/com.example.spi.Greeter\n```\n\n再检查内容是否为正确的二进制类名。\n\n### 3. 检查提供者是否可独立加载\n\n可以暂时用显式类加载测试:\n\n```java\nClass\u003c?> type = Class.forName(\n \"com.example.provider.EnglishGreeter\",\n true,\n Thread.currentThread().getContextClassLoader());\n\nSystem.out.println(type);\nSystem.out.println(Greeter.class.isAssignableFrom(type));\n```\n\n如果 `isAssignableFrom` 返回 `false`,重点检查重复 API 和类加载器边界。\n\n### 4. 检查模块解析\n\n模块化应用应检查:\n\n```bash\njava --show-module-resolution \\\n --module-path mods \\\n --module com.example.app/com.example.app.Main\n```\n\n确认提供者模块已经解析,并且模块声明包含正确的:\n\n```java\nuses ...\nprovides ... with ...\n```\n\n### 5. 检查延迟错误\n\n不要只包围 `ServiceLoader.load`:\n\n```java\ntry {\n ServiceLoader\u003cGreeter> loader =\n ServiceLoader.load(Greeter.class);\n\n for (ServiceLoader.Provider\u003cGreeter> provider :\n loader.stream().toList()) {\n System.out.println(provider.type());\n System.out.println(provider.get());\n }\n} catch (ServiceConfigurationError error) {\n error.printStackTrace();\n}\n```\n\n真实代码中应根据业务需求决定是启动失败、跳过单个可选插件,还是降级到内置实现。\n\n---\n\n## 十五、SPI 与反射、依赖注入、插件框架的边界\n\n`ServiceLoader` 使用了 Java 的类加载和服务配置机制,但它不是通用依赖注入容器。\n\n它不会自动完成:\n\n- 构造器参数注入;\n- 生命周期回调;\n- 配置绑定;\n- 依赖图排序;\n- 条件装配;\n- 事务管理;\n- 健康检查;\n- 远程服务发现;\n- 插件权限控制。\n\n如果服务实现需要依赖,可以让服务接口暴露显式工厂协议:\n\n```java\npublic interface PluginProvider {\n String id();\n\n Plugin create(PluginContext context);\n}\n```\n\n调用方先发现 `PluginProvider`,再把宿主控制的 `PluginContext` 传给它。这样依赖关系是显式的,也避免了让 `ServiceLoader` 猜测构造参数。\n\n但 `PluginContext` 本身必须谨慎设计。它暴露的每个对象都是插件可以使用的能力边界。把数据库连接、文件系统对象或宿主内部服务直接放进上下文,会扩大插件对宿主状态的影响范围。\n\n---\n\n## 十六、何时适合使用 `ServiceLoader`\n\n`ServiceLoader` 适合以下情况:\n\n- 服务接口稳定,提供者数量不固定;\n- 实现通过 JAR 或模块部署;\n- 需要在运行时发现可选组件;\n- 提供者初始化规则简单;\n- 类加载器或模块边界已经明确;\n- 不要求不可信代码的安全隔离。\n\n它不适合直接承担以下职责:\n\n- 复杂依赖注入;\n- 强版本协商;\n- 高级插件生命周期;\n- 不可信代码执行;\n- 需要独立资源配额的扩展;\n- 必须支持安全热卸载的插件系统。\n\n一种常见的分层方式是:\n\n```text\n稳定 SPI 接口\n ↓\nServiceLoader 发现提供者\n ↓\n宿主校验版本、能力和名称\n ↓\n宿主创建并管理插件生命周期\n ↓\n必要时通过独立进程实现安全隔离\n```\n\n这样可以保持机制边界清晰:`ServiceLoader` 负责“找到谁”,应用协议负责“谁能用、怎么用、何时停”,运行环境负责“插件能影响什么”。\n\n---\n\n## 十七、规范保证、实现行为与工程选择\n\n最后需要明确三类结论。\n\n**Java API 和模块系统保证的内容**包括:\n\n- `ServiceLoader` 按服务类型发现提供者;\n- 类路径服务可通过 `META-INF/services` 声明;\n- 模块可通过 `uses` 和 `provides` 声明服务关系;\n- 服务加载支持延迟实例化;\n- `ServiceLoader.Provider` 可以在实例化前暴露提供者类型;\n- `reload()` 会清除加载器缓存。\n\n**不应当当作规范保证的内容**包括:\n\n- 多个提供者的业务优先级;\n- 所有环境中的发现顺序;\n- 提供者构造是否只执行一次;\n- 不同框架对 TCCL 的设置方式;\n- 插件类加载器是否具备 child-first 行为;\n- 重新加载后旧实例和旧类加载器是否能够回收。\n\n**必须由工程设计决定的内容**包括:\n\n- 服务缺失是启动失败还是降级;\n- 多个实现如何选择;\n- 插件是否允许动态加载和停止;\n- SPI API 如何版本化;\n- 插件如何获得配置和宿主能力;\n- 是否需要模块层;\n- 是否需要独立进程隔离。\n\n理解 `ServiceLoader` 的关键,不是记住一个 `for` 循环,而是掌握它完整的边界:\n\n\\[\n\\text{声明} \\rightarrow \\text{可见性} \\rightarrow \\text{发现}\n\\rightarrow \\text{延迟实例化} \\rightarrow \\text{业务选择}\n\\rightarrow \\text{生命周期与隔离}\n\\]\n\n其中前四步由 Java SPI 机制提供,后两步必须由应用和运行环境明确设计。\n\n---\n\n## 系列导航与关联阅读\n\n- 系列入口:[Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付](https://wrblog.cn/articles/ee791cb5-6de0-5903-b22b-047cf231e387)\n- 上一篇:[Java 25 Foreign Function & Memory API:Arena、MemorySegment 和本地调用](https://wrblog.cn/articles/fe5cb1a1-79a6-50b0-8cb3-cda33433ebd5)\n- 下一篇:[Java 注解处理器:编译期模型、代码生成、增量构建和调试](https://wrblog.cn/articles/bcab483f-9bf5-5d50-b2d4-351f6c7adc18)\n\n## 官方资料\n\n- [Java SE 25 Documentation](https://docs.oracle.com/en/java/javase/25/)\n- [Java Language Specification](https://docs.oracle.com/javase/specs/jls/se25/html/)\n\n> 本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。\n","tags":["Java","Java 25 LTS","SPI"],"likeCount":0,"commentCount":0,"createdByUserId":"10000000000","createdByDisplayName":"小郝","createdByAvatar":"/public/profile/10000000000/avatar/2026/08/04/db02b81c-42f2-441b-8a80-61370cdbb581.webp","publishTime":"2026-09-01 13:45:21","updateTime":"2026-09-01 13:45:21"}},"status":200,"locale":"zh-CN","theme":"light"}