Java 基础体系 · 第 54/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java SPI 与 ServiceLoader:发现、模块化、隔离和插件架构
SPI(Service Provider Interface,服务提供者接口)是一种“由调用方定义扩展契约、由实现方在运行时提供实现、由框架按契约发现实现”的架构机制。
它至少包含四个角色:
- 服务接口(service interface):调用方依赖的稳定抽象。
- 服务提供者(service provider):实现服务接口的具体类,或者返回服务实现的工厂方法。
- 服务发现机制(service discovery):根据类路径、模块描述符或模块层找到提供者。
- 服务加载器(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 的关键因果关系是:
而不是:
因此,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 的基本发现过程可以抽象为:
其中:
- 是服务类型,例如
Greeter.class; - 是用于发现资源的类加载器;
- 是类加载器 找到的服务配置资源集合;
- 是资源 中解析出的提供者类名集合;
- 是最终候选提供者集合。
这个集合只表示“发现了哪些候选者”,不表示已经实例化了哪些对象。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
这个结果依赖四个前置条件:
Greeter.class能被调用方的类加载器加载;META-INF/services/com.example.spi.Greeter位于该类加载器可见的类路径中;- 配置文件中的类能够被同一个类加载器加载;
- 提供者符合实例化要求,并且实现了
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 表示:
该模块会通过服务机制使用某个服务类型。
它不是普通的 requires。requires 表示编译期和运行期的模块依赖;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 类型身份不只由类的二进制名称决定,还与定义该类的类加载器有关。可以近似表示为:
因此,下面两个类型即使都叫 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 位于 pluginPath,ServiceLoader 也不会凭空发现它。
十、一个插件架构的完整数据流
以宿主加载一个插件为例,完整路径可以表示为:
插件 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();
这段代码隐含了两个危险假设:
- 一定存在提供者;
- 第一个提供者就是正确提供者。
更明确的方式是定义选择规则:
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 ...
以及模块描述符中的 uses、provides 和 requires。
误解四:服务发现会按配置文件顺序返回稳定优先级
不能依赖这一点。多个提供者的选择规则必须由应用定义,例如:
- 显式配置提供者名称;
- 提供者声明优先级;
- 根据能力协商;
- 发现多个默认提供者时启动失败。
误解五: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声明; - 模块可通过
uses和provides声明服务关系; - 服务加载支持延迟实例化;
ServiceLoader.Provider可以在实例化前暴露提供者类型;reload()会清除加载器缓存。
不应当当作规范保证的内容包括:
- 多个提供者的业务优先级;
- 所有环境中的发现顺序;
- 提供者构造是否只执行一次;
- 不同框架对 TCCL 的设置方式;
- 插件类加载器是否具备 child-first 行为;
- 重新加载后旧实例和旧类加载器是否能够回收。
必须由工程设计决定的内容包括:
- 服务缺失是启动失败还是降级;
- 多个实现如何选择;
- 插件是否允许动态加载和停止;
- SPI API 如何版本化;
- 插件如何获得配置和宿主能力;
- 是否需要模块层;
- 是否需要独立进程隔离。
理解 ServiceLoader 的关键,不是记住一个 for 循环,而是掌握它完整的边界:
其中前四步由 Java SPI 机制提供,后两步必须由应用和运行环境明确设计。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 25 Foreign Function & Memory API:Arena、MemorySegment 和本地调用
- 下一篇:Java 注解处理器:编译期模型、代码生成、增量构建和调试
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论