Java 的 SPI 机制
Java 的 SPI 机制
ServiceLoader 像一把木质的小摇椅,吱呀一声就把整个类加载链晃起来。
入口始终只有一行:
ServiceLoader.load(MyService.class)
外表轻松,内里粗犷。
它的流程更像老工厂的手账式巡线:
- 用调用者的 ClassLoader 作为起点
- 去找
META-INF/services/<接口全名> - 读文件
- 看到一行类名就反射一次
- 成功了就缓存进 providers
- 下一行继续
这机制出奇朴素,像一架木头梯子,踩一节响一下。
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 的实现(明明删除过)
最终诊断:
- 文件系统对
plugins/*展开不按固定顺序 - 某些 jar 被 AppClassLoader 看到较早
- SPI 文件位置固定,但实现类的可见性随 classpath 浮动
- 某些情况下,被父加载器“吞掉”旧版本的实现
- LazyIterator 在不稳定顺序下,每批次合并结果不同
解决方式非常具体:
方案(确实有效)
- 部署脚本里显式排序 plugin:
java -cp "gateway-core.jar:$(ls plugins/*.jar | sort | tr '\n' ':'):gateway-api.jar" com.lee.Main
- 把插件加载改成自定义 URLClassLoader(隔离、可控)
像:
new URLClassLoader(urls, Main.class.getClassLoader());
-
使用像 Dubbo SPI、SpringFactories 的增强版 SPI
这些 SPI 都加了排序、隔离、缓存与强校验。 -
插件目录加 version 目录并显式声明 mapping
避免插件冲突。
这件事真正的经验是:
SPI 本质上依赖“classpath 是稳定序列”这件事。
一旦 classpath 不稳定,SPI 像断线风筝。
更多推荐

所有评论(0)