专注于 JetBrains IDEA 全家桶,永久激活,教程
持续更新 PyCharm,IDEA,WebStorm,PhpStorm,DataGrip,RubyMine,CLion,AppCode 永久激活教程

Java SPI机制实现与源码分析

0. 前言

本文着重于实现一个基于 Java SPI 的 demo 以及对其实现原理的解析,即 ServiceLoader 类源码分析。

其实最初想写这篇文章的原因是在之前的一次面试中,被面试官问到关于 Java SPI 的问题,但没能说出让他满意的答案,所以才想着整理一篇 SPI 的文章,顺便也巩固一下双亲委派机制的知识。

话不多说,直入正题。

再多说一句,曾经在偶然的情况下发现,我在掘金上发表的文章,会被其他人转载,且标注为原创,包括但不限于微某公某号、C某DN、知某乎,拉某勾、以及其他不知名的网站,所以在本文中的图片会有 Github 的水印,虽然这篇文章可能没什么阅读量,也可能会有错误,但随便就被别人拿来用,并且标注为原创,心里还是会还是很不爽呢。

1. SPI 简述

SPI 的全称是 Service Provider Interface,翻译过来就是服务提供方接口。它是 Java 内置的一种服务提供发现机制,只需要在环境变量中添加相应接口的实现,程序就能自动装载该类并使用它。

SPI 有良好的扩展性,框架制定了规则(接口),具体的厂商提供实现(实现接口),如果想切换实现方案,只需要把另一个厂商的实现放在环境变量中就可以,也不需要修改代码,显然这是一种策略模式。

下面,我们就来实现一个简单的基于 SPI 的 demo。

2. Java 实现 SPI 的步骤

2.1 定义接口

首先需要定义一个接口,这就是所谓的“标准”,而厂商就是根据这个标准接口进行实现的,比如关于 JDBC 标准接口有 MySQL 的实现和 Oracle 的实现。

而在这个 demo 中,我所定义的 HelloSpi 接口的标准是需要实现 say 方法,其核心功能就是基于 say 方法输出一段文字。

public interface HelloSpi {
    /**
     * spi接口的方法
     */
    void say();
} 

2.2 创建接口的实现类

创建两个实现类 HelloInEnglish 和 HelloInChinese,分别输出一行关于 hello 的语句。

public class HelloInChinese implements HelloSpi {
    @Override
    public void say() {
        System.out.println("from HelloInChinese: 你好");
    }
} public class HelloInEnglish implements HelloSpi { @Override public void say() { System.out.println("from HelloInEnglish: hello"); } } 

在这里有一个注意点,实现类必须要有无参构造函数,否则会报错,因为在 ServiceLoader 在创建实现类实例的时候会通过无参构造函数来实现,具体的代码在后面会分析。

2.3 创建接口全限定名配置元文件

接下来就是在 resources 目录下创建一个 services 文件夹,继而创建一个名称为 HelloSpi 接口全限定名的文件,在我项目中就是 org.walker.planes.spi.HelloSpi。

而文件的内容就是刚刚创建的两个实现类的全限定名,文件中每行代表一个实现类。

org.walker.planes.spi.HelloInEnglish
org.walker.planes.spi.HelloInChinese

2.4 使用ServiceLoader加载配置文件中的类

创建一个有 main 方法的测试类,调用 ServiceLoader#load(Class) 方法加载对应的类,并执行。

public class SpiMain {
    public static void main(String[] args) {
        // 加载 HelloSpi 接口的实现类
        ServiceLoader<HelloSpi> shouts = ServiceLoader.load(HelloSpi.class);
        // 执行say方法
 for (HelloSpi s : shouts) { s.say(); } } } 执行结果为: from HelloInEnglish: hello from HelloInChinese: 你好 

至此,就基于 Java SPI 机制实现了一个简单的 demo,下面会分析一下 ServiceLoader 类加载接口实现类的原理。

3. ServiceLoader 源码分析

通过上面的一个实现 Java SPI 的 demo,我想你已经知道它的实现流程了,下面就来说说 ServiceLoader 类是如何根据接口类找到并加载实现类的。

