
Java 里有个隐藏很深、但几乎每个写服务端的人都离不开的扩展机制叫 SPIService Provider Interface服务提供者接口。网上讲 SPI 的文章不少但大多数是在翻译规范文档看完知道有ServiceLoader这么个玩意儿一写还是不会用一踩坑还是不知道为什么。这篇文章我不打算做那种教科书式科普而是从一个真实使用者的角度先把它解决的问题掰开揉碎讲清楚再带你扒一扒 JDK 源码里ServiceLoader的工作方式最后给出一套可以直接抄走的完整示例和一份踩坑清单。文章会涉及 JDBC 驱动加载、日志门面这类你每天都在用的场景也会讨论类加载器、懒加载这些进阶内容。适合正在学习框架原理、想搞懂 Spring Boot 自动装配底层逻辑的人也适合那些想在自己的项目里做插件化、动态加载扩展功能的开发者。读完你应该能做到自己定义一套 SPI 规范、写实现类、配置文件、用ServiceLoader加载并正确排序同时能排查掉大部分“为什么加载不到”的经典问题。1. 先搞清楚SPI到底解决什么问题1.1 一个让人头皮发麻的场景调用方把实现写死假设你在做一个数据转换框架需要支持从 CSV 读数据、从 JSON 读数据、从 XML 读数据。最直白粗暴的写法是public class DataImporter { public void importData(String format, String path) { if (csv.equals(format)) { new CsvParser().parse(path); } else if (json.equals(format)) { new JsonParser().parse(path); } else if (xml.equals(format)) { new XmlParser().parse(path); } } }这个代码能跑通但是问题很大。第一框架每支持一种新格式你都要改主流程代码破坏开闭原则第二如果这个框架是要分发给别的团队用的对方想自己写一个 Excel 解析器扩展进去根本做不到因为他改不了你的核心类。换句话说调用方被死死绑在了实现类上。这是很多 Java 项目从“能用”到“可扩展”之间最绕不过去的一道坎。1.2 把“依赖倒置”落到实处引入 SPISPI 的思路非常反直觉五个字就能概括实现反向找。框架方在代码里不写new XxxParser()只定义一个接口然后在 classpath 下的一个约定目录里放一个文本文件文件里写明“我的实现类是哪一个”。运行时框架用 JDK 自带的工具类去这个约定目录扫文件把实现类反射创建出来再拿来用。于是上面那个框架的调用方就变成了ServiceLoaderDataParser loaders ServiceLoader.load(DataParser.class); for (DataParser parser : loaders) { if (parser.canParse(format)) { return parser.parse(path); } }新增一种格式时所有方只需要做三件事写一个DataParser实现类、在META-INF/services/目录下加一个文件名和接口全限定名一样的文件、把文件内容写成新实现类的全限定名。主流程代码一行都不用动。这就是 Java 里“插件化”最原始的雏形也是后来很多框架做扩展点的基础。1.3 API 和 SPI 的本质区别不少人会把 API 和 SPI 混淆其实它们出发点完全不同。**APIApplication Programming Interface**是“我给你提供什么”由框架定义接口业务方调用。它是正向依赖调用方依赖框架。**SPIService Provider Interface**是“我留了一个插槽你往里填充实现”由框架定义扩展点业务方实现接口。框架方负责加载调用业务方负责提供实现依赖方向被倒置了。一句话总结API 是给调用者用的SPI 是给实现者用的。举一个生活中的例子。家里墙上的插座和电器插头插座定义了电压、频率、形状这就是 API所有电器都得按这个标准来。但是插座上还留着一个可拆卸的转换头位置你可以在上面接各种转换器这个预留的插槽就是 SPI。你可以买新转换头插上去不用把整面墙拆了重新布线。JDK 的 SPI 机制就是那面墙上的预留插槽ServiceLoader则是帮你识别这个插槽里到底插了什么设备的识别器。2. Java SPI机制的JDK实现原理2.1 约定优于配置SPI 三要素JDK 实现 SPI 依赖三个东西缺一不可。服务接口这是契约决定实现类需要具备什么能力。比如java.sql.Driver、java.time.zone.ZoneRulesProvider。服务实现类实现该接口的类必须提供无参构造器并且类是public的。配置文件必须放在META-INF/services/目录下文件名必须是接口的全限定名即包名加类名文件内容每行写一个实现类的全限定名支持#注释。目录结构示意src/main/resources/ └── META-INF/ └── services/ └── com.example.spi.DataParser # 文件名是接口全限定名文件内容com.example.spi.impl.CsvParser com.example.spi.impl.JsonParser为什么 JDK 选择用配置文件而不是直接扫描包路径下的注解因为文件扫描是显式的只加载你声明过的实现类。如果像搜索组件那样把所有 class 都翻一遍启动时的类加载开销会非常夸张而且容易触发各种 ClassLoader 的连锁副作用。配置文件的方式既简单又稳妥这也是早期 JDK 设计者最看重的点不加载就没风险不回头就没坑。2.2 ServiceLoader 的工作流程整个加载过程可以拆成下面几个步骤。调用ServiceLoader.load(DataParser.class)拿到一个ServiceLoader实例。遍历这个实例它实现了Iterable。第一次调用iterator.next()时程序才去读取META-INF/services/com.example.spi.DataParser文件。按行解析忽略空行和#开头的注释行。对每个实现类调用Class.forName(className, true, classLoader)完成类加载。通过反射调用无参构造器创建实例。把实例缓存到内部的LinkedHashMap中下次再遍历就直接复用。所以注意一点在迭代开始之前ServiceLoader 不会加载任何实现类。这种设计就是为了实现懒加载防止一启动就加载一堆实际上根本用不到的扩展类造成启动变慢、内存浪费。2.3 懒加载与迭代器启动时不加载任何实现ServiceLoader内部维护了一个懒加载的迭代器这一点很关键。它不像一些框架那样在load()的时候就把所有实现全部实例化而是每次hasNext()、next()的时候才真正“打开文件、读一行、加载一个类、创建一个实例”。遍历完一个实现类再继续读下一行。这意味着如果你只用了前两个实现后面的实现类不会被加载某一个实现类的构造器抛异常了不会影响前面已经被使用的实例reload()方法可以丢弃内部缓存重新读取配置文件适合动态插拔插件的场景。这一整套机制本质上就是“按需加载”和懒汉式单例有异曲同工之处。用到才加载省下的都是真金白银。3. 手写一个SPI案例5分钟跑通全流程3.1 完整案例给协议网关动态加载适配器我前两年做一个内部数据传输中间件的时候需要支持多种协议接入。最初把所有适配器写死在代码里后来协议越来越多我实在受不了每次都改核心代码就切换到 SPI。这里把当时最核心的模型简化一下拿出来讲你可以直接照着写。先定义服务接口package com.example.spi; public interface ProtocolAdapter { String getProtocolName(); Object convert(String originData); }写两个实现类package com.example.spi.impl; import com.example.spi.ProtocolAdapter; public class HttpAdapter implements ProtocolAdapter { Override public String getProtocolName() { return http; } Override public Object convert(String originData) { return http:[ originData ]; } }package com.example.spi.impl; import com.example.spi.ProtocolAdapter; public class FtpAdapter implements ProtocolAdapter { Override public String getProtocolName() { return ftp; } Override public Object convert(String originData) { return ftp:[ originData ]; } }在src/main/resources/META-INF/services/下新建文件com.example.spi.ProtocolAdapter内容com.example.spi.impl.HttpAdapter com.example.spi.impl.FtpAdapter注意文件名不能随便写必须和接口的全限定名完全一致。否则ServiceLoader会根据接口名去找文件找不到就静默返回空迭代器代码完全不报错但你会莫名其妙地发现加载不到任何实现。3.2 加载与调用用 ServiceLoader 遍历拿到适配器加载逻辑很简单package com.example.spi; import java.util.ServiceLoader; public class GatewayMain { public static void main(String[] args) { ServiceLoaderProtocolAdapter adapters ServiceLoader.load(ProtocolAdapter.class); for (ProtocolAdapter adapter : adapters) { System.out.println(loaded adapter: adapter.getProtocolName()); Object result adapter.convert(hello spi); System.out.println(result); } } }运行后输出loaded adapter: http http:[hello spi] loaded adapter: ftp ftp:[hello spi]这里有个很容易被忽略的点ServiceLoader.load()接受的是接口的Class对象而且返回的迭代器元素类型就是接口类型。你不需要关心底层实现类是怎么被创建出来的也不用关心它们具体叫什么名字。框架拿到的是一组接口引用调用时只需要面向接口操作。3.3 多个实现类同时生效怎么选处理顺序上面的例子把所有实现都打印出来了。但真实业务里同一时刻往往只需要一个适配器生效。比如传入的协议名是http那就只调HttpAdapter。一个简单的做法是遍历匹配协议名String protocol http; for (ProtocolAdapter adapter : ServiceLoader.load(ProtocolAdapter.class)) { if (adapter.getProtocolName().equals(protocol)) { Object result adapter.convert(hello); // 拿到result做后续处理 break; } }但这里有一个潜在的坑SPI 自己没有定义加载顺序的语义。虽然正常情况下ServiceLoader会按照配置文件里的行顺序加载但如果同一个配置文件在多个 jar 里都存在比如 classpath 里有多个同类库顺序就可能会受到 jar 包加载顺序的影响变得不稳定。所以如果业务上对优先级有要求最稳妥的做法是在接口里加一个排序维度比如public interface ProtocolAdapter { String getProtocolName(); Object convert(String originData); // 数值越小优先级越高 default int order() { return Integer.MAX_VALUE; } }加载后再用Comparator排序ListProtocolAdapter ordered new ArrayList(); ServiceLoader.load(ProtocolAdapter.class).forEach(ordered::add); ordered.sort(Comparator.comparingInt(ProtocolAdapter::order));千万别依赖配置文件的行序来决定谁先谁后。我曾经在这个问题上吃过亏某次中途往一个已经上线的 jar 包插了一份新的 SPI 实现结果某个老功能的行为突然变了排查了半天才发现是迭代顺序变了导致选错了适配器。加个order()之后整个世界安静了。4. JDK里那些你没注意过的SPI身影4.1 从 Class.forName 到 DriverManagerJDBC 驱动加载的演变JDBC 可能是 Java 里最经典的 SPI 实践。老一代开发者在写 JDBC 代码前都会先执行这么一句Class.forName(com.xxx.jdbc.Driver);这行代码通过反射加载了驱动类驱动类的静态代码块在加载时会主动向DriverManager注册自己。这其实是“实现方主动上报”的模式。后来 JDK 版本升级DriverManager自己改成用 SPI 来加载驱动了。初始化DriverManager的时候它会通过ServiceLoader.load(Driver.class)去读取META-INF/services/java.sql.Driver文件把文件里声明的实现类全部实例化并注册。这就是为什么很多新项目里你不再需要写Class.forName那行都能正常连上数据库的原因。同时这也解决了另一个历史问题如果驱动在 classpath 下但不在启动类加载器的加载范围Class.forName就需要指定正确的 ClassLoader否则会出现“类加载不到”的诡异异常。而ServiceLoader在加载时会把线程上下文类加载器利用起来从根上规避了双亲委派模型带来的“反向加载”难题。4.2 日志门面与第三方桥接Java 生态里日志门面层能够做到“编译期只依赖门面接口、运行期自动绑定底层实现”靠的也是 SPI 思想。你写的代码里统一调用门面接口但真正输出日志时用的是哪个底层实现并不是在代码里写死的而是通过约定文件在运行时自动发现、自动桥接。这样带来的好处非常明显底层日志实现可以随便换门面不用动。这种机制也是基于META-INF/services的文件驱动只是大多数开发者整天在用却没意识到这背后就是 JDK SPI。4.3 JDK 里那些标准 SPI 在哪里除了 JDBCJDK 自身还内置了不少 SPI服务接口用途java.sql.Driver加载 JDBC 数据库驱动java.nio.charset.spi.CharsetProvider注册自定义字符集java.time.zone.ZoneRulesProvider加载时区规则提供者javax.imageio.spi.ImageReaderSpi注册图片读取器扩展javax.xml.parsers.SAXParserFactory等提供 XML 解析器的工厂实现这些接口的存在说明一件事情SPI 不是某个框架的私有玩法而是 JDK 官方认定的标准扩展机制。你在自定义扩展点时使用同一套理论和 JDK 内部的设计理念是保持一致的。5. 源码层面剖析ServiceLoader核心5.1 核心类与关键字段ServiceLoader真正干活的部分其实很精简。先看它的几个核心字段字段作用ClassS service服务接口的 Class 对象ClassLoader loader用于加载实现类的 ClassLoader默认取自线程上下文LinkedHashMapString, S providers记录已经加载并且实例化过的实现key 是类名LazyIterator lookupIterator真正的懒加载迭代器控制着“什么时候读文件、什么时候创建实例”providers是一个LinkedHashMap它保证已加载的实例按加载顺序排列。lookupIterator则负责从配置文件里一行一行解析并准备创建实例。public final class ServiceLoaderS implements IterableS { private final ClassS service; private final ClassLoader loader; private final LinkedHashMapString, S providers new LinkedHashMap(); private LazyIterator lookupIterator; ... }5.2 LazyIterator真正干活的部分LazyIterator里面有三个重要的方法hasNextService、nextService和parse。我给出一段经过简化的伪代码方便你理解它每一步在干什么private boolean hasNextService() { if (nextName ! null) { return true; } if (configs null) { String fullName PREFIX service.getName(); // PREFIX META-INF/services/ // 用传入的 ClassLoader 获取该路径下的所有资源 configs loader.getResources(fullName); } // 逐行读取配置内容跳过空行和 # 注释直到拿到一个有效类名 while ((nextName parseLine()) ! null) { return true; } return false; } private S nextService() { String className nextName; Class? c Class.forName(className, false, loader); // 只加载类不初始化 S result service.cast(c.getDeclaredConstructor().newInstance()); // 反射创建实例 providers.put(className, result); // 放入缓存 return result; }关键点有两个。第一parseLine()会跳过空行和注释所以你在配置文件里写注释是没有问题的第二nextService里通过Class.forName(className, false, loader)先把类加载进来再进行一次强转再调用无参构造器。如果实现类没有公开的无参构造器这一步直接抛异常而且异常类型是ServiceConfigurationError不是常见的反射异常。5.3 为什么必须用线程上下文类加载器这部分是源码里最值得细品的地方。ServiceLoader.load(service)默认使用的是Thread.currentThread().getContextClassLoader()而不是ServiceLoader.class.getClassLoader()。原因要从双亲委派模型说起。JDK 核心类库里的类比如java.sql.DriverManager是由启动类加载器或其下的扩展类加载器加载的。由高层加载器加载的代码默认只能访问高层加载器可见的类。而第三方驱动、第三方实现类通常躺在应用 classpath 里只有应用类加载器看得到。如果ServiceLoader用自己所在加载器去加载实现类那能把java.sql.Driver接口的实现类加载出来才有鬼了。所以规范里定了一个约定凡是涉及“核心类库反向加载应用实现”的场合都要通过线程上下文类加载器来解决。ServiceLoader拿到线程上下文类加载器之后等于把加载请求又交还给了应用自己的加载器从而抹平了双亲委派模型在反向加载上的短板。打个比方中央厨房规定了菜谱但它不能决定每家分店用哪个供应商的食材。每家分店采购什么要问分店自己。这个“分店自己”就是线程上下文类加载器。6. 使用SPI最容易踩的坑与排查技巧6.1 配置文件没生效静默返回空迭代器SPI 最坑的地方在于它的“静默失败”。文件名写错了、目录路径写错了、配置文件没打进 jar统统不会报错只是迭代器返回空。排查的时候先看三处文件是不是放在META-INF/services/目录下注意是services有 s少写一个字母必炸文件名是不是接口的全限定名很多人会不小心把文件名写成实现类的类名这也是经典失误文件是不是被打进了最终的 jar/war可以执行jar tf xxx.jar | grep META-INF/services确认。6.2 实现类无法实例化ServiceConfigurationError如果你看到ServiceConfigurationError的堆栈大概率是这几种原因实现类不是public的没有无参构造器构造器内部抛了受检异常类名写错了或者类根本不在 classpath 里。这种报错相对友好因为至少给了堆栈。但要注意ServiceLoader在遍历时遇到这个错误会直接中断整个迭代而不是跳过有问题的实现继续加载下一个。所以如果你的某个实现类有问题后面所有实现都会一起陪葬。6.3 多实现加载顺序“不稳定”正如前面提到的SPI 不保证多个实现之间的顺序稳定。当 classpath 下存在多个 jar每个 jar 里都有同一个META-INF/services/接口名文件时最终发现的实现顺序会受 jar 加载顺序影响。解决办法就是增加显式的order()字段加载后自己排序。这也是为什么很多成熟框架的 SPI 扩展点都有类似Order注解或者order()方法。6.4 类加载器隔离导致“接口类型不一致”在容器环境里比如应用服务器、OSGi 或自研插件系统如果同一个接口被不同的类加载器各加载了一次那ServiceLoader拿到的实现类可能根本不是你期望的那个接口的实现类。因为 Java 的类型匹配是全限定名加类加载器双重决定的来自不同加载器的两个同名接口在 JVM 眼里是两个完全不同的类型。解决思路是接口尽量放在由父级加载器加载的公共模块里实现类才放在各业务模块里。这样ServiceLoader用父级加载器加载接口定义用线程上下文加载器加载实现类才能保证类型对得上、强转不失败。6.5 打包的时候 SPI 文件丢了Maven 项目里最常见的引起这一问题的原因是把META-INF/services文件放在了src/main/java目录下同时又开启了某些编译过滤导致资源文件没有进入target/classes。稳妥的做法是把 SPI 配置文件统一放在src/main/resources/META-INF/services/路径下这样 Maven 的标准资源复制流程会自动帮你打包进去。6.6 常见问题速查表为了方便排查我把最容易遇到的问题整理成一张表症状可能原因排查方式迭代器为空代码不报错配置文件缺失、文件名错误、目录名拼错检查META-INF/services路径和文件名检查 jar 包内容加载时报ServiceConfigurationError无公开无参构造器、类名错误、类不是 public查看堆栈中具体类名检查类定义多个实现顺序不稳定classpath 下多处同名配置文件增加order()排序别依赖配置文件行序在容器中强转失败接口被不同类加载器各自加载接口上移交给父级加载器统一加载配置文件没打进包资源目录配置错误检查target/classes下是否有对应文件7. 框架应用、现代扩展点与模块化方向7.1 从 JDK SPI 到现代框架的自动装配如果你理解了 SPI再去看现在主流框架的自动装配机制会感觉豁然开朗。很多框架里都有一个类似“约定文件”的入口框架启动时读取一批配置项根据配置项去加载对应的实现类完成自动装配。这个思想和 JDK SPI 完全一致接口与实现分离、通过外部文件驱动运行时发现、按需创建实例。只不过框架层把它做得更精细加入了很多增强逻辑例如支持条件装配只有满足某些条件时才加载支持属性取值能够从配置文件里读取参数传给实现类支持扩展点的注解式声明降低使用成本。但底层仍然是“约定文件 反射创建 缓存实例”这三个步骤。所以先把 JDK SPI 玩明白后面看任何框架的扩展机制你都会有一种“原来你也在这里”的熟悉感。7.2 模块化系统module-info 中的 provides 与 uses从 Java 9 开始JDK 引入模块化系统之后SPI 又多了一种声明方式。你可以在module-info.java里直接用provides声明服务实现module com.example.core { exports com.example.spi; uses com.example.spi.ProtocolAdapter; } module com.example.impl { requires com.example.core; provides com.example.spi.ProtocolAdapter with com.example.spi.impl.HttpAdapter; }这种情况下META-INF/services文件甚至可以省略模块系统会通过provides语句自动发现服务实现。不过在实际存量项目里大部分代码还是跑在 classpath 模式下META-INF/services配置文件仍然是绝对主流。所以两种方式都值得了解但别急着把旧项目都改成模块化。7.3 如何把 SPI 用在自己的框架里如果你想在自己的项目里引入 SPI我建议从最简单的场景开始找出一处因为“接口多个实现”而不断写if-else的代码把实现类拆到独立子包添加META-INF/services声明文件用ServiceLoader替换if-else如果有优先级需求给接口加order()默认方法。刚开始不需要设计多复杂的插件体系先把最基础的一环打通。等你的业务扩展点多了自然会发现还要补上“配置参数传递”“异常隔离”“动态卸载”这些能力。那时候你会感谢 JDK 这套最朴素的 SPI因为所有升级都是在它基础上完成的没有历史包袱。我个人在实际操作中的体会是Java SPI 这套机制看上去很简单但它是少有的“越用越觉得巧妙”的基础设施。它把“服务发现”做成了语言级的能力见不到什么魔法也没有隐藏的扫描逻辑一切都有迹可循。你只要记住了那个约定看懂LazyIterator的懒加载节奏以后遇到任何扩展点相关的代码都不会再觉得发怵。最后再分享一个我自己坚持的小习惯每次写完一个 SPI 模块都会顺手在测试里加一个用例专门断言配置文件里声明的每个实现类都能被实例化。因为配置文件里如果有一个类名敲错了运行时往往要到真正调用那个扩展功能的时候才炸定位成本很高。提前用测试锁死它能帮你省掉很多“线上突然报错却不知道是哪位扩展实现写错了类名”的深夜时光。