
title: 一个实现类静态块抛异常另外 6 个 SPI 插件集体失踪ServiceLoader 迭代的 3 个陷阱tags: [Java, SPI, ServiceLoader, 类加载, 源码解析]埋点数据少了三分之一但没有一行 ERROR 日志我们有一套自研的数据埋点框架对外暴露一个MetricsReporter接口各业务线自己写实现、打成 jar 塞进lib目录靠 SPI 装配。到 2026 年初一共有 7 个实现Prometheus、Kafka、本地文件、ClickHouse、阿里云 SLS还有两个业务线自己搞的私有上报。那天数据组的同学找过来说 ClickHouse 里的埋点数据从前一天下午开始少了一大截但 Prometheus 的指标是正常的。我第一反应是 ClickHouse 那个 reporter 的连接池满了去翻日志——干净得离谱没有 ERROR没有 WARN连异常堆栈都没有。然后我做了一件事在服务启动完成后打印一遍实际加载到的 reporter 列表。原本应该是 7 个实际只有 2 个。Prometheus 和 Kafka 在其余 5 个全都没了。真正的原因在spi.properties的加载顺序里。JDK 的ServiceLoader读META-INF/services文件时顺序基本上就是 classpath 上 jar 的顺序而排在第三位的那个私有 reporter静态初始化块里读了一个配置文件public class TenantPrivateReporter implements MetricsReporter { private static final String ENDPOINT; static { // 从 /data/conf/tenant-endpoint.conf 读上报地址 try (InputStream in new FileInputStream(/data/conf/tenant-endpoint.conf)) { Properties p new Properties(); p.load(in); ENDPOINT p.getProperty(endpoint); } catch (IOException e) { // 这里直接抛作者以为配置缺了就该炸 throw new IllegalStateException(tenant endpoint config missing, e); } } Override public void report(String name, double value) { /* ... */ } }逐行说这段代码的问题static块在类初始化阶段执行ServiceLoader反射newInstance()会触发类初始化FileInputStream打开的那个路径在前一天下午被运维的一次目录清理脚本删掉了catch 里把IOException包成IllegalStateException往外抛于是类初始化失败JVM 抛ExceptionInInitializerError。关键在下一步ServiceLoader会把这个错误包装成ServiceConfigurationError从next()里抛出来。而我们的装配代码是这么写的public ListMetricsReporter loadReporters() { ListMetricsReporter list new ArrayList(); try { for (MetricsReporter r : ServiceLoader.load(MetricsReporter.class)) { list.add(r); } } catch (Throwable t) { // 三年前某位同事加的防御性catch日志级别是 debug log.debug(load reporter failed, t); } return list; }try包在整个for外面。第三个实现抛错循环直接终止后面 4 个实现根本没机会被实例化。而log.debug在生产配置里是关闭的——所以线上一行日志都没有。ServiceLoader 的迭代到底是怎么走的JDK 17 里ServiceLoader内部真正干活的是LazyClassPathLookupIterator。挑几行核心的看private boolean hasNextService() { while (nextProvider null nextError null) { try { Class? clazz nextProviderClass(); // 解析下一行类名 if (clazz null) return false; if (clazz.getModule().isNamed()) { /* 模块路径分支 */ } if (!service.isAssignableFrom(clazz)) { fail(service, clazz.getName() not a subtype); } nextProvider (ProviderImplT) new ProviderImplT(service, clazz, acc); } catch (ServiceConfigurationError e) { if (throwOnError) throw e; // 关键分支 else nextError e; } } return true; }三点值得注意nextProviderClass()只做Class.forName(cn, false, loader)第二个参数false表示不初始化。也就是说解析类名这一步不会触发静态块。真正触发类初始化的是ProviderImpl.get()→newInstance()而这个动作发生在next()里不是hasNext()里。throwOnError这个字段决定了错误是抛出还是暂存。用ServiceLoader.load()得到的迭代器throwOnError为true——错误会一路抛到你的 for 循环外面。所以我们踩的坑是设计上就存在的JDK 的语义是「有一个坏了整批就不可信」而我们的业务语义是「有一个坏了剩下的照常上报」。两者不匹配就必须自己接管迭代。三种修法的取舍我当时列了三个方案团队讨论了半小时方案改动量隔离粒度风险手动Iterator 单个 try小只改装配类单个实现失败不影响其他需要区分hasNext和next的异常ServiceLoader.stream()惰性过滤小需 JDK 9同上且能先拿type()判断再实例化JDK 8 项目用不了换成 Spring 的SpringFactoriesLoader大插件全要改元数据格式由 Spring 兜底插件方要配合改周期长最终选的是第二个我们本来就在 JDK 17 上。改完的代码public ListMetricsReporter loadReporters() { ListMetricsReporter result new ArrayList(); IteratorServiceLoader.ProviderMetricsReporter it ServiceLoader.load(MetricsReporter.class).stream().iterator(); while (true) { ServiceLoader.ProviderMetricsReporter provider; try { if (!it.hasNext()) break; provider it.next(); // 这一步只解析类不初始化 } catch (ServiceConfigurationError e) { log.error(SPI 条目解析失败跳过, e); continue; // 坏条目不影响后续 } Class? extends MetricsReporter type provider.type(); try { MetricsReporter r provider.get(); // 这一步才触发静态块 result.add(r); log.info(reporter 加载成功: {}, type.getName()); } catch (Throwable t) { log.error(reporter 实例化失败已跳过: {}, type.getName(), t); } } return result; }这段的价值在于把「解析类名」和「实例化」两个阶段的异常分开处理了。provider.type()能在实例化之前拿到类名这样日志里就能明确写出是哪个实现挂了——上面那次事故里我们连「谁挂了」都不知道全靠打印列表反推。continue而不是break保证一个坏条目不会带走整批。另外我给插件规范加了一条硬性要求实现类的静态块和构造器里不允许做 IO。需要读配置就实现一个init()方法由框架在装配完成后统一调用失败只影响这一个 reporter。这条规则写进了插件接入文档评审时必须过。顺手排掉的第二个坑多 ClassLoader 下的缓存修完上面这个我顺手翻了ServiceLoader的reload()public void reload() { lookupIterator1 null; instantiatedProviders.clear(); // 清掉已实例化的 lookupIterator2 null; providers.clear(); // 清掉缓存 ... }instantiatedProviders是个ArrayList它持有所有已经实例化出来的实现对象的强引用。我们的插件平台每次热更新都new一个URLClassLoader如果ServiceLoader实例被静态字段持有、且没调reload()那么旧 ClassLoader 就会因为instantiatedProviders里的对象而无法回收——Class对象持有 defining ClassLoader 的引用这是一条完整的强引用链。我用 MAT 抓了一次堆确认存在 3 个URLClassLoader实例Metaspace 占用 412MB。修法是把ServiceLoader实例改成方法内局部变量装配完就丢只保留实例化出来的 reporter 列表卸载插件时显式清空列表并置空 ClassLoader 引用。改完之后连续 20 次热更新Metaspace 稳定在 180MB 左右。复盘的几个数字故障持续 19 小时前一天 14:20 到次日 09:40期间 ClickHouse 埋点丢失约 4700 万条。无告警的原因有两层log.debug在生产关闭我们的健康检查只探活 HTTP 端口没有校验 reporter 数量。事后加的两个检查启动时如果加载到的 reporter 数量少于配置里声明的expected-count直接启动失败/actuator/health里暴露 reporter 列表。从加日志到定位根因用了 40 分钟其中 30 分钟花在「为什么一行日志都没有」上。我的几个判断SPI 这套机制适合稳定、少量、由自己团队控制的扩展点不适合做开放插件平台。JDK 的ServiceLoader没有版本、没有依赖声明、没有隔离出错语义还是「全批失败」。如果扩展点会被外部团队提交实现我更建议自己定义一套注册表 显式加载或者直接上 OSGi/自研 ClassLoader 隔离方案——ServiceLoader那点便利换不来可控性。任何「防御性」的 catch Throwable 都应该配 ERROR 级别日志。上面那个log.debug让 19 小时的故障变成了盲飞。团队后来在 checkstyle 里加了一条规则catch 块里如果只有 debug/trace 级别日志编译告警。不要在静态块里做任何可能失败的事。静态块的异常会被包装成ExceptionInInitializerError而且类一旦初始化失败就永久处于 erroneous 状态后续每次访问都抛NoClassDefFoundError连重试都做不到。这个约束在 SPI 场景下尤其致命。如果你还在 JDK 8stream()用不了就自己写Iterator手动循环把hasNext()和next()分别包 try——虽然拿不到type()但至少能保证一个坏条目不带走整批。这是最小成本的修法。留个问题如果一个 SPI 实现类的静态块里读了配置、并且第一次加载就失败了之后运维把配置补上在不重启 JVM的前提下你有办法让这个类重新初始化成功吗如果你的答案是「换 ClassLoader 重新加载」那么原来那个已经处于 erroneous 状态的Class对象什么时候才会被回收欢迎在评论区聊聊你的思路。