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 的最小骨架摆出来SPIService Provider Interface说白了就是三件事一个接口、一个配置文件、一次ServiceLoader.load。// 1. 接口定义在 core 模块 public interface DataSink { String name(); void write(ListRecord records); } // 2. 实现放在各自的 module public class KafkaSink implements DataSink { private final KafkaProducerString, 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(ListRecord 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 MapString, DataSink sinks new LinkedHashMap(); public SinkRegistry() { ServiceLoaderDataSink 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.ServiceLoaderpublic static S ServiceLoaderS load(ClassS service) { ClassLoader cl Thread.currentThread().getContextClassLoader(); return ServiceLoader.load(service, cl); } public static S ServiceLoaderS load(ClassS service, ClassLoader loader) { return new ServiceLoader(service, loader); } private ServiceLoader(ClassS 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。真正干活的是LazyIteratorprivate 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); // ← 这里返回的是 EnumerationURL } 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的结果打出来EnumerationURL 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 插件加一个 transformerplugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.5.1/version executions execution phasepackage/phase goalsgoalshade/goal/goals configuration transformers !-- 关键把同路径的 META-INF/services 文件按行追加合并而不是覆盖 -- transformer implementationorg.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.xSpringspring.factories启动时一次性读全不支持可配 OrderMETA-INF/spring.factoriesSpring Boot 2.x 自动装配Spring Boot 3.imports启动时一次性读全同上META-INF/spring/*.importsSpring Boot 3.xDubbo 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 KafkaProducerString, byte[] producer; // 不在构造函数里创建 public KafkaSink() { } // 保持空构造只做 SPI 契约 Override public String name() { return kafka; } // 真正被选中时才初始化双检锁保证只建一次 private KafkaProducerString, byte[] producer() { if (producer null) { synchronized (this) { if (producer null) { producer createProducer(); } } } return producer; } Override public void write(ListRecord records) { KafkaProducerString, byte[] p producer(); records.forEach(r - p.send(new ProducerRecord(r.topic(), r.key(), r.payload()))); } }改完之后启动时 4 个 Sink 对象都会创建但只有实际被路由到的那个才会建 Kafka 连接。复盘这次故障的真实数字定位耗时6 小时 40 分钟其中 2 小时浪费在 TCCL 方向上。影响范围测试环境 3 天数据只落到 MetricsKafka 和 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 体系里做扩展点直接用ObjectProviderListT或者Autowired ListT就够了Spring 会帮你处理排序Order、条件装配ConditionalOnProperty和懒加载Lazy。多引一层 SPI 只会让扩展点变得更难调试——你在 IDE 里对着接口按CtrlAltB查看实现类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 坑尤其是那种本地好好的上了环境就不对的。