一个实现类静态块抛异常,另外 6 个 SPI 插件集体失踪:ServiceLoader 迭代的 3 个陷阱
2026/8/27 16:11:29 网站建设 项目流程

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 的ServiceLoaderMETA-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会把这个错误包装成ServiceConfigurationErrornext()里抛出来。而我们的装配代码是这么写的:

public List<MetricsReporter> loadReporters() { List<MetricsReporter> 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 = (ProviderImpl<T>) new ProviderImpl<T>(service, clazz, acc); } catch (ServiceConfigurationError e) { if (throwOnError) throw e; // 关键分支 else nextError = e; } } return true; }

三点值得注意:

  1. nextProviderClass()只做Class.forName(cn, false, loader),第二个参数false表示不初始化。也就是说解析类名这一步不会触发静态块。
  2. 真正触发类初始化的是ProviderImpl.get()newInstance(),而这个动作发生在next()里,不是hasNext()里。
  3. throwOnError这个字段决定了错误是抛出还是暂存。用ServiceLoader.load()得到的迭代器,throwOnErrortrue——错误会一路抛到你的 for 循环外面。

所以我们踩的坑是设计上就存在的:JDK 的语义是「有一个坏了,整批就不可信」,而我们的业务语义是「有一个坏了,剩下的照常上报」。两者不匹配,就必须自己接管迭代。

三种修法的取舍

我当时列了三个方案,团队讨论了半小时:

方案改动量隔离粒度风险
手动Iterator+ 单个 try小,只改装配类单个实现失败不影响其他需要区分hasNextnext的异常
ServiceLoader.stream()惰性过滤小,需 JDK 9+同上,且能先拿type()判断再实例化JDK 8 项目用不了
换成 Spring 的SpringFactoriesLoader大,插件全要改元数据格式由 Spring 兜底插件方要配合改,周期长

最终选的是第二个,我们本来就在 JDK 17 上。改完的代码:

public List<MetricsReporter> loadReporters() { List<MetricsReporter> result = new ArrayList<>(); Iterator<ServiceLoader.Provider<MetricsReporter>> it = ServiceLoader.load(MetricsReporter.class).stream().iterator(); while (true) { ServiceLoader.Provider<MetricsReporter> 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 下的缓存

修完上面这个,我顺手翻了ServiceLoaderreload()

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对象什么时候才会被回收?欢迎在评论区聊聊你的思路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询