ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java SPI机制详解:ServiceLoader源码解析与应用实战

Java SPI机制详解:ServiceLoader源码解析与应用实战 面试或者技术讨论里只要提到 SPI经常能看到两种完全不同的反应懂硬件的人会下意识想到 SPI 串行总线协议玩单片机的朋友脑子里全是 MISO、MOSI、SCLK、片选信号而做 Java 后端的人马上会想到ServiceLoader、META-INF/services、JDBC 驱动加载。同样是三个字母意思差了十万八千里。本文说的是 Java 界的 SPI也就是 Service Provider InterfaceJDK 内置了一套服务发现机制专门解决接口定义好了但实现类要留给别人在运行时提供这类问题。这套机制在 JDBC 驱动加载、日志门面绑定、Spring 的自动配置里都有应用凡是搞 Java 开发的人都会在工作中碰到它。而且它还是面试题里的常客很多候选人能背出ServiceLoader.load()这句话但问深一层就卡住了——配置文件到底怎么放加载过程为什么是延迟的多个实现类怎么控制顺序这些细节背后全是源码和设计层面的考虑。这篇文章我会从最基础的概念讲起把ServiceLoader的源码拆开看一遍再带你手写一个能跑的完整示例最后聊聊生产环境里那些真实会踩的坑。内容面向对 Java 有一定基础、想搞懂 SPI 机制的开发者如果你正在准备面试最后还有一章专门讲面试官想听什么。1. 先分清两个SPIJava扩展机制不是硬件串行总线1.1 从一张电路板说起的撞名事故在开始聊 Java SPI 之前我必须先把这个歧义说清楚。硬件领域的 SPI 是 Serial Peripheral Interface一种高速全双工同步串行通信总线由 Motorola 在 1979 年定义后来在嵌入式领域遍地开花。它靠四根线工作SCLK 提供时钟、MOSI 主机发数据、MISO 从机回数据、CS 负责片选设备。芯片之间交换数据、读写 Flash、驱动屏幕全靠这套协议。而 Java 里的 SPI 是 Service Provider Interface直译过来是服务提供者接口。它跟总线协议没有半点关系是一套接口定义与实现分离的机制。名字相近纯属巧合如果你去网上搜SPI 时序图 搜到的全是电气波形图搜Java SPI出来的全是ServiceLoader和配置文件。这篇只聊 Java SPI。毕竟项目标题写的是 JAVA SPI 机制如果读者真正想解决的是单片机 SPI 通信问题那得去看 GPIO 配置和时钟树的文章看到这里可以先退出了。1.2 SPI机制的核心思想把实现选择权交出去Java 里做接口与实现解耦很多人第一反应是写一个工厂类在工厂里switch或者if-else选择具体实现public class LoggerFactory { public static Logger create() { String type System.getProperty(log.type, console); if (console.equals(type)) { return new ConsoleLogger(); } else if (file.equals(type)) { return new FileLogger(); } return new ConsoleLogger(); } }这段代码的问题是每加一个实现你都要改工厂类。如果这个工厂类属于框架层而你希望框架的用户自己扩展实现那工厂根本没法提前写上——编译期就不知道用户要写什么类。SPI 就是冲着这个问题来的。它的思路是接口定义方只负责定义接口和调用逻辑实现方通过一个固定约定把实现类登记到配置里JDK 在运行时扫描配置、加载实现类完成装配。调用方完全不需要硬编码。用一个生活化的类比插座是接口它规定了火线、零线、地线的位置和电压但插座不知道你今天会插什么电器。电器厂商只要按标准做插头就可以随时接入。SPI 就是这个标准插头协议之前在开发里做插件系统、可替换的存储层、多套依赖环境的隔离用的都是这套思路。1.3 SPI与API的本质区别一个被调用一个被实现这两个词的对比在很多资料里都会出现。API 是 Application Programming Interface接口提供方把能力封装好调用方直接拿来用实现方是接口提供方自己。SPI 则是反过来接口提供方只规定了一个坑位谁来填坑由外部决定实现方是第三方接口方手里只有约定。更直接一点API 的代码是你写的、你调用的SPI 的代码是你写的、别人实现的然后你的代码反过来调用别人的实现。比如java.sql.Driver是 JDK 里的 SPI 接口JDK 只定义了规范Oracle 显然不可能替 MySQL 实现驱动。MySQL 驱动 jar 带一个META-INF/services/java.sql.Driver配置文件JDK 的DriverManager在启动时通过 SPI 找到 MySQL 的驱动类。这就是标准的使用场景。还有一层区别很多文章没讲透API 的链接是编译期的调错了方法编译器会直接报错SPI 的链接是运行期的配置里写错一个类名编译期完全感知不到只有跑到加载那一步才会炸而且炸出来的是很难看的ServiceConfigurationError。这一点在后面讲踩坑的时候会细说。2. ServiceLoader源码拆解一行代码背后发生了什么2.1 入口ServiceLoader.load()到底做了什么ServiceLoader是 JDK 自带的服务加载器位于java.util包JDK 6 引入。用起来非常简单ServiceLoaderMyInterface loader ServiceLoader.load(MyInterface.class); for (MyInterface impl : loader) { impl.doSomething(); }但这行简单的代码背后隐藏了好几个设计决策。先看load方法的源码逻辑不同 JDK 版本略有差异这里以 JDK 11 后的实现为准public static S ServiceLoaderS load(ClassS service) { ClassLoader cl Thread.currentThread().getContextClassLoader(); return new ServiceLoader(reflection.getCallerClass(), service, cl); }关键点有两个第一它默认使用线程上下文类加载器Thread Context Class Loader而不是ServiceLoader自己的类加载器。这个细节在类加载器隔离严格的环境里很重要比如应用服务器会把不同应用的 jar 放到不同类加载器里如果你在某个类里直接new ServiceLoader()它可能找不到别的模块里的实现类。SPI 特意采用线程上下文类加载器就是为了打破父委托模型的限制让当前线程能访问到应用层的类。第二构造ServiceLoader对象本身不需要立刻读配置、加载类。它只是先初始化内部结构真正的加载操作发生在迭代器被调用的时候。这个懒的设计是 SPI 性能的关键。2.2 LazyIterator延迟加载的关键设计ServiceLoader内部有一个LinkedHashMapString, S providers做缓存还有一个懒加载迭代器LazyIterator。providers保存已加载并实例化的实现类key 是类名value 是实例。如果多次调用iterator()只要缓存里有就直接返回不会重复加载。真正读取配置文件的代码集中在LazyIterator.hasNextService()里核心步骤是这样的private boolean hasNextService() { if (nextName ! null) { return true; } if (configs null) { try { String fullName PREFIX service.getName(); if (loader null) { configs ClassLoader.getSystemResources(fullName); } else { configs loader.getResources(fullName); } } catch (IOException x) { fail(service, Error locating configuration files, x); } } while ((pending null) || !pending.hasNext()) { if (!configs.hasMoreElements()) { return false; } pending parse(service, configs.nextElement()); } nextName pending.next(); return true; }这里PREFIX就是META-INF/services/service.getName()是接口的全限定名。合起来就是它去META-INF/services/目录下找以接口全限定名为文件名的配置文件。注意getResources读的是所有匹配该路径的资源不是只读一个。在多个 jar 都包含同名文件时会返回一组URL逐个解析。parse方法负责读取文件内容按行解析每一行去掉注释和空格后就是一个实现类的全限定名。类名解析出来后并不立刻加载而是存在nextName里。真正执行Class.forName和newInstance发生在nextService()里private S nextService() { if (!hasNextService()) { throw new NoSuchElementException(); } String cn nextName; nextName null; Class? c null; try { c Class.forName(cn, false, loader); } catch (ClassNotFoundException x) { fail(service, Provider cn not found, x); } if (!service.isAssignableFrom(c)) { fail(service, Provider cn not a subtype, x); } try { S p service.cast(c.getDeclaredConstructor().newInstance()); providers.put(cn, p); return p; } catch (Throwable x) { fail(service, Provider cn could not be instantiated, x); } throw new Error(); }注意Class.forName(cn, false, loader)的第二个参数是false表示执行类加载时不初始化静态代码块。这是刻意的加载类但先不触发静态初始化真正实例化的时候才调用无参构造器。于是延迟变成了两级——迭代到某个实现时才加载这个类的字节码加载完也不急着执行静态逻辑。用一句话总结这个过程配置文件是入口类名是线索加载和实例化都在迭代的瞬间发生。2.3 类加载细节Class.forName与实例化的分离很多初学者会混淆类加载和实例化。一个类从被 JVM 载入到可以 new 出对象要经过加载、链接、初始化这几个阶段。Class.forName(cn, false, loader)只做了加载和链接初始化被推迟了。而newInstance()会触发初始化执行静态代码块和构造器。这个分离的意义在于如果你在配置里写了 10 个实现类但实际只用第 1 个那剩下的 9 个只需要加载字节码、做格式校验可以不初始化、不实例化。对于启动速度有要求的场景这个差异是能感知到的。还有一点ServiceLoader要求实现类必须有公开的无参构造器。看getDeclaredConstructor().newInstance()就知道了它不接收参数构造器也必须能被反射访问到。如果你的实现类只有带参构造器不好意思SPI 加载它的时候会直接抛异常。3. 手写一个可运行的SPI实例从接口到Provider的完整链路3.1 定义接口与两个实现类理论讲再多不如自己动手跑一遍。我在这节给你一个完整的示例你可以在本地直接照抄运行。场景选一个日志接口方便理解也好扩展。先定义一个接口package com.example.log; public interface Logger { void log(String message); }再写两个实现类package com.example.log; public class ConsoleLogger implements Logger { public ConsoleLogger() { System.out.println([ConsoleLogger] constructor invoked); } Override public void log(String message) { System.out.println(console: message); } }package com.example.log; public class FileLogger implements Logger { public FileLogger() { System.out.println([FileLogger] constructor invoked); } Override public void log(String message) { System.out.println(file: message); } }如果你的项目用的是 Maven标准目录结构下应该把接口和实现类放在src/main/java/com/example/log/下。接下来的重点不在 Java 代码而在配置文件。3.2 配置文件的正确姿势文件名与全限定名在src/main/resources/META-INF/services/目录下新建一个文件文件名必须是接口的全限定名META-INF/services/com.example.log.Logger不能叫Logger或者log-provider必须是com.example.log.Logger因为源码里用的是PREFIX service.getName()getName()返回的是不带.class的全限定名。文件里每一行写一个实现类的全限定名com.example.log.ConsoleLogger com.example.log.FileLogger文件里支持#注释空行会被跳过。类名可以乱序排列加载顺序就是文件里从上到下的顺序LinkedHashMap保证了这个顺序。写完之后你的工程里应该能看到这两个东西src/main/java/com/example/log/下的接口和实现类src/main/resources/META-INF/services/com.example.log.Logger配置文件3.3 遍历加载与JDK9的Provider API写一个入口类调用它package com.example.log; import java.util.ServiceLoader; public class Main { public static void main(String[] args) { ServiceLoaderLogger loader ServiceLoader.load(Logger.class); System.out.println(--- iterate start ---); for (Logger logger : loader) { logger.log(hello spi); } System.out.println(--- iterate done ---); } }运行结果会是这样--- iterate start --- [ConsoleLogger] constructor invoked console: hello spi [FileLogger] constructor invoked file: hello spi --- iterate done ---如果你第一次接触 SPI可能会奇怪为什么配置文件里写了两个实现循环里就刚好遍历到两个对象其实不是循环驱动加载而是迭代器驱动加载——hasNext()去读配置、next()去加载和实例化循环体只是把加载结果用起来。JDK 9 开始ServiceLoader新增了stream()方法返回StreamProviderS其中Provider暴露了type()和get()两个方法。用它可以在不实例化的情况下查看类型做一些过滤ServiceLoaderLogger loader ServiceLoader.load(Logger.class); loader.stream() .filter(p - p.type().getSimpleName().contains(File)) .map(ServiceLoader.Provider::get) .forEach(logger - logger.log(filtered spi));这里.stream()只是读取配置和加载类.get()才执行实例化。如果你想在多个实现里做选择可以在拿到Provider后先看type()按名字或注解决定是否实例化避免把所有实现都 new 一遍。ServiceLoader还提供了reload()方法清空缓存下次迭代时重新读取资源。这主要用于动态部署场景比如某个类加载器里的实现被更新了你想让它重新被发现。4. 生产环境里的SPIJDBC驱动、日志门面与Spring的加载差异4.1 JDBC驱动DriverManager为什么自带驱动列表说起 Java SPI 最有名的应用JDBC 驱动加载绝对排第一。JDK 4 时代的写法是先Class.forName(com.mysql.jdbc.Driver)注册驱动然后再DriverManager.getConnection(...)。JDK 6 以后JDBC 4.0 规范引入了基于 SPI 的自动驱动注册Class.forName这一步不再是必需的。原因就在DriverManager的静态初始化块里它调用了ServiceLoader.load(Driver.class)把 classpath 里所有META-INF/services/java.sql.Driver文件中的实现类都加载并注册到内部列表。MySQL 驱动 jar 里就有这个文件com.mysql.cj.jdbc.Driver所以你只要把 MySQL 驱动 jar 放到 classpath 里DriverManager.getConnection就会自动识别它。驱动对接多个数据库时比如 MySQL、PostgreSQL、Oracle 同时出现在 classpathDriverManager会按 SPI 逐个加载getConnection时再根据 JDBC URL 协议头挑出合适的驱动。如果你需要手动控制DriverManager还有setLoginTimeout、deregisterDriver这些方法但大部分场景下自动注册就够了。面试里经常问JDBC 4.0 之后为什么不需要Class.forName了答案的核心就在 SPI。4.2 SLF4J与日志实现的SPI绑定另一个代表性案例是日志框架。SLF4J 是日志门面本身不提供日志输出能力它需要找到具体的实现logback、log4j2 等。早年 SLF4J 是靠静态绑定的方式classpath 里放哪个 binding 包就找哪个实现因为StaticLoggerBinder类在编译期就捆进 jar 了。后来以 log4j2 为代表的实现开始采用META-INF/services方式。比如 log4j2 的 API 模块里就有META-INF/services/org.apache.logging.log4j.spi.Provider它的配置文件内容不是简单的类名而是还带了一些优先级信息在运行时通过ServiceLoader加载多个Provider再按优先级选择实际实现。这个场景说明一件事SPI 不只是一个接口对应一个实现那么简单它天生支持多个实现同时存在具体选哪个由使用方自己决定。日志框架就是通过读取所有Provider再结合优先级、条件判断来选。比单纯工厂模式灵活得多。4.3 Spring的SpringFactoriesLoaderSPI思想的变体Spring 家族也有一个类似的机制叫SpringFactoriesLoader配置文件放在META-INF/spring.factories里。它跟 JDK SPI 思路一致但有几个明显的不同配置是按 key-value 形式组织的key 是接口全限定名或注解类型value 是多个实现类用逗号分隔而不是一个接口一个文件。实例化方式和时机完全由 Spring 控制不会把所有实现一股脑 new 出来而是配合条件注解如ConditionalOnClass按需装配。提供了AutoConfiguration的自动配置加载能力Spring Boot 启动时扫META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports这算是新一代的SPI 变体了。从 JDBC 到日志再到 Spring Boot你会发现整条主线都是一样的定义接口的人不知道、也不关心谁来实现运行时靠约定好的配置路径加载实现。Spring 只是把这个思想做得更强大增加了条件化、按类型分组、装配时机等能力。理解了 JDK SPI再去看 Spring 的源码会觉得非常顺。5. SPI机制的边界与易踩的坑加载顺序、类加载器与打包合并5.1 配置文件名写错与类名解析失败我在实际项目里见过太多次这个错误配置文件命名成了接口的简单类名比如Logger而不是全限定名com.example.log.Logger。这个问题编译期不会报错程序启动也不一定报错但如果有人遍历了ServiceLoader就会静默地一个实现都找不到。另一个相关问题是类名写错。配置文件里写com.example.log.ConsoleLogger多了空格虽然代码会 trim或者com.example.log.ConsoleLogerr拼写错误那么启动时不报错只有迭代到这一行时才会抛ServiceConfigurationError。这是因为加载被推迟到了nextService()配置文件本身不会在load()时被校验。排查这类问题的方法很直接先确认resources目录下有没有META-INF/services目录再确认文件名是接口全限定名最后看文件里的类名在编译后的target/classes里存不存在。一个容易忽略的细节IDE 有时候不把resources下的空目录打进target你可以检查target/classes/META-INF/services/目录是否存在、配置文件有没有被拷贝过去。5.2 多实现时的顺序与优先级没有内置的WeightJava SPI 规范里没有任何优先级机制多个实现的遍历顺序就是配置文件里行的顺序。如果你把类名写在同一个文件里谁在上谁先被加载如果分散在多个 jar 里JVM 返回资源的顺序受getResources的枚举顺序影响而这个顺序并不保证稳定。这就带来一个实际困扰框架如果需要默认实现和扩展实现并存完全靠配置顺序来区分是非常脆弱的。因为打包工具、类路径顺序、依赖解析顺序都可能改变资源配置。所以我建议业务代码里不要依赖顺序即优先级要做这层控制就用自己的注解或元数据标记优先级加载后排序。那个 log4j2 的例子就很好它在配置里带了优先级字段而不是只写类名。配置文件排序不稳定这个问题在面试的延伸题里也常出现如果你的回答能提到上面这一点面试官通常会觉得你真的踩过坑。5.3 类加载器不匹配应用服务器里的找不到实现前面提到ServiceLoader.load()默认用Thread.currentThread().getContextClassLoader()。这个设计在普通 Java 应用里没问题但在应用服务器、OSGi、插件化系统里就可能变成噩梦。举个例子Tomcat 的 Web 应用里每个应用有自己的WebAppClassLoader如果你的 SPI 接口类由容器类加载器加载而实现类在应用自己的 jar 里仅仅依赖线程上下文类加载器可能找不到实现类。更隐蔽的情况是同一个接口被不同类加载器各加载了一次导致isAssignableFrom判断失败抛出的错误信息是 Provider not a subtype。常见的规避思路是把ServiceLoader.load(接口, 明确的ClassLoader)这个重载方法用起来显式传入实现类所在的类加载器。比如在框架里通常用自己 jar 的包路径去找Class.forName在 Web 应用里可以用Thread.currentThread().getContextClassLoader()之后确认一下加载器之间的关系。不过这种问题往往没有通解得结合具体容器去调。5.4 Maven打包时META-INF/services文件被覆盖这个坑我觉得很值得单独写。当一个工程引用多个依赖而每个依赖都带META-INF/services/com.example.log.Logger这个路径的文件时普通的maven-shade-plugin默认行为可能会让后面的文件覆盖前面的导致你只看到最后被打进 jar 的那份配置丢失其他实现类的声明。正确做法是在shade插件里配置ServicesResourceTransformerplugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ServicesResourceTransformer/ /transformers /configuration /plugin这个 transformer 会把多个META-INF/services/下同名文件的内容合并成一份而不是覆盖。打包工具里spring-boot-maven-plugin和assembly插件也都有对应的合并策略只是名字不一样。如果线上运行 SPI 找不到实现先想想打包这一步有没有合并过。6. 面试官问SPI时到底想听什么6.1 从知道到看过源码的四个层次我自己在面试里问到 SPI 相关问题时并没有期待候选人把ServiceLoader的源码背出来。我通常根据回答判断他处在哪个层次第一层能说出 SPI 是 Service Provider Interface用ServiceLoader加载配置文件在META-INF/services下。这一层说明他知道有这个东西但是很可能只是背了八股。第二层能说清 SPI 和 API 的区别能举出 JDBC 驱动的例子知道Class.forName不再是必须的。这一层说明他理解这套机制解决的实际问题。第三层能说出加载过程是延迟的ServiceLoader内部有缓存配置文件按行解析实现类必须有无参构造器。这一层基本可以确定他看过源码或者至少在真实项目里用过。第四层能聊到SpringFactoriesLoader和 JDK SPI 的差异能指出优先级缺失、类加载器问题、打包合并问题说明他遇到过真实场景。这一层就是加分项了。大多数候选人在第二层到第三层之间。如果你想准备得深一点把前面几章讲到的细节自己捋一遍面试基本不会卡壳。6.2 两个经典的追问SPI与API的区别、延迟加载是如何做到的这两个问题出现频率最高我再展开一下参考回答。问 SPI 和 API 的区别时思路不要只停留在一个被调用、一个被实现。可以结合代码讲API 的调用方依赖接口定义方编译期就绑定了SPI 的接口定义方反而要依赖实现方但这个依赖是运行时才建立的通过配置文件来解耦。你还可以补充一句SPI 接口通常由框架作者定义实现类由插件作者提供所以 SPI 是框架反向控制插件的手段。问延迟加载是怎么做到的时候不要只说它用了 LazyIterator。可以按这个顺序拆解ServiceLoader.load()只创建了加载器对象没有读文件迭代器调用hasNext()时才通过getResources读配置读到的只是类名字符串没有立即加载next()执行时先Class.forName加载类再用无参构造器newInstance创建实例创建结果放进LinkedHashMap缓存。整个链路里只有你真正去遍历它那些实现类才会被加载和实例化。如果把这两个问题讲通面试官下一步很可能追问Class.forName和ClassLoader.loadClass的区别或者问你有没有用过ServiceLoader.stream()。这些细节在前面都已经覆盖到了。最后说点个人感受。SPI 机制本身并不复杂它本质上就是接口 配置文件 反射加载三件套。但真正能体现一个程序员功底的是他在什么场景下会想到用 SPI而不是用if-else硬编码以及配置和打包出了问题之后能不能快速定位。我在一个多模块项目里接过一个需求要给消息推送模块加一个新的渠道同时又不能动主程序代码。我第一反应就是定义MessageChannel接口然后把每个渠道实现做成独立的 jar通过 SPI 注册。后来加第四个渠道的时候只写了一个新类、一行配置、一个构建模块主程序零改动。这就是 SPI 机制让我最爽的一次使用体验也是我一直建议团队在新模块设计时考虑 SPI 的原因。
返回列表