关于JAVA SPI的面试题还蛮多的,正好来深度分析一下。在此之前,先看一道面试题。

面试官问:“Java的SPI机制是什么?”

你答:“META-INF/services里放配置文件,ServiceLoader加载。”

面试官追问:“那JDBC 4.0为什么不用写Class.forName了?Bootstrap ClassLoader看不见MySQL驱动的。”

是不是就有点被问倒了? 哈哈,下文尝试一下来回答这个问题。

本篇从三个层面来讲:

  1. SPI是什么、解决了什么痛点
  2. TCCL怎么让父加载器加载到子加载器的类,面试核心考点
  3. Dubbo/ES/Kafka Connect在SPI基础上做了什么增强,Spring Boot为什么没用它

读完本文后,面试官再问SPI,你能从META-INF/services一路讲到TCCL和Dubbo的@Adaptive

SPI解决了什么痛点

做过业务系统的人都知道,框架如果只依赖具体实现类,换MySQL驱动、换日志实现,往往得改源码重新编译。搞中间件和基础库时,这条路子走不通,因为:接口得稳定,实现得能换。

SPI(Service Provider Interface)是JDK给出的约定:框架只依赖接口,第三方在jar里放**META-INF/services/配置文件,运行时由ServiceLoader**发现实现类。

JDBC是最常被引用的例子。JDBC 4.0之前,应用得自己写Class.forName("com.mysql.jdbc.Driver")把驱动类加载进来。4.0之后,DriverManager初始化时调用ServiceLoader.load(Driver.class),classpath上各厂商驱动jar在META-INF/services/java.sql.Driver里登记实现类,应用只写jdbc:mysql://...,不用知道驱动类全名。控制权从应用主动加载变成了框架被动发现。

JDK SPI需要的三样东西:

要素 说明 要求
接口 框架定义的扩展契约 公共接口
配置文件 META-INF/services/<接口全名> UTF-8,每行一个实现类
实现类 provider 具体类 public 无参构造

在这里插入图片描述

和工厂+switch、配置文件里写类名再反射相比,SPI的差别在于约定统一

方式 耦合度 发现机制 多实现共存
工厂 + switch 框架要知道所有实现类名 编译期写死 加实现要改框架代码
配置类名 + 反射 配置与代码分离,但无标准目录 各自约定 靠配置选一项
JDK SPI 框架只依赖接口 META-INF/services/<接口全名> 天然支持多项,框架自行筛选

SPI的三要素齐了,但还有一个关键问题:配置文件在jar里,谁来扫?用什么ClassLoader扫? 这就引出了TCCL。

TCCL(线程上下文类加载器):SPI为什么能加载到应用classpath上的类

先看一个矛盾:

java.sql.DriverManagerrt.jar里,由Bootstrap ClassLoader加载。你的com.mysql.cj.jdbc.Driver在应用classpath里,由AppClassLoader加载。

Bootstrap根本看不见AppClassLoader的东西,这是双亲委派模型的硬性规定。

那JDBC 4.0凭什么能做到不用写Class.forName

答案在TCCL(线程上下文类加载器)。ServiceLoader.load(Driver.class)无参重载时,取的是当前线程的上下文类加载器,默认指向AppClassLoader,从应用classpath扫描并加载驱动实现:

public static <S> ServiceLoader<S> load(Class<S> service) {
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return new ServiceLoader<>(Reflection.getCallerClass(), service, cl);
}

DriverManager.getConnection里也有类似逻辑:调用方类加载器为空或是Platform时,回退到TCCL。

准确说法:SPI通过TCCL主动绕过双亲委派的方向限制,让rt.jar里的框架代码能加载到应用classpath上的实现类。TCCL作为桥梁,让父加载器侧代码能加载子加载器可见的实现

面试时把TCCL和JDBC驱动加载串起来讲,一般就能答到点子上。

JDK SPI怎么工作

两方各干什么

SPI里固定有两个角色。**框架(消费方)**只依赖接口,比如java.sql.Driver。**实现方(provider)**在jar里放两样东西:实现类本身,以及META-INF/services/java.sql.Driver,里面写实现类全名,一行一个。MySQL、PostgreSQL各自登记,框架代码不用改。

调用时按什么顺序发生

很多人以为ServiceLoader.load(Driver.class)一执行就会实例化驱动,其实不是load只准备好loader,classpath还没扫。

真正干活的是迭代器DriverManager初始化时拿到ServiceLoader<Driver>的迭代器,逐个next()。每往前一步,JDK才去classpath找META-INF/services/java.sql.Driver,读类名,再反射创建实例。这叫懒加载:第一次next()才暴露配置错误或类找不到。

ServiceLoader<Driver> loader = ServiceLoader.load(Driver.class);
Iterator<Driver> it = loader.iterator();
Driver driver = it.next();  // 到这行才真正去扫配置文件、加载类

classpath上挂了多个驱动jar,配置文件会被合并,迭代器按顺序吐出多个Driver实例。DriverManager.getConnection(url)再逐个问能不能接这个URL,能接就用。SPI只负责找出来,选哪一个由框架定。 带中间件接入的项目里,classpath同时挂MySQL和PostgreSQL驱动,最终用哪个取决于URL前缀匹配,不是SPI替你拍板。

在这里插入图片描述

DriverManager在初始化阶段对迭代中的异常做了catch,避免某个坏驱动拖垮全局——这是消费方的防御写法,不是SPI自带的容错。

实现方要满足什么

classpath部署时,实现类要有public无参构造,且能被当前ClassLoader加载。配置文件用UTF-8,#后面是注释,重复类名会跳过。