未做特殊说明,本文源码都是基于 JDK1.8。

3.1 ServiceLoader#load(Class)

首先我们来看上面例子中的第4步,通过调用 ServiceLoader#load(Class) 方法加载对应的类,来看看 load 方法的源码。

// 示例 main 方法中的 load 方法
public static <S> ServiceLoader<S> load(Class<S> service) {
    // 获取线程上下文类加载器
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    // 调用重载方法,通过 cl 线程上下文类加载器加载 service 目标类
 return ServiceLoader.load(service, cl); } // load 重载方法 public static <S> ServiceLoader<S> load(Class<S> service, ClassLoader loader) { return new ServiceLoader<>(service, loader); } 

正如上面代码中注释锁描述的那样,通过调用 ServiceLoader#load(Class) 方法,首先会获取线程上下文类加载器,然后通过线程上下文类加载器加载目标类。

说到这里,就不得不提一下 JVM 双亲委派机制和线程上下文类加载器(Thread Context ClassLoader)。

3.2 JVM 双亲委派机制

相信大家都知道 JVM 的双亲委派机制,即当类加载器接收到类加载的请求时,它不会自己去尝试加载这个类,而是把这个请求委派给父加载器去完成,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载类。

一般我们所说的类加载器包括启动类加载器(Bootstrap ClassLoader)、拓展类加载器(Extension ClassLoader)、应用类加载器(AppClassLoader)、自定义类加载器(Custom ClassLoader)。

其中在本文中需要了解的是,启动类加载器负责加载 Java 的核心类,包括:

  • 位于 JAVA_HOME\lib 中的类
  • 被 -Xbootclasspath 参数所指定的路径中
  • 虚拟机能够识别的类库,如 rt.jar、tools.jar

还有应用类加载器,它负责加载用户类路径 classpath 上所指定的类库。

知道了双亲委派机制和启动类加载器,再来说回 SPI。

前文中也提到过,所谓的 SPI 就是 JDK 提供的一些标准接口,由厂商来进行实现,当环境变量中存在该实现时,就能够自动进行类的加载,从而使用厂商的实现。

值得注意的是,JDK 提供标准 SPI 接口一般是和核心代码库放在一起的,比如 JDBC 驱动 Driver.class 接口就是在 rt.jar 包中。也就是说,SPI接口是被启动类加载器加载的,如果基于传统的双亲委派机制,其实是通过启动类类加载器来加载厂商的实现类的,这个时候就会发现,厂商实现类是在 classpath 中,不能被启动类加载器加载。

基于这样一种困境,线程上下文类加载应运而生,它就是用来打破双亲委派机制的一种方法。

3.3 线程上下文类加载器

线程上下文类加载器是 Thread 类的一个属性,它是用来缓存当前线程的类加载器的。

在 ServiceLoader#load(Class) 方法中,首先获取了当前线程的线程上下文类加载器,在示例代码中,执行此方法的线程是 main 线程,加载 main 线程的类加载器是应用类加载器。

95_1.png ServiceLoader#load 重载方法1

应用类加载器负责加载 classpath 上所指定的类库,当前工程当然属于 classpath 路径,所以使用应用类加载器加载 SPI 接口的实现类是可以加载成功的。

接下来言归正传,我们继续分析 ServiceLoader 类基于线程上下文类加载加载 SPI 接口实现类的过程。

3.4 源码追踪

在 load 的重载方法中,其实是创建了一个 ServiceLoader 类实例,传入的参数是目标接口 HelloSpi.class 和类加载器 AppClassLoader。

95_2.png ServiceLoader#load 重载方法2

而在这个 ServiceLoader 类的构造方法中,也进行了一些操作,具体见下面代码的注释。

