打了 fat jar 之后 SPI 只剩一个实现:ServiceLoader 的 5 个加载边界
title: 打了 fat jar 之后 SPI 只剩一个实现:ServiceLoader 的 5 个加载边界
tags: Java,SPI,ServiceLoader,源码,类加载
category: Java
那个只在打包后复现的 bug
去年 11 月,我们把一个数据同步服务从本地 IDE 部署到测试环境,同一份代码,同一个 commit,行为却不一样。
服务里有个DataSink接口,通过 SPI 加载了 4 个实现:KafkaSink、EsSink、FileSink、MetricsSink。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 | 启动时一次性读全 | 不支持,可配 @Order | META-INF/spring.factories | Spring Boot 2.x 自动装配 |
Spring Boot 3.imports | 启动时一次性读全 | 同上 | META-INF/spring/*.imports | Spring Boot 3.x |
| Dubbo ExtensionLoader | 运行时,按 name 取 | 支持@Activateorder | META-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 的StaticLoggerBinder、java.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 坑,尤其是那种"本地好好的,上了环境就不对"的。