JDK 9之后走模块系统,SPI有两条并行路径:

机制 配置方式 优先级
模块化 SPI module-infoprovides/uses 高,ServicesCatalog先查
传统 SPI META-INF/services/文件 与模块化双轨并存

模块里的provider还可以提供静态provider()方法返回实例,不一定要无参构造。classpath上的命名模块实现,在旧式扫描路径里可能被跳过,做JPMS迁移时要单独留意。

JDK SPI是底座,中间件在底座上怎么改?先看两个插件场景。

Kafka Connect和Elasticsearch怎么用SPI

Elasticsearch和Kafka Connect都做插件,新能力靠外部的jar接入,不会在核心代码里把实现类名写死。两家都沿用META-INF/services/约定,差别在ClassLoader和实例化时机

Kafka Connect:标准SPI + plugin.path

Connect的Source/Sink连接器、Converter、Transformation等,来自用户自带jar。Connect把plugin.path目录下的jar当插件库,启动时用ServiceLoaderScanner扫描。插件jar里要有对应接口的services文件,扫描器检查类能加载、public、有无参构造,才进可用列表。

Connect更贴近标准ServiceLoader规则。

Elasticsearch:自研SPIClassIterator,延迟实例化

ES启动时装插件,每个插件有自己的ClassLoader。PluginsService用自研的SPIClassIterator读services文件,只读类名、不马上**new**,避免classpath顺序和静态初始化把启动搞挂。和标准ServiceLoader比,ES多包这一层,因为插件ClassLoader隔离和启动顺序更复杂。

插件场景这么玩其实够用了,但是一些开源的RPC框架还要按名取、排序、注入,Dubbo因此自建了一套。

Dubbo为什么自己搞一套SPI

JDK SPI只能全量迭代,不能按名字取某一个实现,也没有优先级、依赖注入。RPC框架扩展点多、组合关系复杂,Dubbo在JDK SPI思路上自建了ExtensionLoader

配置文件路径变了:META-INF/dubbo/internal/<接口全名>,内容是name=实现类,例如dubbo=org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol

加载方式也变了:不是for (Protocol p : loader),而是按名取

Protocol protocol = ExtensionLoader.getExtensionLoader(Protocol.class).getExtension("dubbo");

Dubbo在原生SPI之上补了四块能力:

特性 作用
按名获取 getExtension("dubbo"),不用全量迭代
@Adaptive 按URL参数在运行时选具体实现
@Activate 条件激活与order排序
Wrapper 装饰器链包装扩展点

Dubbo没有替换JDK SPI规范,而是在RPC场景把发现、选择、组装做完整。

Spring Boot为啥不用JDK SPI

Dubbo在SPI之上做了增强,那Spring Boot呢?很多人以为Spring Boot的自动配置也是JDK SPI,其实不是

Spring Boot自动配置走的是META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,由SpringFactoriesLoader演进而来,不是JDK SPI。别把@EnableAutoConfiguration说成JDK SPI。

对比项 JDK SPI Spring Boot imports
配置文件 META-INF/services/接口全名 META-INF/spring/*.imports
格式 每行一个实现类 每行一个配置类全名
典型用途 JDBC、插件发现 Starter自动配置

工业级框架的SPI演进对比

接口、配置文件、加载器,JDK SPI定了最小协议。Kafka Connect、Elasticsearch、Dubbo在同一思路上各自演进:改配置路径、换ClassLoader、控制实例化时机。
在这里插入图片描述

原理就讲到这里,下面我们过一遍高频的面试题。

面试里常问的四个问题

下面四个问题串起来,就是大多数SPI面试的主线。建议对照上文TCCL、Dubbo、Spring Boot几节,用自己的话说一遍。

Q1:SPI是什么

SPI是JDK的服务发现机制:框架定义接口,第三方在META-INF/services/登记实现类,ServiceLoader运行时加载。SPI是提供方实现框架定义的接口,控制权在框架这边

Q2:TCCL和JDBC驱动加载怎么串起来?

DriverManager在rt.jar,MySQL驱动在应用classpath。ServiceLoader.load()无参重载取TCCL(默认AppClassLoader),从应用classpath扫描驱动。DriverManager.getConnection在类加载器为空或Platform时也回退TCCL。本质是TCCL作为桥梁,让父加载器侧代码能加载子加载器可见的实现。

Q3:Dubbo SPI和JDK SPI有什么不同?

JDK SPI只能全量迭代,无按名、无优先级、无注入。Dubbo用META-INF/dubbo/internal/name=实现类格式,通过getExtension(name)按名加载;@Adaptive按URL选实现,@Activate做排序和条件激活,Wrapper做装饰。JDK SPI负责发现,Dubbo SPI负责发现+组装。

Q4:Spring Boot自动配置是JDK SPI吗?

不是。Spring Boot走META-INF/spring/*.imports,由SpringFactoriesLoader加载,配合@Conditional做条件装配。和JDK SPI是两条线,不要搞混。

最后,如何评价Java SPI

接口稳定、实现方在独立jar里、框架愿意自己做多实现筛选,这类场景SPI仍然合适。JDBC驱动、ES和Kafka插件都是典型用法。

原生SPI有三条硬局限:

  1. 不能按名字直接取实现,只能迭代后自己筛
  2. 没有优先级和条件激活,多个实现共存时框架得另写规则
  3. 只负责**new**对象,不做依赖注入,实现类里要用别的Bean得自己想办法

希望这篇文章能帮到你。


参考内容

更多推荐