当前位置: 首页 > news >正文

打了 fat jar 之后 SPI 只剩一个实现:ServiceLoader 的 5 个加载边界


title: 打了 fat jar 之后 SPI 只剩一个实现:ServiceLoader 的 5 个加载边界
tags: Java,SPI,ServiceLoader,源码,类加载
category: Java


那个只在打包后复现的 bug

去年 11 月,我们把一个数据同步服务从本地 IDE 部署到测试环境,同一份代码,同一个 commit,行为却不一样。

服务里有个DataSink接口,通过 SPI 加载了 4 个实现:KafkaSinkEsSinkFileSinkMetricsSink。IDE 里跑main方法,日志打印loaded 4 sinks;打成 fat jar 扔到测试机上跑,日志变成loaded 1 sinks,而且每次都是MetricsSink——四个模块里字母序最后的那个。

最初我们怀疑是环境变量、profile、甚至怀疑是运维改了配置。折腾了大半天,直到有人把 jar 解开:

unzip -p sync-service.jar META-INF/services/com.xxx.sync.DataSink # com.xxx.sync.impl.MetricsSink

只有一行。而源码里 4 个 module 各自的src/main/resources/META-INF/services/com.xxx.sync.DataSink都是好好的。问题出在maven-shade-plugin上:它把多个 jar 合并成一个时,遇到路径相同的资源文件会直接覆盖,而不是拼接。四个模块的 SPI 配置文件路径完全一样,最后一个赢。

这个坑教科书上不会写,但凡做过插件化、做过 fat jar 部署的人,大概率都会撞一次。

先把 SPI 的最小骨架摆出来

SPI(Service Provider Interface)说白了就是三件事:一个接口、一个配置文件、一次ServiceLoader.load

// 1. 接口定义在 core 模块 public interface DataSink { String name(); void write(List<Record> records); } // 2. 实现放在各自的 module public class KafkaSink implements DataSink { private final KafkaProducer<String, byte[]> producer; // 关键:SPI 要求必须有无参构造,否则加载期直接抛异常 public KafkaSink() { Properties props = new Properties(); props.put("bootstrap.servers", System.getProperty("kafka.servers", "localhost:9092")); props.put("acks", "all"); this.producer = new KafkaProducer<>(props); } @Override public String name() { return "kafka"; } @Override public void write(List<Record> records) { for (Record r : records) { producer.send(new ProducerRecord<>(r.topic(), r.key(), r.payload())); } } }

配置文件放在src/main/resources/META-INF/services/com.xxx.sync.DataSink,内容就是全限定类名,一行一个:

com.xxx.sync.impl.KafkaSink

加载侧:

public class SinkRegistry { private final Map<String, DataSink> sinks = new LinkedHashMap<>(); public SinkRegistry() { ServiceLoader<DataSink> loader = ServiceLoader.load(DataSink.class); for (DataSink sink : loader) { // 注意:迭代时才真正实例化 sinks.put(sink.name(), sink); } log.info("loaded {} sinks: {}", sinks.size(), sinks.keySet()); } public DataSink get(String name) { DataSink s = sinks.get(name); if (s == null) throw new IllegalArgumentException("no sink named " + name); return s; } }

这段代码在 IDE 里工作良好,因为 IDE 的 classpath 是一个个独立目录,4 个 module 的META-INF/services各自独立存在,ClassLoader.getResources()能扫到 4 个 URL。打成 fat jar 后它们被压成了一个文件,只剩 1 个 URL。

ServiceLoader 源码:懒加载藏在迭代器里

很多人以为ServiceLoader.load()这一行就把所有实现类都加载好了。翻一眼 JDK 8 的源码(java.util.ServiceLoader):

public static <S> ServiceLoader<S> load(Class<S> service) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); return ServiceLoader.load(service, cl); } public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader) { return new ServiceLoader<>(service, loader); } private ServiceLoader(Class<S> svc, ClassLoader cl) { service = Objects.requireNonNull(svc, "Service interface cannot be null"); loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl; acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null; reload(); } public void reload() { providers.clear(); // providers 是一个 LinkedHashMap 缓存 lookupIterator = new LazyIterator(service, loader); }

四行构造函数里没有任何 IO,没有任何Class.forName。真正干活的是LazyIterator

private boolean hasNextService() { if (nextName != null) return true; if (configs == null) { try { String fullName = PREFIX + service.getName(); // "META-INF/services/" + 接口全名 if (loader == null) configs = ClassLoader.getSystemResources(fullName); else configs = loader.getResources(fullName); // ← 这里返回的是 Enumeration<URL> } 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 + service.getName():路径是硬编码拼出来的,所以配置文件名必须精确等于接口全限定名。我见过有人把文件名写成DataSink(少了包名)、写成com.xxx.sync.DataSink.txt(多了后缀),结果扫不到还以为是 JDK 的锅。
  • loader.getResources(fullName):注意是getResources复数,返回一个枚举。fat jar 把 4 个文件压成 1 个,这个枚举就只有 1 个元素——这正是我们那个 bug 的根。
  • parse只解析类名字符串,不加载类。

实例化在nextService()

private S nextService() { if (!hasNextService()) throw new NoSuchElementException(); String cn = nextName; nextName = null; Class<?> c = null; try { c = Class.forName(cn, false, loader); // false = 不执行静态初始化 } 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.newInstance()); // 无参构造,JDK 9+ 改为 getConstructor().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,意思是加载但不初始化。到了c.newInstance()那一步才触发静态块。这个时间差在实际中会带来一个很隐蔽的现象:某个实现类静态块里读配置、连数据库,只有当你迭代到它时才会炸。如果你的 for 循环在第 2 个实现上就 break 了,第 3 个实现的静态块永远不会执行。

还有一句需要单独拎出来:fail(...)抛的是ServiceConfigurationError,这是个Error 不是 Exception。写catch (Exception e)兜不住它。我们线上曾经因为一个实现类的构造函数里连不上 Redis,整个服务启动阶段直接挂掉,日志里只有一行ServiceConfigurationError,因为外层的catch (Exception)完全没拦住。

我们排查时走过的两条弯路

第一条弯路:怀疑 TCCL。ServiceLoader.load(Class)用的是线程上下文类加载器(TCCL)。在 Tomcat、SpringBoot fat jar、OSGi 这类有自定义类加载体系的环境里,TCCL 跟加载接口的类加载器不是同一个,确实会导致加载不到。我们花了两小时打印Thread.currentThread().getContextClassLoader(),确认是LaunchedURLClassLoader,接口和实现都由它加载,没有问题。

排查这个问题的时候有个小技巧很好用,直接把getResources的结果打出来:

Enumeration<URL> urls = Thread.currentThread().getContextClassLoader() .getResources("META-INF/services/com.xxx.sync.DataSink"); while (urls.hasMoreElements()) { log.info("SPI config found at: {}", urls.nextElement()); }

IDE 里打出 4 行file:/xxx/target/classes/...,jar 里只打出 1 行jar:file:/opt/app/sync-service.jar!/...。看到这个对比,方向就明确了。

第二条弯路:怀疑 Maven 没打进资源。我们检查了<resources>配置、检查了.gitignore有没有把META-INF忽略掉。都没问题。真正的原因是 shade 阶段的覆盖,不是打包阶段的丢失——文件在,只是内容被换了。这两种故障现象几乎一样,但排查方向完全相反。

修复只需要给 shade 插件加一个 transformer:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.5.1</version> <executions> <execution> <phase>package</phase> <goals><goal>shade</goal></goals> <configuration> <transformers> <!-- 关键:把同路径的 META-INF/services 文件按行追加合并,而不是覆盖 --> <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/> </transformers> </configuration> </execution> </executions> </plugin>

用 Gradle 的话对应的是 shadow 插件的mergeServiceFiles()。Spring Boot 的spring-boot-maven-plugin因为把依赖 jar 原样嵌在BOOT-INF/lib里、不做解压合并,反而不会踩这个坑——这也是为什么同一套代码用不同打包方式表现不一样。

几种"发现实现类"的机制,横向比一比

机制发现时机是否支持排序依赖配置文件典型使用者
JDK ServiceLoader运行时,懒加载不支持,按文件顺序META-INF/services/接口全名JDBC Driver、SLF4J 1.x
Springspring.factories启动时一次性读全不支持,可配 @OrderMETA-INF/spring.factoriesSpring Boot 2.x 自动装配
Spring Boot 3.imports启动时一次性读全同上META-INF/spring/*.importsSpring Boot 3.x
Dubbo ExtensionLoader运行时,按 name 取支持@ActivateorderMETA-INF/dubbo/接口全名Dubbo SPI
注解 + classpath 扫描启动时扫包支持各类框架的 @Component

对比下来,JDK 原生 SPI 最大的短板有两个:没有名字,没有顺序。你没法说"给我叫 kafka 的那个实现",只能全部实例化完再自己筛。Dubbo 之所以自己写了一套 ExtensionLoader,核心就是补上了 key-value 映射和@Adaptive/@Activate这些能力。

第二个短板更致命:全部实例化。前面源码里看到了,迭代过程中每个实现都会newInstance()。如果你只想用其中一个,其余三个的构造函数照样会跑——如果它们在构造函数里建连接池,那就是三份白白浪费的连接。我们后来的做法是把重资源初始化从构造函数里挪出去:

public class KafkaSink implements DataSink { private volatile KafkaProducer<String, byte[]> producer; // 不在构造函数里创建 public KafkaSink() { } // 保持空构造,只做 SPI 契约 @Override public String name() { return "kafka"; } // 真正被选中时才初始化,双检锁保证只建一次 private KafkaProducer<String, byte[]> producer() { if (producer == null) { synchronized (this) { if (producer == null) { producer = createProducer(); } } } return producer; } @Override public void write(List<Record> records) { KafkaProducer<String, byte[]> p = producer(); records.forEach(r -> p.send(new ProducerRecord<>(r.topic(), r.key(), r.payload()))); } }

改完之后,启动时 4 个 Sink 对象都会创建,但只有实际被路由到的那个才会建 Kafka 连接。

复盘:这次故障的真实数字

  • 定位耗时:6 小时 40 分钟,其中 2 小时浪费在 TCCL 方向上。
  • 影响范围:测试环境 3 天,数据只落到 Metrics,Kafka 和 ES 双双无数据。因为是异步链路,监控上只体现为"下游数据量为 0",没有任何异常日志。
  • 修复代码量:pom 里 5 行。
  • 后续加的防御:启动自检,SPI 加载数量少于预期直接 fail-fast。
private static final int EXPECTED_SINK_COUNT = 4; @PostConstruct public void verify() { if (sinks.size() < EXPECTED_SINK_COUNT) { throw new IllegalStateException(String.format( "SPI 加载异常:期望 %d 个 DataSink,实际只有 %d 个 %s。" + "检查 fat jar 的 META-INF/services 是否被覆盖", EXPECTED_SINK_COUNT, sinks.size(), sinks.keySet())); } }

这段代码后来又救过我们一次——有人重构时把某个 module 的resources目录挪错了位置,服务在启动阶段就报错了,而不是等到跑三天才发现数据缺失。静默降级比直接崩溃可怕得多,这是我从这次事故里拿到的最实在的一条经验。

我的取舍判断

我不建议在业务代码里直接用 JDK 原生 ServiceLoader。理由很直接:它没有名字寻址、没有顺序控制、加载失败抛 Error、全量实例化。这四条里任意一条在业务系统里都够你难受一阵。

原生 SPI 真正适合的场景只有一类:你在写一个要被别人依赖的库,且不想引入任何第三方依赖。JDBC 的Driver、SLF4J 1.x 的StaticLoggerBinderjava.nio.file.spi.FileSystemProvider都属于这一类——接口极稳定、实现数量少、发现一次就够。

如果你是在 Spring 体系里做扩展点,直接用ObjectProvider<List<T>>或者@Autowired List<T>就够了,Spring 会帮你处理排序(@Order)、条件装配(@ConditionalOnProperty)和懒加载(@Lazy)。多引一层 SPI 只会让扩展点变得更难调试——你在 IDE 里对着接口按Ctrl+Alt+B查看实现类,SPI 配置文件里的字符串是不会被 IDE 索引进调用链的。

如果确实要做插件化、要支持运行时热插拔第三方 jar,那不如直接抄 Dubbo 的ExtensionLoader思路:配置文件写成name=全限定类名的 key-value 形式,加载时只解析不实例化,getExtension(name)时才按需创建并缓存。三百行代码,换来的可控性远超原生 SPI。

留个问题

如果你在一个 Spring Boot fat jar 里调用ServiceLoader.load(DataSink.class),但DataSink接口来自一个外部 jar,而实现类在你自己的BOOT-INF/classes下——这时 TCCL 是LaunchedURLClassLoader,接口的定义类加载器也是它。那么Class.forName(cn, false, loader)会成功吗?如果你把接口 jar 放到了java -cp的启动类路径上(由 AppClassLoader 加载),结果又会变成什么?

欢迎在评论区说说你踩过的 SPI 坑,尤其是那种"本地好好的,上了环境就不对"的。

http://www.jsqmd.com/news/1366159/

相关文章:

  • Anthropic开着6倍薪资抢人,CEO却担心新员工“只为钱来”?遭网友嘲讽:那你降薪试试
  • iOS激活锁终极绕过:AppleRa1n一键解锁iPhone 6s-X完整方案
  • AI辅助游戏开发:从Vibe Coding到独立游戏上线的全流程实践
  • 2026年下半年五常稻花香供应厂家实力解析:五常市庆喜米业有限公司、黑龙江秋然米业有限公司、五常市兴国粮油工贸有限公司 - 卓企推荐
  • BiliTools终极指南:如何用AI智能总结3倍提升B站学习效率
  • 5大核心功能揭秘:SleeperX如何彻底改变Mac电源管理体验
  • Java反编译终极指南:CFR工具从入门到精通实战解析
  • 5分钟快速上手:终极CRT滤镜指南 - 在现代游戏中重现经典显示效果
  • 2026 年太原热镀锌管方管批发,长治晋城管材采购答疑 - LYL仔仔
  • 从业务中屏到垂直智枢:数字孪生在城市治理中的三级价值跃迁
  • 天虹购物卡闲置别吃灰:拒绝绑定锁死,这份安全变现指南请收好 - 购物卡回收找京尔回收
  • 程序员如何通过漏洞挖掘实现技术变现
  • SSM+Vue农产品电商系统开发实战与优化
  • 广州正规网站建设企业如何选择以及避坑指南
  • 青龙面板自动化脚本终极指南:告别繁琐签到,5分钟搞定全平台任务
  • 2026 年佛山高纯度铁粉销售 钢渣生产源头厂家 - LYL仔仔
  • 李晓伟律师团队解读南京心肌梗死未达诊断标准拒赔应对方案 - 行路心安
  • 跨越界限:如何在Mac上轻松制作Windows启动盘
  • 宣威曲靖装修避坑全攻略:本地 23 年家装实战数据 + 真实落地案例 - GrowthUME
  • Python爬虫实战:构建电竞赛事积分数据抓取与可视化分析系统
  • 虚幻引擎逆向分析:从核心数据结构到运行时网格合并实战
  • 如何通过OmenSuperHub完全掌控惠普OMEN游戏本性能:开源风扇控制与功耗解锁终极指南
  • 终极指南:如何在Mac上轻松制作Windows启动盘,完美绕过硬件限制
  • 工业机器人控制柜嵌入式控制硬件选型|强抗震加固设计 异构算力加持多关节协同
  • OpenHarmony与React Native融合开发:SegmentControl组件实现
  • 如何用League Akari实现英雄联盟战绩分析的终极自动化
  • BiliTools终极指南:如何用开源工具高效管理B站学习资源
  • MyTV-Android原生电视直播应用开发指南:打造极致流畅的Android电视直播体验
  • 3步解锁专业缠论分析:ChanlunX缠论插件终极免费方案
  • Unity Lua调试实战:EmmyLua-AttachDebugger独立调试器配置指南