Java SPI 被 Spring 惯坏后:ServiceLoader 源码里 3 个把人坑哭的实例化细节

📅 2026/8/3 6:29:25 👁️ 阅读次数 📝 编程学习
Java SPI 被 Spring 惯坏后:ServiceLoader 源码里 3 个把人坑哭的实例化细节

引子:为什么一堆框架都爱用 SPI

Java 标准库里有个不起眼的接口加载机制:Service Provider Interface。你在 classpath 下放一个META-INF/services/全限定接口名的文件,里面写几个实现类的全限定名,运行时调用ServiceLoader.load(接口.class)就能拿到所有实现。JDBC 4.0 的驱动自动注册、SLF4J 的绑定、Dubbo 的扩展点,底层都是这套玩法。

它最大的卖点是"面向接口编程 + 解耦具体实现"。模块 A 只依赖接口,模块 B 在打包时塞一个 services 文件,A 完全不用改代码就能加载到 B 的实现。听起来很美,但我们团队在一个插件化项目里用它时,栽了三个跟头,其中一个直到上线当晚才暴露。

问题:被 Spring 惯出来的错觉

我们的场景是:核心系统定义一批Processor接口,各个业务线以独立 jar 的形式提供实现,核心系统在启动时扫描并装配。第一版我让业务线同学把实现类写成 Spring Bean,里面@Autowired了一堆依赖,然后信心满满地用ServiceLoader.load(Processor.class)去拿。

结果一跑就NullPointerException——实现类确实被加载了,但里面的@Autowired字段全是 null。原因很简单:ServiceLoader是通过反射clazz.getConstructor().newInstance()直接 new 出来的,它根本不知道 Spring 的存在,也不会走容器注入。这个问题在单元测试里居然没暴露,因为测试同学手写了实现类并手动塞了依赖。

这只是第一个坑。下面我把ServiceLoader的实例化链路拆开,讲清楚三个最容易忽略的细节。

源码/原理:ServiceLoader 是怎么把实现类 new 出来的

先放一段我们用来"调试世界观"的最小复现:

// 1. 定义接口 public interface Codec { String name(); byte[] encode(String s); } // 2. 实现类放在 META-INF/services/com.demo.Codec 里 public class GzipCodec implements Codec { private final int level; // 3. 注意:没有无参构造也能被加载吗? public GzipCodec(int level) { // 答案:不能,ServiceLoader 只能调无参构造 this.level = level; } public String name() { return "gzip"; } public byte[] encode(String s) { return s.getBytes(StandardCharsets.UTF_8); } }

逐行看这段代码暴露的约束:

  • 第 1 行定义的接口本身没问题,ServiceLoader只认接口或抽象类。
  • 第 7 行我故意写了一个带参构造GzipCodec(int level)。一旦你没显式提供无参构造,编译器就不会生成默认构造,ServiceLoader在反射时会抛InstantiationException(包装成ServiceConfigurationError)。
  • 第 9 行encode里直接s.getBytes(...)没做 null 判断,这是另一个隐患:ServiceLoader对实现类没有任何生命周期约束,你拿到的就是一个裸对象。

再看ServiceLoader内部的加载逻辑(JDK 8/11 通用骨架):

// ServiceLoader.LazyIterator 的核心片段(简化) Class<?> c = Class.forName(cn, false, loader); // 1. 只加载不初始化 if (!service.isAssignableFrom(c)) // 2. 类型校验,不是接口的实现就报错 throw new ServiceConfigurationError(...); S p = service.cast(c.getConstructor().newInstance()); // 3. 无参构造 + 强转 providers.put(cn, p); // 4. 缓存到 providers Map return p;

逐行解释关键的四个动作:

  • 第 1 行Class.forName(cn, false, loader)的第二个参数false表示只加载不初始化,所以实现类的 static 块会在第一次真正使用时才跑,不会在加载阶段触发。我们曾有一个实现类在 static 块里连数据库连接,结果初始化时机不可控,排查了半天。
  • 第 2 行做类型校验,如果你的 services 文件里写了一行拼写错误的类名,这里会抛出ServiceConfigurationError,而且不会告诉你具体哪一行,只会报整个文件解析失败。
  • 第 3 行c.getConstructor().newInstance()是坑王:它强制要求公共无参构造。实现类写成包级私有、或者忘了无参构造,统统在这里炸。
  • 第 4 行把实例缓存进providers,意味着ServiceLoader默认是单例缓存——同一个ServiceLoader实例多次iterator()拿到的是同一批对象,线程安全但无法热更新。