private ServiceLoader(Class<S> svc, ClassLoader cl) {
    // 检查目标接口类是否为空,若为空,抛出 NullPointerException
    service = Objects.requireNonNull(svc, "Service interface cannot be null");

    // 若入参 cl 为空,默认使用系统类加载器(应用类加载器)
 loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl; // Java 安全管理相关,本文不详细说明 acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null; // 清空缓存提供者 providers ,重新加载所有 SPI 接口实现类 reload(); } 

ServiceLoader#reload() 方法实际上是对 ServiceLoader 类的私有成员变量 providers 做一个清空的操作,同时创建一个懒加载的迭代器 LazyIterator 对象,传入的参数是目标接口类和类加载器。

// Cached providers, in instantiation order
// 缓存提供者,实际上是存储 SPI 接口实现类对象,key 为实现类类名,value 为 SPI 接口实现类实例
private LinkedHashMap<String,S> providers = new LinkedHashMap<>();

// The current lazy-lookup iterator
// 当前懒加载迭代器,开始迭代时创建对象,然后放入 providers 中 private LazyIterator lookupIterator; public void reload() { // 清空 providers providers.clear(); // 创建懒加载迭代器 lookupIterator = new LazyIterator(service, loader); } 

至此,ServiceLoader 类已经做好了加载 SPI 接口实现类的前置准备,当程序在 for 循环中遍历 ServiceLoader 对象时,实际上是调用了 Iterator 接口的 hasNext 方法,最终调用的是上一步提到的 LazyIterator#hasNext() 方法,如果返回 true ,就调用 next 方法开始进行迭代。

public boolean hasNext() {
    // Java 访问控制上下文为null时,调用 hasNextService 方法,这里就是调用了 hasNextService 方法
    if (acc == null) {
        return hasNextService();
    } else {
 PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() { public Boolean run() { return hasNextService(); } }; return AccessController.doPrivileged(action, acc); } } 

而一般情况下 acc == null 的判断会是 true,继而会调用 LazyIterator#hasNextService() 方法,这个方法实际上是解析 META-INF/services 目录下的文件,生成 SPI 接口实现类迭代器,设置 nextName 属性,具体逻辑见下面代码中的注释。

// Github:https://github.com/Planeswalker23
private boolean hasNextService() {
    // 第一次进入此方法时 nextName == null
    if (nextName != null) {
        return true;
 } // configs 属性表示的是 URL 类型的 Enumeration 对象,第一次进入此方法时为null if (configs == null) { try { // PREFIX = "META-INF/services/",这也解释了为什么要在 META-INF/services/ 目录下建立接口全路径名的文件 // service 属性就是创建懒加载迭代器 LazyIterator 对象时传入的 service 对象,即 SPI 接口类 HelloSpi.class // 所以 fullName 变量就是 META-INF/services/HelloSpi ,代表了在该目录下创建的文件名 String fullName = PREFIX + service.getName(); // 若类加载器 loader 为 null,会通过系统类加载器加载配置文件 // 若不为 null,则通过该类加载器加载配置文件 if (loader == null) configs = ClassLoader.getSystemResources(fullName); else configs = loader.getResources(fullName); } catch (IOException x) { fail(service, "Error locating configuration files", x); } } // 第一次进入此方法时,pending 迭代器为 null while ((pending == null) || !pending.hasNext()) { // 若配置文件中没有数据,返回 false if (!configs.hasMoreElements()) { return false; } // 将配置文件中的全路径类名转换为迭代器的方式存储到 pending 属性中,每行代表 pending 属性中的一个元素 pending = parse(service, configs.nextElement()); } // 将 nextName 属性设置为 pending 迭代器的下一个元素 nextName = pending.next(); return true; } 

在执行完 hasNextService 方法后,已经完成了 SPI 配置文件的解析、生成了 SPI 标准接口实现类的迭代器、完成了下一个实现类类名 nextName 的赋值,最终返回 true,表示迭代器有 next 值,然后会调用迭代器的 next 方法,最终调用 LazyIterator#next() 方法,这个方法其实调用的是 LazyIterator#nextService() 方法,在这个方法中,完成了类的加载,类的实例化等工作,具体内容见下面代码注释。

// Github:https://github.com/Planeswalker23
private S nextService() {
    // 不是第一次调用 hasNextService 方法,作用是将 pending.next() 赋值给 nextName
    if (!hasNextService())
        throw new NoSuchElementException();
 // 标记了当前要进行加载和实例化的类名 String cn = nextName; // 然后将 nextName 属性置为 null nextName = null; Class<?> c = null; try { // 通过类名 cn 、false 属性(代表不进行初始化)、类加载器 loader(创建 LazyIterator 时传入的线程上下文类加载器,即应用类加载器)这三个参数调用 Class#forName 方法进行类的加载工作 c = Class.forName(cn, false, loader); } catch (ClassNotFoundException x) { fail(service, "Provider " + cn + " not found"); } if (!service.isAssignableFrom(c)) { fail(service, "Provider " + cn + " not a subtype"); } try { // 将加载成功的类进行实例化,然后强转为 HelloSpi 类型 S p = service.cast(c.newInstance()); // 将实例化后的对象放到 providers 属性中 providers.put(cn, p); // 返回实例化的对象,在 for 循环中通过调用 say 方法执行实现类的逻辑 return p; } catch (Throwable x) { fail(service, "Provider " + cn + " could not be instantiated", x); } throw new Error(); // This cannot happen } 

至此,就完成了 ServiceLoader 类基于线程上下文类加载器实现 Java SPI 机制的整个流程的源码分析。

3.5 为什么 SPI 接口实现类需要一个无参构造器

在 2、2 创建接口的实现类部分中,我提到过一句话:实现类必须要有无参构造函数,接下来分析一下提到这句话的原因。

其实很简单,在 LazyIterator#nextService() 方法中实例化加载完成的类时,是通过 Class#newInstance() 方法完成实例化的,这个方法的源码如下所示。

@CallerSensitive
public T newInstance() {
    // 省略大部分代码...

    Class<?>[] empty = {};
 // 调用 getConstructor0 获取无参构造函数,若没获取到会报 NoSuchMethodException final Constructor<T> c = getConstructor0(empty, Member.DECLARED); // 省略大部分代码... } // 获取无参构造函数 Class#getConstructor0 private Constructor<T> getConstructor0(Class<?>[] parameterTypes, int which) throws NoSuchMethodException { // 获取该类的所有构造函数,进行遍历 Constructor<T>[] constructors = privateGetDeclaredConstructors((which == Member.PUBLIC)); for (Constructor<T> constructor : constructors) { // 返回无参构造函数 if (arrayContentsEq(parameterTypes, constructor.getParameterTypes())) { return getReflectionFactory().copyConstructor(constructor); } } throw new NoSuchMethodException(getName() + ".<init>" + argumentTypesToString(parameterTypes)); } 

正如上面的代码所示,在进行类的实例化时,是通过调用 Class#getConstructor0 方法来获取构造器的,而这个方法获取的正是无参构造器,这也就是实现类必须要有无参构造函数的原因。

4. 小结

本文实现了一个基于 Java SPI 机制的的简单 demo,然后分析了 ServiceLoader 类基于线程上下文类加载器实现 Java SPI 的源码。

关于 Java SPI 的部分就总结到这里,如果有其他补充的希望能够告诉我,一起学习学习。

希望能够帮助到大家。

完工,告辞。

版权声明:本文为Planeswalker23所创,转载请带上原文链接,感谢。

5. 参考资料

本文使用 mdnice 排版

文章永久链接:https://tech.souyunku.com/28520

未经允许不得转载:搜云库技术团队 » Java SPI机制实现与源码分析

JetBrains 全家桶,激活、破解、教程

提供 JetBrains 全家桶激活码、注册码、破解补丁下载及详细激活教程,支持 IntelliJ IDEA、PyCharm、WebStorm 等工具的永久激活。无论是破解教程,还是最新激活码,均可免费获得,帮助开发者解决常见激活问题,确保轻松破解并快速使用 JetBrains 软件。获取免费的破解补丁和激活码,快速解决激活难题,全面覆盖 2024/2025 版本!

联系我们联系我们