先交代一个背景我最早对SPI产生兴趣不是因为看了哪篇源码解析而是因为在接手一个老项目时被ClassNotFoundException折磨了一下午。业务方反馈功能偶发失效日志里清清楚楚写着找不到某个实现类但代码里明明已经引入了对应的jar包。后来排查下来问题出在META-INF/services目录下的一个配置文件没被打进最终产物里。从那时起我才意识到SPI这套机制在Java生态里远比自己想象得更底层、更重要。它藏在JDBC驱动的自动加载里藏在Slf4j的日志桥接里藏在Spring Boot的自动装配里也藏在Dubbo这种重型框架的扩展点设计里。很多Java开发者写了两三年业务代码可能每天都在用SPI却从来没点进去看过ServiceLoader的源码。这篇文章就围绕Java SPI机制把它的来龙去脉、实现原理、实战经验和常见坑位一次讲透后面准备面试或者做框架设计的同学可以重点参考。1. SPI机制是什么以及它到底要解决什么问题1.1 从一次接口到底由谁实现的争论说起先聊一个场景。假设你在做一套电商订单系统需要对接外部积分服务。常规做法是先定义一个接口比如PointsService然后在自己的工程里写一个DefaultPointsServiceImpl。这个接口的定义方、实现方都在你手里用起来没任何问题。但假如场景换一下接口定义方是某个框架的作者比如Tomcat的开发者而实现方是各个数据库驱动厂商Tomcat自己根本不知道未来会有多少个数据库厂商、每个厂商的驱动类名是什么。这时候接口定义方和实现方完全解耦了甚至可以说实现方根本不受接口定义方控制。再极端一点像SLF4J这种日志门面它的接口是固定的但底层实现既可能是Logback也可能是Log4j2具体用哪个完全取决于classpath里放了哪个jar包。这些场景背后都有一个共同诉求接口定义方不希望在代码里硬编码实现类的类名而是希望运行时能自动发现并加载合适的实现。Java SPI机制就是JDK原生提供的一套解决方案全称叫Service Provider Interface它的工作方式本质上就三步接口定义方在jar包的META-INF/services目录下放一个配置文件文件名是接口的全限定名文件内容是接口实现类的全限定名调用方用ServiceLoader去扫描加载框架代码通过ServiceLoader拿到实现类实例后再调用。这套机制听起来简单但它是Java模块化、插件化体系里非常基础的一块拼图。1.2 SPI和API到底差在哪很多初学SPI的人会卡在一个概念上它跟API有什么本质区别我自己在带团队的时候也发现不少人能说出SPI就是服务提供者接口这句话但让他用自己的话解释清楚就说不利索了。API这个概念接口的定义方就是接口的调用方。你提供一个接口别人按你定义的规则来实现最终这个实现是服务于你的。比如你写了一个工具类库对外暴露了一个排序接口使用这个库的人需要传入一个比较器这个比较器的实现者按照你的接口规则办事但接口的归属权在你手里。SPI恰好相反。接口的定义方是服务的使用方但接口的实现方是独立的第三方。比如JDBC这套标准java.sql.Driver这个接口是Sun现在的Oracle定义的但实现它的MySQL驱动、PostgreSQL驱动、Oracle驱动全都是各个数据库厂商自己写的。接口归属权在JDK手里但实现对JDK来说完全是黑盒。这就是为什么ServiceLoader的配置文件名必须用接口的全限定名——JDK自己也不知道该去哪里找实现只能靠这个约定好的文件名去扫描。用大白话总结API是我要实现你的接口让你调用我SPI是我定义接口你来实现我在运行时发现你并调用你。一个是别人写实现来配合你一个是别人写实现来被你发现。1.3 没有SPI之前Java开发者是怎么活的现在的Java开发者可能觉得SPI这种自动发现机制理所应当但回到JDBC还是Class.forName显式加载驱动的年代代码长什么样呢Class.forName(com.mysql.jdbc.Driver); Connection conn DriverManager.getConnection(url, username, password);这一行Class.forName就是典型的硬编码——数据库驱动类名被写在业务代码里。如果要从MySQL切换到PostgreSQL光改url还不够还得改这一行类名。每个接入业务系统的开发人员都可能忘记加载驱动而驱动没加载时DriverManager.getConnection只会抛出一个让人摸不着头脑的No suitable driver found异常。在JDBC 4.0之前这就是行业常态。每个数据库厂商的驱动要生效都得靠业务方主动写一行加载代码。这在单体应用时代还能忍但到了中间件、框架大量出现的时代这种硬编码方案就完全撑不住了。框架作者希望业务方只需要引入一个jar包什么额外代码都不写底层就能自动把该加载的东西全部加载好。SPI就是在这种情况下被纳入JDK标准体系的。JDBC 4.0之后驱动jar包里只要带着META-INF/services/java.sql.Driver这个文件DriverManager初始化时就会通过ServiceLoader自动加载所有驱动类Class.forName那行代码就变成了历史。2. 源码详解ServiceLoader到底是怎么工作的2.1 先看整体架构再钻进细节java.util.ServiceLoader是JDK自带的SPI加载器它的核心逻辑可以拆成三块配置扫描、懒加载迭代、缓存复用。用官方一点的描述ServiceLoader.load(ClassS service)做的事情是根据传入的接口类型去classpath下所有的jar包里扫描META-INF/services/接口全限定名这个文件读取文件中的实现类全限定名然后通过反射实例化。但这里有几个容易忽略的细节不看源码根本发现不了。先说懒加载。ServiceLoader.load()返回的其实是一个ServiceLoader实例这个实例在创建时并不会真的去扫描所有classpath也不会实例化任何实现类。真正的工作发生在你调用iterator()开始遍历的那一刻。这也是为什么很多框架在启动时加载了一堆SPI配置但JVM内存看不出明显增长——因为很多实现类是等第一次真正被用到时才实例化的。再说缓存。同一个ServiceLoader实例的iterator()方法可以多次调用但被实例化过的类不会重复创建。ServiceLoader内部有一个LinkedHashMapString, Class?缓存记录的是实现类名到Class对象的映射实例化后还会把它缓存到LinkedHashMapString, S里。所以在一个ServiceLoader实例的生命周期内同一个实现类只会被创建一次。2.2 核心方法的逐行解读我直接贴一段简化后的核心代码方便理解。public IteratorS iterator() { return new IteratorS() { // 当前正在处理的providers迭代器 IteratorMap.EntryString, Class? knownProviders providers.entrySet().iterator(); public boolean hasNext() { if (knownProviders.hasNext()) { return true; } // lookupIterator是真正扫描配置文件的迭代器 return lookupIterator.hasNext(); } public S next() { if (knownProviders.hasNext()) { return providers.get(knownProviders.next().getKey()).get(); } return lookupIterator.next(); } }; }这里有个值得玩味的顺序问题每次遍历时先返回已缓存过的provider再扫描新的配置文件。这意味着如果你在同一个ServiceLoader实例上先取了一次实现类之后又往classpath里加了新的jar包新加的实现类能被发现但位置永远排在已经实例化过的类后面。再看真正负责扫描配置文件的lookupIterator它的hasNext()方法做了这样几件事。public boolean hasNext() { if (nextName ! null) { return true; } if (configs null) { // 1. 拼接配置文件路径 String fullName PREFIX service.getName(); // 2. 用当前线程的context class loader加载配置 if (loader null) { configs ClassLoader.getSystemResources(fullName); } else { configs loader.getResources(fullName); } } // 3. 读取配置文件中每一行解析出实现类名 while ((pending null) || !pending.hasNext()) { if (!configs.hasMoreElements()) { return false; } pending parse(configs.nextElement()); } nextName pending.next(); return true; }第一处关键点是PREFIX它的值是META-INF/services/。这个前缀在JDK里是硬编码写死的所以SPI配置文件的位置没有任何商量的余地你必须放在这个目录下面文件名必须是接口的全限定名。第二处关键点是加载器用的是ClassLoader.getResources而不是getResource。单数和复数的区别很大getResource只返回第一个匹配项getResources会返回classpath下所有匹配项的枚举。这保证了当多个jar包里都有同一个接口的配置文件时所有实现类都能被发现。parse方法的逻辑也不复杂按行读取配置文件用#作为注释标识按空白字符切分过滤掉空行和无效类名最终得到一个实现类名的迭代器。2.3 实例化过程与异常处理的几个特殊点当迭代器拿到实现类名之后会调用nextService()方法完成实例化这里有几个值得留意的设计选择。private S nextService() { String cn nextName; nextName null; Class? c null; try { c Class.forName(cn, false, loader); } catch (ClassNotFoundException x) { fail(service, Provider cn not found); } if (!service.isAssignableFrom(c)) { fail(service, Provider cn not a subtype); } try { S p service.cast(c.getDeclaredConstructor().newInstance()); providers.put(cn, p); return p; } catch (Throwable x) { throw new ServiceConfigurationError(...); } }注意Class.forName(cn, false, loader)这里的第二个参数传的是false意味着只加载类并链接不初始化静态块。所以SPI加载过程不会主动触发实现类的静态代码块static block只有等到真正创建实例时类的初始化阶段才会执行。另外这里对实现类的类型校验很严格实现类必须是接口的子类型否则直接抛ServiceConfigurationError这个异常是Error的子类而不是Exception。这意味着如果你配置了一个不相关的类程序会直接崩溃而不会让你有机会用try-catch把它吞掉。有些框架顶层会捕获这个错误做降级处理但如果你是自己直接调用ServiceLoader遇到这个错误基本就是配置写错了。最后实例化要求实现类必须有无参构造方法因为反射用的是getDeclaredConstructor().newInstance()它默认调用的就是无参构造器。如果你的实现类只有带参数的构造方法SPI会直接实例化失败。3. 实战案例拆解JDBC、Spring、Dubbo里的SPI应用3.1 JDBC驱动自动注册最经典的官方实践JDBC从4.0开始引入SPI机制之后驱动加载路径跟以前完全不同了。现在你引入一个MySQL驱动jar包配置好数据源驱动程序就能自动被注册到DriverManager里。要想理解这个自动化过程建议直接把mysql-connector-java这个jar包解压开看一眼。提示把本地的mysql驱动jar包复制出来改成zip后缀解压进入META-INF/services目录你能看到一个名为java.sql.Driver的文件。用文本编辑器打开内容通常就一行com.mysql.cj.jdbc.Driver。这整个机制的核心竟然就是这一行文本。再看JDK这边java.sql.DriverManager的静态初始化代码块长这样static { loadInitialDrivers(); println(JDBC DriverManager initialized); } private static void loadInitialDrivers() { ... ServiceLoaderDriver loadedDrivers ServiceLoader.load(Driver.class); IteratorDriver driversIterator loadedDrivers.iterator(); while(driversIterator.hasNext()) { Driver driver driversIterator.next(); // 注册到DriverManager的synchronized集合里 } ... }所以运行时的实际路径是DriverManager静态初始化 →ServiceLoader.load(Driver.class)→ 扫描所有jar包的META-INF/services/java.sql.Driver→ 找到驱动类 → 实例化并注册。整个过程不需要业务代码参与。这里有个特别容易踩的坑如果你的应用依赖了多个数据库驱动jar包比如同时引入了MySQL和Oracle的驱动那么ServiceLoader会加载所有实现类DriverManager里会注册多个Driver。DriverManager.getConnection在建立连接时会遍历所有已注册的Driver依次尝试连接直到某个Driver能成功处理这个URL为止。这就是为什么当classpath里同时存在MySQL驱动和另一个数据库驱动时如果连接URL写错了驱动前缀系统不会立刻报错而是逐个尝试后统一抛找不到合适的驱动。搞清楚这一点数据库连接排查会省很多时间。3.2 Spring Boot的SpringFactoriesLoaderSPI机制的变体Spring Boot的自动装配也用到了类似SPI的思路但实现方式更灵活。它扫描的文件是META-INF/spring.factories或META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports里面按Key-Value方式配置了一系列自动配置类的类名。对比一下JDK SPI是一个接口对应一个固定路径的配置文件而Spring Boot的SpringFactoriesLoader用的是一个统一配置文件配置内容是键值对相当于在一个文件里塞了很多组SPI关系。这种设计避免了每个接口都要扫一次配置文件的性能开销代价是配置文件的可读性差点而且不同框架如果都往spring.factories里写配置彼此之间会有一定的命名空间冲突风险。实操心得排查Spring Boot启动时某个自动配置有没有生效可以直接看META-INF/spring.factories里有没有对应配置再用--debug启动参数看自动配置报告。很多网上帖子说配置没生效最后查下来都是因为jar包里压根没有这个文件或者是打的fat jar把META-INF下的目录给过滤掉了。3.3 Dubbo的ExtensionLoaderSPI机制的商业级改良Dubbo的扩展机制以JDK SPI为基础但进行了相当大幅度的增强。如果你面试问的是Dubbo为什么不用JDK原生SPI这其实是一个很能体现深度的回答点。JDK原生SPI有几个短板第一加载不到指定实现只能全量遍历第二不支持扩展点排序和依赖注入第三实例化时机不可控迭代到谁就实例化谁。Dubbo的ExtensionLoader在配置上就做了改进它扫描的是META-INF/dubbo和META-INF/dubbo/internal目录配置文件的内容也变成了Key-Value格式dubboorg.apache.dubbo.rpc.protocol.dubbo.DubboProtocol这样每个实现类都配了一个名字使用时可以通过名称精确获取指定实现。同时ExtensionLoader的getExtension(String name)方法支持缓存同一个扩展点只实例化一次。再加上Adaptive注解实现的方法级自适应选择Activate注解实现的条件化激活这些能力已经远超JDK SPI的定位了。从这个对照可以看出来JDK SPI是解决能不能自动发现的问题而Dubbo SPI解决的是在复杂框架环境下怎么高效管理大量扩展点的问题。两者不在同一层抽象上但后者显然是以前者为基础的。4. 从零手写一个基于SPI的支付网关插件4.1 需求与工程结构设计理论说了一大堆不如亲手做一遍。我设计了一个小项目让大家能直观感受到SPI机制从定义接口到加载实现的完整过程。需求描述做一个支付网关SDK支付渠道的接入要支持插件化。今天对接支付宝明天通过PayService接口对接微信支付还要能对接海外支付渠道但SDK的调用方代码不能因为渠道变化而改动。这个需求用SPI非常合适。工程结构分成两个模块pay-spi-demo/ ├── pay-api/ // 接口定义模块定义了支付抽象接口 │ ├── src/main/java/com/example/pay/api/PayService.java │ └── src/main/resources/META-INF/services/com.example.pay.api.PayService └── pay-provider-alipay/ // 阿里巴巴支付渠道实现模块 ├── src/main/java/com/example/pay/alipay/AlipayPayService.java └── src/main/resources/META-INF/services/com.example.pay.api.PayService这个设计的巧妙之处在于pay-api是接口契约模块真正被业务方依赖pay-provider-alipay是渠道实现模块业务方只要把它引入classpath就自动拥有了支付宝渠道的支付能力。新增渠道时不需要修改pay-api的任何代码只需要新增一个provider模块。4.2 接口定义与配置文件书写细节先看一下PayService接口package com.example.pay.api; public interface PayService { /** * 发起支付 * param orderId 订单号 * param amount 金额单位分 * return 支付流水号 */ String pay(String orderId, long amount); /** * 查询支付状态 * param tradeNo 支付流水号 * return 支付状态 */ String queryStatus(String tradeNo); }配置文件路径是pay-api模块的src/main/resources/META-INF/services/com.example.pay.api.PayService内容如下com.example.pay.alipay.AlipayPayService这里要重点强调一下文件的命名规则META-INF/services/后面跟的必须是接口的全限定名不是实现类的全限定名也不是简写。我见过很多同学第一次做SPI配置时把这个写错然后折腾半天找不到原因。如果配置接口名和实际接口不匹配ServiceLoader扫描到的结果是空不会报任何错只在遍历时返回false。4.3 实现类、加载代码与运行结果接下来实现支付宝渠道package com.example.pay.alipay; import com.example.pay.api.PayService; public class AlipayPayService implements PayService { static { System.out.println([AlipayPayService] 静态初始化块执行); } public AlipayPayService() { System.out.println([AlipayPayService] 构造方法执行); } Override public String pay(String orderId, long amount) { return alipay_trade_ orderId _ amount; } Override public String queryStatus(String tradeNo) { return PAID; } }业务方获取支付渠道的代码如下这也是使用ServiceLoader的标准写法package com.example.pay.client; import com.example.pay.api.PayService; import java.util.ServiceLoader; public class PayClient { public static void main(String[] args) { ServiceLoaderPayService loader ServiceLoader.load(PayService.class); for (PayService payService : loader) { System.out.println(加载到实现类: payService.getClass().getName()); String tradeNo payService.pay(20250101001, 100L); System.out.println(支付流水号: tradeNo); System.out.println(支付状态: payService.queryStatus(tradeNo)); } } }运行main方法后控制台输出如下[AlipayPayService] 静态初始化块执行 [AlipayPayService] 构造方法执行 加载到实现类: com.example.pay.alipay.AlipayPayService 支付流水号: alipay_trade_20250101001_100 支付状态: PAID4.4 代码验证与观察要点在这里我建议你做一个小改造来验证前面源码解析的正确性把配置文件里的实现类注释掉只保留一个空文件再看运行结果。你会发现ServiceLoader返回的迭代器hasNext()直接是falsefor循环里的代码一行都没执行但程序正常退出不报错。这种静默失败的特性正是SPI配置错误难排查的根本原因。再做一个测试看Class.forName(cn, false, loader)的懒加载特性。在AlipayPayService里加上一个静态代码块打印语句然后修改主程序只调用loader.iterator()但不调用next()。你会发现没有任何输出。只有当真正走到for循环、触发next()时静态代码块才会执行。这个实验直观展示了SPI实例化的真正时机。实测现象只执行ServiceLoader.load()不遍历实现类的静态代码块不会执行要想让多个实现类同时生效按优先级选择抱歉JDK原生SPI不支持排序。配置文件里的行顺序有一定作用但并不能保证在多jar包场景下严格有序。真正要做优先级控制要么像Dubbo一样改成键值对形式自己解析要么在遍历时手动过滤。5. 避坑指南SPI实践中的常见问题与排查技巧5.1 实现类加载不到的5个典型原因我在实际开发和答疑过程中汇总了最常见的一批SPI问题整理成一个速查表供参考现象可能原因解决方案迭代器为空遍历不到任何实现配置文件路径不对或文件名不是接口全限定名检查META-INF/services/目录jar包解压后看文件是否存在抛ServiceConfigurationError: xxx not a subtype配置文件里写了一个不实现该接口的类确认实现类声明了implements对应接口抛NoClassDefFoundError或ClassNotFoundException实现类自身依赖的第三方库缺失检查依赖传递确认实现类引用的类都在classpath上实例化异常NoSuchMethodException实现类没有无参构造方法给实现类补上无参构造器多个实现类加载顺序不可控配置文件顺序 classpath扫描顺序共同决定如有优先级要求手动过滤或改用Dubbo SPI方案5.2 类加载器视角下的坑父委托模型与线程上下文加载器这是SPI最容易被忽略的一个技术细节但越是这种细节越能体现功底。ServiceLoader默认使用当前线程的Thread.currentThread().getContextClassLoader()来扫描配置文件。为什么要用线程上下文加载器因为SPI机制本身就面临一个类加载器的困境很多SPI接口定义在JDK核心类库中比如java.sql.Driver但实现类在第三方jar包里。按照Java的双亲委派模型JDK核心类库中的类由Bootstrap ClassLoader加载它根本不可能加载classpath下的第三方类。如果没有线程上下文加载器打破双亲委派SPI机制永远无法工作。部分。这个机制保证了JDK核心代码里也能通过SPI加载到第三方实现。如果你在自己的框架里禁用了线程上下文加载器或者某个线程池复用了未被正确初始化的线程就可能出现SPI加载不到的问题。遇到这种情况排查思路是确认线程上下文加载器是否为null或是否指向了错误的ClassLoader。个别SSH框架环境里这类问题常被归因为依赖冲突实际上是由于类加载器隔离导致的。5.3 多实现类的选择策略ServiceLoader不帮你做决定如果你配置了多个实现类比如支付接口既有支付宝实现又有微信实现ServiceLoader会依次返回所有实现类。但你最终要用哪个JDK SPI并不关心也提供不了任何决策机制。常见做法有三种。第一全量遍历后根据某个标识筛选比如实现类有个channel()方法返回渠道名。第二用有限的迭代次数只取第一个。第三借助ProviderT这个内部类按需获取。ServiceLoader从Java 9开始提供了stream()方法和findFirst()方法可以惰性地获取第一个实现ServiceLoaderPayService loader ServiceLoader.load(PayService.class); PayService payService loader.stream() .map(ServiceLoader.Provider::get) .filter(p - alipay.equals(p.getChannel())) .findFirst() .orElse(null);但用findFirst()的前提是你确信配置文件中第一个实现就是你要的。实际打包过程中各种依赖顺序可能变化所以生产级代码不建议依赖这个方式做路由决策还是建议定义明确的筛选规则。5.4 构建工具、打包与模块化带来的新麻烦Maven项目里做SPI有个重复踩的坑配置文件放到src/main/resources下时Maven的resources插件默认会原样拷贝到target/classes目录。这看起来没问题但如果你用了Maven shade插件打fat jar再或者做了资源过滤开启filtering就可能导致META-INF/services文件被意外处理——要么没合并要么过滤掉了某些行。多个jar包同时提供同一个接口的SPI配置也很常见。标准做法是把配置文件合并但普通jar包打之前你需要确保每个模块自己的META-INF/services文件只声明了当前模块的提供类。如果把所有实现类都集中在某个主模块的配置里那么该模块没被依赖的时候其他模块的实现就不会被加载。Java 9模块化之后ServiceLoader增加了对module-info.java的支持用provides和uses关键字声明服务。这属于新特性但要提醒一句如果你的项目还在用Maven未开启模块化且依赖了模块化jar包旧SPI扫描方式仍然有效但类加载细节会略有差别排查时需要多考虑一层。5.5 性能与内存SPI真的会拖慢启动吗这是我在一些性能敏感项目里被反复问到的。我的回答是关键在于实现类实例化的代价而不在于SPI扫描本身。ServiceLoader.load()返回后如果你不遍历它几乎不消耗什么资源做的只是暂存class引用具体来说它连配置文件都没有立刻读取。真正读取配置文件发生在第一次hasNext()调用时实际代码是读取classpath下所有jar包里匹配的文件。这个过程本质上是IO操作一个接口的配置文件通常就一两个文件读取耗时基本在毫秒级。隐患在于遍历时会实例化所有实现类。如果某个实现类的构造方法里做了重的初始化逻辑比如建数据库连接池、初始化Netty线程那一次SPI加载可能额外消耗几百毫秒甚至几秒。很多框架把这种重对象封装成懒加载代理等你真正调用时再完成初始化。如果你自己要做一个SPI加载器建议也参考这个模式避免无脑实例化所有provider。6. 手写一个简化版SPI框架加深理解6.1 为什么建议你手写一遍读源码和写代码是两种完全不同的学习路径。ServiceLoader源码看十遍不如自己动手实现一个10行代码的简易版本因为写的过程能逼你把每一步的边界条件想清楚配置文件从哪读用哪个类加载器怎么处理多jar包重复配置如何缓存实例这些一旦动手就全都变成必须回答的问题。这里我提供一个最小实现不追求完备只求把核心机制表达到位。package com.example.spi; import java.io.BufferedReader; import java.io.InputStreamReader; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.*; public class SimpleSpiLoaderT { private static final String PREFIX META-INF/services/; private final ClassT serviceType; private final ClassLoader classLoader; private final MapString, T cache new HashMap(); public SimpleSpiLoader(ClassT serviceType) { this(serviceType, Thread.currentThread().getContextClassLoader()); } public SimpleSpiLoader(ClassT serviceType, ClassLoader classLoader) { this.serviceType serviceType; this.classLoader classLoader; } SuppressWarnings(unchecked) public ListT loadAll() { ListT result new ArrayList(); String fullName PREFIX serviceType.getName(); try { EnumerationURL urls classLoader.getResources(fullName); SetString classNames new LinkedHashSet(); while (urls.hasMoreElements()) { URL url urls.nextElement(); try (BufferedReader reader new BufferedReader( new InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { int commentIdx line.indexOf(#); if (commentIdx 0) { line line.substring(0, commentIdx); } line line.trim(); if (!line.isEmpty()) { classNames.add(line); } } } } for (String className : classNames) { Class? clazz Class.forName(className, false, classLoader); if (!serviceType.isAssignableFrom(clazz)) { throw new ServiceConfigurationError( serviceType.getName() : Provider className not a subtype); } T instance (T) clazz.getDeclaredConstructor().newInstance(); cache.put(className, instance); result.add(instance); } } catch (IOException | ReflectiveOperationException e) { throw new ServiceConfigurationError(Failed to load SPI: serviceType.getName(), e); } return result; } }这个实现跟ServiceLoader的思路基本一致但省略了懒加载迭代和复杂缓存更直观。使用方式如下SimpleSpiLoaderPayService loader new SimpleSpiLoader(PayService.class); ListPayService providers loader.loadAll(); for (PayService provider : providers) { System.out.println(provider.pay(demo, 10_00)); }读完这个实现再回看JDK源码你就会发现原来的代码并不神秘核心逻辑就是读文件、解析类名、反射实例化三件事只是JDK在效率、并发、异常处理上打磨得更细致罢了。6.2 手写过程中暴露出来的3个容易忽略的问题第一个问题配置文件的编码。JDK源码里读配置时其实用的是ISO-8859-1编码这一点很多人没注意到。如果你的配置文件里出现了中文注释在这两种编码模式下解析结果可能不同。虽然实现类全限定名通常都是ASCII但注释内容建议一律使用英文避免各种环境下的编码问题。第二个问题重复配置的去重。classpath下多个jar包可能包含同名配置文件也可能多个配置文件写了同一个实现类名。我自己的实现里用了LinkedHashSet来去重ServiceLoader内部逻辑里也会做类似处理。但是要注意去重并不代表实例只创建一次。JDK的缓存是基于ServiceLoader实例的如果你创建了多个ServiceLoader实例同一个实现类会被实例化多次。比如你既在代码里手动ServiceLoader.load又有框架内部自动加载了一次就要小心这些对象是各自独立的。第三个问题异常信息。手写版本里如果配置错了抛出的ServiceConfigurationError信息比较粗糙。生产级框架会花心思打磨这些错误信息让用户一眼能定位到哪个jar包的哪个配置文件有问题。尤其是在微服务架构下一个SPI配置错误可能同时出现在几十个实例的日志里错误信息不够清晰排查起来相当痛苦。7. 面试追问与能力拔高7.1 面试官到底想通过SPI问题考察什么最近几年Java面试基本绕不开SPI尤其在问框架原理的时候。但面试官很少直接问什么是SPI更多是从具体场景切入。我整理了几个高频追问你们可以凭这些检验自己的理解深度。第一个问题JDBC驱动程序是怎么做到不用Class.forName也能自动注册的这个问题的答案需要覆盖DriverManager静态初始化块、ServiceLoader.load(Driver.class)、jar包META-INF/services/java.sql.Driver配置文件三步缺一不可。第二个问题为什么要用线程上下文类加载器这个问题问的是双亲委派模型的缺陷。核心类库加载器加载DriverManager但第三方驱动不在自己扫描路径里。需要线程上下文加载器打破模型让核心库代码能加载业务jar包里的类。第三个问题JDK原生SPI有哪些缺陷如果能切中要害答出无法精确获取指定实现类、实例化不可控、不支持排序、不支持依赖注入这几点面试官就能判断你确实有生产级框架的使用经验而不只是背诵概念。第四个问题如何在Spring Boot的机制里自定义一个starter实现类似SPI的自动配置这个不要求你马上写出完整starter但至少应该能列出spring.factories里的配置格式、ConditionalOnClass等条件注解的作用。7.2 从SPI到IOC/DI一条技术脉络SPI和Spring IOC/DI看似是两个独立话题但本质上都围绕同一件事解耦类型之间的直接依赖。SPI把接口和实现的依赖关系延迟到运行时IOC/DI则是通过容器完成对象的创建和装配。更好的理解方式是做一个对比表特性JDK SPISpring IOC/DIDubbo ExtensionLoader配置方式META-INF/services下的文件XML、注解、JavaConfigMETA-INF/dubbo下键值对文件加载时机遍历时懒加载容器启动时首次使用时是否支持按名字获取不支持按BeanName获取getExtension(name)是否支持依赖注入不支持完整支持Spring内置依赖注入典型场景JDBC驱动、日志门面Spring生态中大量组件Dubbo协议、序列化等扩展先理解SPI这条路再往上理解IOC/DI这套容器生态很多框架面试题都能迎刃而解。7.3 个人经验什么场景该用SPI什么场景不该用讲了这么多SPI的原理与实现最后聊点我在项目里自己衡量的取舍。SPI机制并不复杂复杂的是判断在什么情况下该用它什么情况下引入它是给自己找麻烦。如果你的业务代码是几十个人在维护接口的实现类总共就两三个而且短期内没有第三方接入的规划那这个场景不怎么需要SPI。直接在代码里new具体实现类或者用Spring的Autowired注入配合策略模式开发效率要高得多代码也更容易追踪。反之如果你的模块定位是框架、SDK、中间件再或者明确定义了面向第三方开放的扩展点那SPI几乎是必选项。因为你不希望业务方为了接入你的框架还要改你的源码更不希望框架代码里硬编码某个具体实现类导致后续升级时被迫做破坏性变更。还有一类比较隐蔽的场景大型团队内部不同小组各自维护实现模块但公共服务模块不希望编译期依赖它们。用SPI把接口契约单独拉出来各小组只依赖接口模块服务模块运行时通过SPI自动装配能明显降低模块之间的耦合度和你作为架构师统筹协调的成本。在工程管理方面SPI也有一个不太好宣之于众的作用它天然地划清了团队责任边界。接口是谁的、实现是谁的配置文件里写得一清二楚出问题从配置文件和模块依赖关系入手就能定位归属。我在负责一个支付中台项目时就深有体会渠道接入方越来越多了之后如果没有SPI做隔离每次新增渠道都要动底层代码测试和上线流程会变得异常沉重。最后再分享一个小技巧做SPI方案设计时不要只把配置放在META-INF/services里完事最好在框架启动日志里打印一下当前加载到了哪些SPI实现。别小看这一行日志它能在线上环境帮你节省大量排查时间。我自己就遇到过框架升级后某个SPI实现突然不再生效的诡异问题最后靠的正是启动日志里的SPI加载列表一眼看出是资源过滤把配置文件给过滤掉了。实践出真知这句话放在SPI机制的学习上再合适不过。