Java 的 SPI 机制

ServiceLoader 像一把木质的小摇椅,吱呀一声就把整个类加载链晃起来。
入口始终只有一行:

ServiceLoader.load(MyService.class)

外表轻松,内里粗犷。

它的流程更像老工厂的手账式巡线:

  1. 用调用者的 ClassLoader 作为起点
  2. 去找 META-INF/services/<接口全名>
  3. 读文件
  4. 看到一行类名就反射一次
  5. 成功了就缓存进 providers
  6. 下一行继续

这机制出奇朴素,像一架木头梯子,踩一节响一下。

LazyIterator:SPI 的灵魂也算负担

核心逻辑在这一行:

new LazyIterator(service, loader)

LazyIterator 的“懒”不是优雅,而是不着急。
它每次迭代都再打开文件查一眼;每一行再反射一次。

它的几个特点:

  • 没锁
  • 没去重
  • 没排序
  • 没检查版本冲突
  • 没防呆

像老实工人:给啥干啥,从不问你是不是给了三套重复的工具。

不常被提的实现细节

  • SPI 文件里如果包含不可访问的类名,它不会一次性失败
    它会把异常“延迟”到使用迭代的那一步
  • 多个同名 SPI 文件(来自不同 jar)会合并逻辑
    顺序依 classpath
  • 读取是基于 LineNumberReader,换行格式大小写全部照收
  • ServiceLoader 内部的缓存是弱引用,不永远占着内存

这些细节让它更像一把“靠天吃饭”的工具。
它依赖 classpath,依赖加载器的可见性,依赖 jar 合并时你有没有搞出三份同名文件。

真实生产环境里,这种“盲信”导致大量奇怪事故:

  • fat-jar 合并时把 META-INF/services 合在一起
  • shading 把类 relocate,但 SPI 文件没改
  • OSGi 模块化导致 SPI 文件和实现不在一个 loader
  • Spring Boot nested-jar 环境导致某些 plugin 不可达
  • 带版本号的类重复加载,ServiceLoader 依序全 new 出来

SPI 有一种古旧的味道:简单得不合时代,但稳定得像铁锤。


ClassLoader:SPI 的天与地

ClassLoader 决定 SPI 能看到谁。
classpath 决定 ClassLoader 能看到什么。
启动方式(java -cp、java -jar、Bootstrap 追加路径等)决定 classpath。

三者一环扣一环。

类加载塔的现实结构

Bootstrap
  ↓
Platform (旧称 Ext)
  ↓
App
  ↓
自定义 ClassLoader(无数)

默认双亲委派:子级永远问父级“有木有”,父级没有才往下。

ServiceLoader 默认使用调用者的 loader。
如果接口在 AppLoader,而实现类在一个 CustomLoader(如插件框架自己建的 URLClassLoader),情况会分成两种:

情况 A:SPI 文件在 AppLoader 可见,ClassLoader.loadClass() 只能看到父级层级
结果:ServiceLoader 根本加载不到 plugin 实现。

情况 B:SPI 文件和实现类都在自定义加载器下
SPI 正常,甚至能“隔离版本”。
这是 Dubbo、JDBC 驱动、Java9 模块系统常玩的花样。

罕见但杀伤极大的情况:ClassLoader shadowing

假设你 classpath 里有两个 MyServiceImpl:

lib-1.jar
plugins/my-plugin.jar

如果 lib-1.jar 在 classpath 前面,AppClassLoader 会在第一次加载时直接命中 lib-1.jar 版本。
plugin 里的那个再好也不会被看到。

很多人以为“两个 jar 都有类名,但 SPI 读取的是 plugin 里的文件,所以应该都是 plugin 里的实现”。
现实却是:
SPI 文件来源与类加载来源是两套逻辑,不保持一致。

这导致“一半可见,一半失明”的混乱状态。


classpath 顺序带来的真实灾难

真实的线上案例里,导致奇怪错误的根源往往不是代码,而是 classpath 顺序:

  • A.jar 先于 A-patch.jar → patch 失败
  • plugin 排序顺序不稳定 → SPI 找不到实现
  • 多版本 Jackson、Netty、Guava → 瞬间起火
  • shading 后的类与未 shading 的类一起出现 → ClassCast 崩溃
  • ClassLoader 在某些容器里变成三层或四层 → 父-loader 吃掉所有类

尤其是在 Linux 上:

plugins/*

展开顺序取决于文件系统排序,有的按创建时间,有的按字典序,有的随机。

SPI 机制依赖这个顺序来合并文件、加载实现类,稍有抖动就可能导致“昨天能跑今天抽风”。


SPI × classpath × ClassLoader:真实故障案例扩展

结构:

gateway-api.jar
gateway-core.jar
plugins/
  ├── alipay-plugin.jar
  ├── wechat-plugin.jar
  ├── unionpay-plugin.jar

SPI 文件在 core 里:

META-INF/services/com.lee.PaymentChannel

classpath 启动方式:

java -cp gateway-core.jar:plugins/*:gateway-api.jar com.lee.Main

现象:

  • 有时只能加载到两个实现
  • 有时启动飞快,有时卡在初始化
  • 有时还能加载到旧版本 plugin 的实现(明明删除过)

最终诊断:

  1. 文件系统对 plugins/* 展开不按固定顺序
  2. 某些 jar 被 AppClassLoader 看到较早
  3. SPI 文件位置固定,但实现类的可见性随 classpath 浮动
  4. 某些情况下,被父加载器“吞掉”旧版本的实现
  5. LazyIterator 在不稳定顺序下,每批次合并结果不同

解决方式非常具体:

方案(确实有效)

  1. 部署脚本里显式排序 plugin:
java -cp "gateway-core.jar:$(ls plugins/*.jar | sort | tr '\n' ':'):gateway-api.jar" com.lee.Main
  1. 把插件加载改成自定义 URLClassLoader(隔离、可控)
    像:
new URLClassLoader(urls, Main.class.getClassLoader());
  1. 使用像 Dubbo SPI、SpringFactories 的增强版 SPI
    这些 SPI 都加了排序、隔离、缓存与强校验。

  2. 插件目录加 version 目录并显式声明 mapping
    避免插件冲突。

这件事真正的经验是:

SPI 本质上依赖“classpath 是稳定序列”这件事。
一旦 classpath 不稳定,SPI 像断线风筝。


Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