
1. 先搞清楚Java SPI到底在解决什么问题网上搜“SPI”这个词大概率会先翻到一堆硬件资料spi dma、GD32F303、TF卡的spi电路、片选引脚……但今天要聊的是Java生态里的那个SPIService Provider Interface服务提供者接口。它是JDK原生提供的一套服务发现机制核心逻辑一点都不玄乎接口定义方不指定具体实现实现方把自己的类名写进classpath下META-INF/services目录里的一个同名文件需要用到这些实现的时候用java.util.ServiceLoader去加载。就这么一个简单的约定撑起了大量框架的“插件化”能力。要理解它解决了什么问题最经典的案例是JDBC驱动。java.sql.Driver只是个接口DriverManager是JDK自己的但MySQL驱动、PG驱动都是第三方jar。早期的写法是先Class.forName(com.mysql.jdbc.Driver)完成注册Java 6之后引入SPI驱动jar只需在包里带一个META-INF/services/java.sql.Driver文件应用启动后DriverManager会自动把这些驱动实现注册进来。你写业务代码时感觉不到“我到底用的哪个驱动”连接串一给SPI在后台把活干完了。这正是SPI的核心价值让“接口”和“实现”在编译期零依赖在运行期靠约定自动相遇。这篇内容适合谁如果你在准备Java面试ServiceLoader几乎是可以预判的一道加分题面试官考完双亲委派必然会追一句“那SPI是怎么绕过双亲委派的”如果你是做公共组件、基础框架、内部SDK的SPI是预留扩展点成本最低的手段如果你照着文档写了SPI文件却始终加载不到实现那这篇里的踩坑清单能帮你少走很多弯路。我会从原理讲到手写扩展再拆到ServiceLoader源码里的几个关键设计最后给一份实战避坑清单。1.1 从硬编码到自动发现服务加载的三种形态第一种形态是硬编码。接口定义以后调用方直接new一个具体实现接口只是摆设。比如PaymentService paymentService new AlipayPaymentService()将来要换微信支付所有出现new的地方都要改。这种方案在实现数量很少、变化频率很低的时候没什么问题但一旦渠道多起来每次改代码、重新编译、重新上线就非常痛苦。第二种形态是“自研配置文件”。接口定义方提供一个工具类扫一个properties或xml文件读实现类类名然后Class.forName(...).newInstance()。老项目里这种代码太多了本质上就是把“new哪个类”从代码挪到配置里。它的好处是灵活坏处是每个项目都自己造一套轮子文件名、key格式、异常处理没有统一约定A项目会了B项目还得重新学一遍。第三种形态就是SPI。与其每个项目自定义一套服务发现规范不如JDK直接把它定死接口、实现类、META-INF/services目录下的接口全限定名文件三个角色各就各位。这叫“约定优于配置”。Spring、MyBatis、Dubbo里都能看到类似的身影只是叫法不同有的叫ServiceLoader有的叫spring.factories思路一脉相承。1.2 SPI适用的场景以及我不推荐的应用我自己的判断标准很简单如果一个接口将来很可能有多个实现方而且实现方彼此独立、想各自发布版本SPI就是很合适的选择。日志门面的多种绑定、JDBC驱动、序列化器的多种协议都属于这类。框架想给外部留“扩展点”的时候也常用比如一个流程引擎核心只定义Processor接口各种业务Processor通过SPI被发现注册。反过来有些场景我不建议硬套SPI。第一项目里只有一个实现并且不太可能扩展那用Spring的Autowired注入就够了SPI并不会让代码更优雅。第二SPI实例是通过反射创建的必须有无参构造Spring里的构造注入、属性注入在SPI这条路上完全走不通硬用只会给自己挖坑。第三启动阶段所有Provider的加载有性能开销如果实现类很多、每个类初始化都很重需要考虑懒加载策略否则应用启动时间会明显变长。2. 机制拆解类加载器与SPI的“借道”2.1 双亲委派模型为什么会挡住SPI经典JVM类加载规则是Bootstrap加载JDK核心类Platform老版本叫Extension加载扩展库Application加载classpath父加载器加载过的类子加载器不重复加载。默认方向是自顶向下委托。问题就在这里DriverManager是JDK核心类由Bootstrap加载MySQL驱动是应用classpath上的类由Application加载。Bootstrap根本看不到Application的classpath双亲委派只允许父把类交给子不允许父去子那里反向拿。核心框架想在运行时加载应用层的实现类直接被卡死。这里有个细节值得注意JDK 9之后Extension ClassLoader改名为Platform ClassLoader很多老文章还在用ExtClassLoader的提法现在面试时如果只答个名字显然不够。但名称变化不影响本质双亲委派的核心方向仍然是父委托给子SPI要解决的矛盾依旧存在。当年设计JDBC这套东西的人想得很明白既然标准库加载不到驱动那就给一条从标准库代码反向加载应用classpath的通道。2.2 线程上下文类加载器父加载器访问子classpath的钥匙为解决这个矛盾JDK给每个线程塞了一个Thread Context ClassLoader代码可以调用Thread.currentThread().getContextClassLoader()拿到“当前线程上下文里该用的类加载器”默认就是Application ClassLoader。于是核心模块的执行代码可以用它去加载应用classpath中的类。这相当于在原双亲委派树上开了一条横跨通道。ClassLoader contextClassLoader Thread.currentThread().getContextClassLoader(); Class? clazz contextClassLoader.loadClass(com.example.pay.alipay.AlipayPaymentProvider);Servlet容器里的WebAppClassLoader也用相同思路解决“容器加载器与Web应用加载器之间的类访问问题”。所以面试时说“SPI打破了双亲委派模型”并不是特别准确更贴切的说法是SPI借助线程上下文类加载器完成了一次“借道访问”双亲委派模型本身没有被推翻只是多了条旁路。这样答出来面试官基本就能看出你是真看过源码的人。2.3 ServiceLoader的标准加载链路ServiceLoader.load拿到接口后做的事可以拆成三步获取线程上下文ClassLoader作为后续类加载的“执行者”拼接路径META-INF/services/接口全限定名一行行读文件对每一行类名执行Class.forName(className, false, classLoader)再通过无参构造实例化。这里有两个容易忽略的细节。一个是对应实现类必须有public无参构造因为ServiceLoader内部最终通过Constructor.newInstance()触发如果你只定义了一个带参构造大概率会得到NoSuchMethodException或反射的IllegalAccessException这是很多新手第一次写SPI就会踩的坑。另一个细节是加载和实例化被分成两步配置文件里的所有实现类都会被解析出来但具体实例化发生在你遍历的时候谁先被遍历到谁先被实例化这就引出了ServiceLoader最容易被误解的“懒加载”特性。3. 手把手实现一个SPI支付渠道扩展实战说再多理论都不如直接写一个可复现的Demo。我用一个支付渠道扩展的例子来演示完整流程这也是我当初在内部SDK里第一次使用SPI时做的事情。3.1 第一步定义一个稳定的支付接口package com.example.pay.spi; import java.math.BigDecimal; public interface PaymentProvider { String channel(); void pay(String orderId, BigDecimal amount); }接口放在哪个包、叫什么名字非常关键因为META-INF/services下的文件名就是它。如果这个接口将来要被多个团队依赖包名最好具备足够的辨识度比如com.example.pay.spi而不是随手放在某个业务模块里。接口的签名也要想清楚一旦SPI文件散出去实现方已经按签名写完代码再想改方法就会面临所有实现方一起升级的窘境。我见过最痛的情况就是接口里放了一个DTO参数后来DTO要加字段所有provider全都跟着改版本管理乱成一锅粥。所以SPI接口定义要尽量精简参数对象要敢于做“冗余设计”。3.2 第二步编写两个不同渠道的实现类package com.example.pay.spi.alipay; import com.example.pay.spi.PaymentProvider; import java.math.BigDecimal; public class AlipayPaymentProvider implements PaymentProvider { public AlipayPaymentProvider() { // 必须存在且是public } Override public String channel() { return alipay; } Override public void pay(String orderId, BigDecimal amount) { System.out.println(支付宝渠道支付订单号: orderId 金额: amount); } }微信渠道的WechatPaymentProvider结构完全一样只是channel返回“wechat”内部输出换一行。这里要特别强调无参构造器要显式写成public。默认构造器虽然是public无参的但不同模块反射访问时容易踩到模块访问控制或包访问权限问题。写实现类时也不要依赖Spring容器里的任何Bean因为SPI加载它是直接通过反射new出来的不是Spring帮你装配的。如果确实需要Spring的依赖后面第6.3节我会讲怎么处理。3.3 第三步在resources下创建SPI配置文件这是整个流程里最容易翻车的环节。先看正确的目录结构pay-alipay/src/main/resources/META-INF/services/com.example.pay.spi.PaymentProvider文件内容# 支付宝渠道 com.example.pay.spi.alipay.AlipayPaymentProvider com.example.pay.spi.wechat.WechatPaymentProvider几个硬性要求文件名是接口全限定名不是实现类的名字也不是随便起一个provider.propertiesMETA-INF/services必须全小写Linux环境下大小写敏感少写一个字母就是静默失败文件内容每一行一个实现类全限定名行首#开头整行是注释但不要在类名后面加#注释因为整行会被当作类名去加载保存编码保持UTF-8无BOM尤其不要用Windows记事本编辑后直接保存它会悄悄塞进一个不可见字符第一行类名直接变成\uFEFFcom.example...报错信息看起来像ClassNotFoundException排查起来非常隐晦。3.4 第四步用ServiceLoader加载并消费调用方的代码极其简单ServiceLoaderPaymentProvider loader ServiceLoader.load(PaymentProvider.class); for (PaymentProvider provider : loader) { System.out.println(发现支付渠道: provider.channel()); }如果你只想挑选某一个特定渠道可以这样处理PaymentProvider alipay ServiceLoader.load(PaymentProvider.class).stream() .map(ServiceLoader.Provider::get) .filter(p - alipay.equals(p.channel())) .findFirst() .orElseThrow(() - new IllegalStateException(没有可用的支付宝渠道));stream()方法是JDK 9加入的如果你还在维护JDK 8的项目可以用Iterator配合List做同样的事。把上面的代码跑起来控制台会依次输出两个渠道说明SPI已经生效。这个例子里主程序只依赖了接口模块运行的时候能把支付宝和微信两个实现都捞出来——“编译期无感知、运行期自动发现”的感觉到这里就很直观了。3.5 多实现的顺序与优先级多实现同时存在时遍历顺序默认和配置文件里的行顺序一致。但我不建议业务代码依赖这个顺序因为不同打包方式、不同模块装配方式可能改变最终文件内容的拼接顺序。正规做法是在接口里增加优先级语义public interface PaymentProvider { String channel(); void pay(String orderId, BigDecimal amount); default int order() { return 0; } }拿到所有Provider之后按order()排序再决定先调用谁。还有一个很多人不知道的行为ServiceLoader是懒加载的循环里如果break在第一个实现之后后面的Provider不会被实例化。只要你不遍历它们它们的构造器、static块都不会触发别指望在启动时把所有实现都初始化一遍。4. 源码层看ServiceLoader懒加载、缓存与异常设计4.1 ServiceLoader的三种加载入口JDK提供了三个静态方法入口不同行为也不同方法使用的ClassLoader适用场景ServiceLoader.load(service)当前线程上下文类加载器常规场景ServiceLoader.load(service, classLoader)显式指定ClassLoader动态插件、独立jar目录ServiceLoader.loadInstalled(service)系统ClassLoader只加载系统classpath中的实现loadInstalled比较冷门它拿的是系统类加载器加载不了Web容器或自定义类加载器里的实现日常开发几乎用不到但面试里被问到“ServiceLoader有几个load方法”时就靠它撑场子。真正值得关注的是第二个入口它让SPI具备了插件化的能力——你可以为一个指定目录专门创建URLClassLoader再基于这个ClassLoader去做服务发现。4.2 LazyIterator按需实例化的设计意图真正的读取逻辑藏在ServiceLoader内部的LazyIterator里。hasNext阶段负责打开配置文件、解析每一行、把类名存到pending队列next阶段才真正反射加载并实例化。这种设计让“只想要一个实现”的场景不必把全部实现都new一遍也解释了为什么前面说“break之后后面的类不会被实例化”。这个特性的另一面是如果某个实现类的构造器抛了异常而它恰好排在最前面你循环里的异常会立刻中断后面的实现连出场机会都没有。所以遍历所有Provider的时候最好在循环体里对每个实现做独立的try-catch把一个Provider的初始化失败隔离起来避免整个加载过程被拖垮。还有一个容易忽视的点ServiceLoader内部对已经加载过的Provider是有缓存的调用reload()会清空缓存让下一次遍历重新读配置文件。但要注意如果运行期往classpath里新丢了一个jar光靠reload()不一定能发现它因为AppClassLoader根本不知道这个新jar的存在。这就要引入URLClassLoader了具体做法在5.3节会讲到。4.3 ServiceConfigurationErrorError不是ExceptionSPI加载过程中的解析错误、类不存在、构造器不可访问抛出的都是ServiceConfigurationError注意这是Error不是Exception。很多同学只catch了Exception结果日志里打着“Error loading configuration file”的异常直接穿透业务层怎么都定位不到。如果你在对外提供SDK建议把SPI加载的入口包一层try { for (PaymentProvider provider : ServiceLoader.load(PaymentProvider.class)) { // 处理业务 } } catch (ServiceConfigurationError e) { log.error(SPI加载失败请检查 META-INF/services 配置文件, e); }错误信息里通常能直接看到具体是哪一行配置出了问题。Provider com.xxx.Provider not found指配置文件里写了类名但对应类不在classpathError loading configuration file指整个文件都读不到路径和文件名的问题更多。把Error和Exception分清楚排查日志时能少走一大段弯路。5. 框架里的SPI应用从JDBC到Spring Boot5.1 JDBC驱动自动注册最经典的落地案例JDK 6之后DriverManager在静态初始化块里就做了一次ServiceLoader加载ServiceLoaderDriver loadedDrivers ServiceLoader.load(Driver.class); IteratorDriver driversIterator loadedDrivers.iterator();第三方驱动jar只要在包里带了META-INF/services/java.sql.Driver文件应用启动时DriverManager就会把这些驱动实现注册进自己的驱动列表。所以现在的项目连Class.forName(com.mysql.cj.jdbc.Driver)都不需要写了连接串给对就自动找到驱动。回到双亲委派的角度看这个过程DriverManager由Bootstrap或Platform类加载器加载却通过ServiceLoader加载classpath上的驱动实现SPI配合线程上下文类加载器完成了这次跨加载器访问。面试时如果把这个链路讲清楚比单纯背一遍“SPI可以打破双亲委派”要扎实得多。另外注意一个细节多个驱动同时存在时DriverManager.getConnection会逐个尝试已有驱动第一个能连上的就被使用。这也是为什么同时存在MySQL和PG驱动时靠连接串里的jdbc协议前缀做区分。如果你在代码里手动DriverManager.registerDriver注册过驱动它会和SPI自动注册的驱动共存顺序还可能影响最终选择这种隐形的坑在排查“为什么连到了别的数据库”时要留个心眼。5.2 Spring Boot的spring.factoriesSPI的近亲Spring从很早就提供了SpringFactoriesLoader读取META-INF/spring.factories文件。文件格式类似这样org.springframework.boot.autoconfigure.EnableAutoConfiguration\ com.example.autoconfigure.ExampleAutoConfiguration,\ com.example.another.AnotherAutoConfigurationkey是配置类或接口的全限定名value是逗号分隔的多个实现类换行用反斜线。这和原生SPI“一行一个类名”没有本质区别都是扫描文件、读取类名、反射实例化。Spring Boot自动装配就是靠它在启动时从各个jar里捞AutoConfiguration类再配合条件注解决定是否真正创建Bean。两者差异在于加载时机原生SPI在遍历到Provider时才实例化属于典型懒加载spring.factories通常在容器启动阶段统一读取然后由Spring处理条件判断和依赖注入。Spring Boot 3之后自动装配配置逐步迁移到META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports等新路径但思想仍然一致。如果你自己写过一个starter把Configuration类注册进spring.factories其实就是在写一份服务发现清单和SPI文件异曲同工。5.3 用SPI做插件式架构的真实边界做了一个内部SDK想把不同类型的采集器做成插件核心模块定义Reporter接口各插件模块实现接口并放SPI文件宿主程序启动时自动发现。这一步看起来很美但有一个真实工程坑如果你只是把实现jar往某个plugins目录一丢普通AppClassLoader根本不会扫描那个目录。必须主动构建一个URLClassLoader加载这些jar再把这个ClassLoader传给ServiceLoaderURL[] urls Arrays.stream(pluginDir.listFiles(f - f.getName().endsWith(.jar))) .map(f - { try { return f.toURI().toURL(); } catch (MalformedURLException e) { throw new UncheckedIOException(e); } }) .toArray(URL[]::new); ClassLoader pluginLoader new URLClassLoader(urls, Thread.currentThread().getContextClassLoader()); ServiceLoaderReporter reporters ServiceLoader.load(Reporter.class, pluginLoader);这段代码其实就是“SPI配合指定ClassLoader做动态插件”的最小骨架。再往上走热加载、版本冲突、类重复加载、插件生命周期管理每一项都需要额外设计。所以我的态度很明确SPI只是服务发现协议不是完整的插件框架。想做一个生产级插件体系还是要引入OSGi或者成熟的插桩框架不能指望ServiceLoader全包。6. 排查与踩坑SPI实战问题清单6.1 配置文件最容易踩的五个坑下面这些是我过去几年见过的真实翻车现场按频率排个序。排在首位的永远是文件名写错因为ServiceLoader找不到文件时不会报错只会安静地返回一个空集合你根本不知道是“没实现”还是“没找到”。其次是编码有一年我接手一个老项目配置文件在Windows记事本里改过一次第一行就带BOM报错信息里那个类名前面多了个不可见字符排查了很久才定位到编辑器头上。文件名拼错或没写接口全限定名META-INF/services下必须是接口全限定名多一个字母、少一个字母都不行。目录大小写不对必须全小写META-INF/services/Windows本地不敏感Linux生产环境立刻翻车。UTF-8 BOM问题文件保存成带BOM的UTF-8后第一行类名会被污染报ClassNotFoundException。行尾注释误用行首#是注释没问题类名后面加#xxx会把整行当类名直接加载失败。Maven打包过滤resources里做了占位符过滤或打包插件把services文件过滤掉了运行时配置自然为空。索引到jar内部看一眼是最快的验证方式jar tf xxx.jar | grep META-INF/services。什么都没有说明文件没打进去能看到文件再unzip -p xxx.jar META-INF/services/...看内容有没有被改坏。6.2 常见报错与排查速查表现象可能原因排查手段迭代结果为空配置文件路径/文件名错误文件内容为空类不在classpathjar tf检查文件loader.stream().count()看加载数量ClassNotFoundException配置里类名拼错依赖jar缺失对照报错里的类名和配置文件逐字检查ServiceConfigurationError: Provider xxx not found配置文件里有类名但类不在classpath检查运行时依赖确认实现jar是否被正确引入NoSuchMethodException / IllegalAccessException实现类没有public无参构造为实现类补上public无参构造器多个实现只有一个生效shade插件覆盖了同名SPI文件添加ServicesResourceTransformer测试环境正常生产环境不生效fat jar打包后services文件路径变化检查最终jar里的SPI文件是否存在且未被覆盖关于maven-shade-plugin覆盖的问题值得单独说一句。打fat jar时多个依赖jar里可能存在同名SPI文件比如多个驱动jar都有META-INF/services/java.sql.Driver默认情况下shade只会保留其中一个另一个驱动就“神秘消失”了。解决办法是加一个Transformertransformer implementationorg.apache.maven.plugins.shade.resource.ServicesResourceTransformer/这个配置能把多个services文件内容合并而不是互相覆盖。类似的坑在Spring Boot fat jar里也存在但Spring Boot的LaunchedURLClassLoader对BOOT-INF/lib下的jar处理得比较好大多数SPI场景能正常工作真出了问题优先检查最终构建产物而不是在代码里反复猜。6.3 SPI与Spring Bean的管理边界最后想聊一个实践认知SPI和Spring DI是两条线。SPI反射实例化的类不会自动注入Spring容器里的依赖反过来Spring容器管理的Bean也不会自动出现在ServiceLoader的结果里。很多人把SPI实现类标注成Component以为这样既能被SPI加载又能被Spring注入结果SPI加载时用无参构造new出来的对象根本不是Spring代理的那个实例两套体系各管各的。我常用的几种组合方式是如果SPI实现类完全无状态、不依赖其他Bean那直接交给ServiceLoader没问题如果它需要调用业务服务我会让实现类实现ApplicationContextAware在SPI反射创建后手动从Spring容器里捞依赖或者干脆用一个Spring管理器统一扫描SPI结果把Provider包装成Spring Bean再暴露出去。绝大多数业务系统根本不需要把每个接口都SPI化只有在“多实现方独立扩展”的场景才值得引入。别为一个不确定的扩展点盲目上SPI控制复杂度才是第一优先级。写了这么多分享一点个人经验。这些年我在内部中间件里把“哪些功能可以做SPI”列成了一张检查清单第一接口是否足够稳定不会频繁变签名第二是否真的有多方独立实现而不是只有理论上的可能第三实现方是否需要Spring容器之外的上下文。满足这三点再动手。真正用起来以后最直观的收益是新接入方不再需要核心包发版只要实现模块带好services文件主程序一行代码都不用改。但接手维护时也要做好心理准备SPI实现藏得深排查成本比直接看if-else高这时候模块文档和实现登记表就是救命的东西。如果你们团队也在纠结某个扩展点要不要上SPI我的建议是——先看这个接口会不会出现第二个实现者不会就老老实实写装配代码会再考虑SPI别让框架复杂度跑在业务前面。