实战:我们怎么把插件系统救回来

第一个 NPE 坑的修法其实有两种思路。一种是"认命":实现类不依赖 Spring,所有依赖通过构造参数传入,ServiceLoader加载后由我们自己的装配器手动注入。代码长这样:

// 手动装配:把 SPI 拿到的裸对象交给 Spring 容器补依赖 @Service public class ProcessorRegistry { @Autowired private ApplicationContext ctx; public List<Processor> loadAll() { List<Processor> result = new ArrayList<>(); for (Processor p : ServiceLoader.load(Processor.class)) { // 1. 用 AutowireCapableBeanFactory 给裸对象补注入 ctx.getAutowireCapableBeanFactory() .autowireBeanProperties(p, AutowireCapableBeanFactory.AUTOWIRE_BY_TYPE, false); // 2. 如果实现类实现了 InitializingBean,手动回调 if (p instanceof InitializingBean) { try { ((InitializingBean) p).afterPropertiesSet(); } catch (Exception e) { /* 3. 单个失败不能拖垮整体 */ log.error("init fail", e); } } result.add(p); } return result; } }

这段代码的几个取舍点:

  • 第 6 行autowireBeanProperties能把 Spring 容器里的 Bean 填进 SPI 裸对象的字段,解决了@Autowired为 null 的问题。这是我们线上最终采用的方案。
  • 第 11 行用 try-catch 包住afterPropertiesSet,因为ServiceLoader在迭代时如果某个实现抛异常,会直接中断整个迭代,导致后面还没加载的插件全部失效。我们生产环境就遇到过:一个业务线的实现类afterPropertiesSet里调了外部 HTTP 接口超时,结果把其他 6 个健康的插件一起带崩了。

第二个坑是 services 文件的格式。文件里每行一个实现类全限定名,#开头的是注释,但行尾不能有空格、不能有 BOM 头、Windows 上用记事本保存会带 UTF-8 BOM,这些都会让解析失败。我们后来统一用构建脚本生成这个文件,禁止手改。

第三个坑是线程上下文类加载器。在我们的插件 jar 由自定义URLClassLoader加载的场景下,ServiceLoader.load(Processor.class)默认用的是调用者的类加载器(即Processor接口定义所在的 loader)。如果接口在父 loader、实现在子 loader,默认传参是对的;但如果你在某个线程里换了 TCCL(Thread.currentThread().getContextClassLoader()),又不小心把这个 loader 传进了ServiceLoader.load(Processor.class, wrongLoader),就会拿到空集。我们排查这个花了差不多一下午,最后对齐了 loader 层级才解决。

对比:标准 SPI 和 Spring 的两种替代

维度java.util.ServiceLoaderSpring Boot spring.factories手动 Map 注册
加载时机惰性迭代时才实例化启动期一次性实例化自己控制
依赖注入完全不管天然支持自己写
热更新不支持(缓存)不支持支持
排序能力可自定义
适合场景框架级扩展点Spring 生态内部业务插件、需排序/热更

我的判断:纯框架、不需要 Spring 依赖、也不关心顺序的场景,标准 SPI 是最省事的;但只要你的实现类需要容器注入或者你要对插件排序、启停,标准 SPI 就不合适了。

总结与我的取舍

SPI 是个好机制,但它的"无参构造 + 无注入 + 无顺序 + 缓存"四件套,决定了它只适合做静态、轻量、无状态的扩展点。我们现在的规则很明确:核心系统内部的扩展点一律用 Spring 的List<Processor>自动收集(Spring 会按@Order排序并注入好),只有那些要被打成独立 fat jar、脱离 Spring 容器运行的真正"插件",才用标准ServiceLoader

我不建议在 Spring 项目里为了"解耦"而硬上ServiceLoader,除非你真的需要类加载隔离。多数时候,一个@Component+@Order就够了,反而更可控。

思考题

你的项目里有没有把@Service标注的类当成 SPI 实现丢给ServiceLoader加载过?如果实现类需要读取配置文件,用Class.getResourceAsStreamClassLoader.getResourceAsStream拿到的是同一个流吗?欢迎在评论区聊聊你踩过的 SPI 坑。