阅读 Flink 源码前必会的知识 SPI 和 Classloader

一、本文大纲

阅读 Flink 源码前必会的知识 SPI 和 Classloader

二、ClassLoader 类加载器

1、Java 中的类加载器以及双亲委派机制

Java 中的类加载器,是 Java 运行时环境的一部分,负责动态加载 Java 类到 Java 虚拟机的内存中。

有了类加载器,Java 运行系统不需要知道文件与文件系统。

那么类加载器,什么类都加载吗?加载的规则是什么?

Java 中的类加载器有四种,分别是:

  • BootstrapClassLoader,*类加载器,加载JVM自身需要的类;
  • ExtClassLoader,他负责加载扩展类,如 jre/lib/ext 或 java.ext.dirs 目录下的类;
  • AppClassLoader,他负责加载应用类,所有 classpath 目录下的类都可以被这个类加载器加载;
  • 自定义类加载器,如果你要实现自己的类加载器,他的父类加载器都是AppClassLoader。

阅读 Flink 源码前必会的知识 SPI 和 Classloader

类加载器采用了双亲委派模式,其工作原理是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

双亲委派模式的好处是什么?

第一,Java 类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层次关系可以避免类的重复加载,当父类加载器已经加载过一次时,没有必要子类再去加载一次。

第二,考虑到安全因素,Java 核心 Api 类不会被随意替换,核心类永远是被上层的类加载器加载。如果我们自己定义了一个 java.lang.String 类,它会优先委派给 BootStrapClassLoader 去加载,加载完了就直接返回了。

如果我们定义了一个 java.lang.ExtString,能被加载吗?答案也是不能的,因为 java.lang 包是有权限控制的,自定义了这个包,会报一个错如下:

java.lang.SecurityException: Prohibited package name: java.lang

2、双亲委派机制源码浅析

Java 程序的入口就是 sun.misc.Launcher 类,我们可以从这个类开始看起。

下面是这个类的一些重要的属性,写在注释里了。

public class Launcher {
    private static URLStreamHandlerFactory factory = new Launcher.Factory();
    // static launchcher 实例
    private static Launcher launcher = new Launcher();
    // bootclassPath ,就是 BootStrapClassLoader 加载的系统资源
    private static String bootClassPath = System.getProperty("sun.boot.class.path");
    // 在 Launcher 构造方法中,会初始化 AppClassLoader,把它作为全局实例保存起来
    private ClassLoader loader;
    private static URLStreamHandler fileHandler;
    ......
}

这个类加载的时候,就会初始化 Launcher 实例,我们看一下无参构造方法。

 public Launcher() {
        Launcher.ExtClassLoader var1;
        try {
            // 获得 ExtClassLoader
            var1 = Launcher.ExtClassLoader.getExtClassLoader();
        } catch (IOException var10) {
            throw new InternalError("Could not create extension class loader", var10);
        }

        try {
            // 获得 AppClassLoader,并赋值到全局属性中
            this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
        } catch (IOException var9) {
            throw new InternalError("Could not create application class loader", var9);
        }
		
        // 把 AppClassLoader 的实例赋值到当前上下文的 ClassLoader 中,和当前线程绑定
        Thread.currentThread().setContextClassLoader(this.loader);
       // ...... 省略无关代码

    }

可以看到,先获得一个 ExtClassLoader ,再把 ExtClassLoader 作为父类加载器,传给 AppClassLoader。最终会调用这个方法,把 ExtClassLoader 传给 parent 参数,作为父类加载器。

阅读 Flink 源码前必会的知识 SPI 和 Classloader

而在初始化 ExtClassLoader 的时候,没有传参:

Launcher.ExtClassLoader var1;
        try {
            var1 = Launcher.ExtClassLoader.getExtClassLoader();
        } catch (IOException var10) {
            throw new InternalError("Could not create extension class loader", var10);
        }

而最终,给 ExtClassLoader 的 parent 传的参数是 null。可以先记住这个属性,下面在讲 ClassLoader 源码时会用到这个 parent 属性。

阅读 Flink 源码前必会的知识 SPI 和 Classloader

然后 Launcher 源码里面还有四个系统属性,值得我们运行一下看看,如下图

阅读 Flink 源码前必会的知识 SPI 和 Classloader

从上面的运行结果中,我们也可以轻易看到不同的类加载器,是从不同的路径下加载不同的资源。而即便我们只是写一个 Hello World,类加载器也会在后面默默给我们加载这么多类。

看完了 Launcher 类的代码,我们再来看 java.lang.ClassLoader 的代码,真正的双亲委派机制的源码是在这个类的 loaderClass 方法中。

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先,检查这个类是否已经被加载了,最终实现是一个 native 本地实现
            Class<?> c = findLoadedClass(name);
            // 如果还没有被加载,则开始架子啊
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    // 首先如果父加载器不为空,则使用父类加载器加载。Launcher 类里提到的 parent 就在这里使用的。
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        // 如果父加载器为空(比如 ExtClassLoader),就使用 BootStrapClassloader 来加载
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                }
				
                // 如果还没有找到,则使用 findClass 类来加载。也就是说如果我们自定义类加载器,就重写这个方法
                if (c == null) {
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

这段代码还是比较清晰的,加载类的时候,首先判断类是不是已经被加载过了,如果没有被加载过,则看自己的父类加载器是不是为空。如果不为空,则使用父类加载器加载;如果父类加载器为空,则使用 BootStrapClassLoader 加载。

最后,如果还是没有加载到,则使用 findClass 来加载类。

类加载器的基本原理就分析到这里,下面我们再来分析一个 Java 中有趣的概念,SPI。

三、SPI 技术

1、什么是 SPI,为什么要有 SPI

SPI 全称(Service Provide Interface),在 JAVA 中是一个比较重要的概念,在框架设计中被广泛使用。

在框架设计中,要遵循的原则是对扩展开放,对修改关闭,保证框架实现对于使用者来说是黑盒。因为框架不可能做好所有的事情,只能把共性的部分抽离出来进行流程化,然后留下一些扩展点让使用者去实现,这样不同的扩展就不用修改源代码或者对框架进行定制。也就是我们经常说的面向接口编程

我理解的 SPI 用更通俗的话来讲,就是一种可插拔技术。最容易理解的就是 USB,定义好 USB 的接口规范,不同的外设厂家根据 USB 的标准去制造自己的外设,如鼠标,键盘等。另外一个例子就是 JDBC,Java 定义好了 JDBC 的规范,不同的数据库厂商去实现这个规范。Java 并不会管某一个数据库是如何实现 JDBC 的接口的。

2、如何实现 SPI

这里我在 Github 上有一个工程,Flink-Practice,是公众号文章附带的代码,有需要可以下载:

Flink实战代码

阅读 Flink 源码前必会的知识 SPI 和 Classloader

实现 SPI 的话,要遵循下面的一些规范:

  • 服务提供者提供了接口的具体实现后,需要在资源文件夹中创建 META-INF/services 文件夹,并且新建一个以全类名为名字的文本文件,文件内容为实现类的全名(如图中下面的红框);
  • 接口实现类必须在工程的 classpath 下,也就是 maven 中需要加入依赖或者 jar 包引用到工程里(如图中的 serviceimpl 包,我就放在了当前工程下了,执行的时候,会把类编译成 class 文件放到当前工程的 classpath 下的);
  • SPI 的实现类中,必须有一个不带参数的空构造方法

执行测试类之后输出如下:

阅读 Flink 源码前必会的知识 SPI 和 Classloader

可以看到,实现了提供方接口的类,都被执行了。

3、SPI 源码浅析

入口在 ServiceLoader.load 方法这里

    public static <S> ServiceLoader<S> load(Class<S> service) {
        // 获取当前线程的上下文类加载器。ContextClassLoader 是每个线程绑定的
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return ServiceLoader.load(service, cl);
    }

首先需要知道,Thread.currentThread().getContextClassLoader(); 使用这个获取的类加载器是 AppClassLoader,因为我们的代码是在 main 函数执行的,而自定义的代码都是 AppClassLoader 加载的。

可以看到最终这个 classloader 是被传到这个地方

阅读 Flink 源码前必会的知识 SPI 和 Classloader

那么不传这个 loader 进来,就加载不到吗?答案是确实加载不到。

因为 ServiceLoader 是在 rt.jar 包中,而 rt.jar 包是 BootstrapClassLoader 加载的。而实现了接口提供者的接口的类,一般是第三方类,是在 classpath 下的,BootStrapClassLoader 能加载到 classpath 下的类吗?不能, AppClassLoader 才会去加载 classpath 的类。

所以,这里的上下文类加载器(ContextClassLoader ),它其实是破坏了双亲委派机制的,但是也为程序带来了巨大的灵活性和可扩展性。

其实 ServiceLoader 核心的逻辑就在这两个方法里

        private boolean hasNextService() {
            if (nextName != null) {
                return true;
            }
            if (configs == null) {
                try {
                    // 寻找 META-INF/services/类
                    String fullName = PREFIX + service.getName();
                    if (loader == null)
                        configs = ClassLoader.getSystemResources(fullName);
                    else
                        configs = loader.getResources(fullName);
                } catch (IOException x) {
                    fail(service, "Error locating configuration files", x);
                }
            }
            while ((pending == null) || !pending.hasNext()) {
                if (!configs.hasMoreElements()) {
                    return false;
                }
                // 解析这个类文件的所有内容
                pending = parse(service, configs.nextElement());
            }
            nextName = pending.next();
            return true;
        }

        private S nextService() {
            if (!hasNextService())
                throw new NoSuchElementException();
            String cn = nextName;
            nextName = null;
            Class<?> c = null;
            try {
                // 加载这个类
                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 {
                // 初始化这个类
                S p = service.cast(c.newInstance());
                providers.put(cn, p);
                return p;
            } catch (Throwable x) {
                fail(service,
                     "Provider " + cn + " could not be instantiated",
                     x);
            }
            throw new Error();          // This cannot happen
        }

寻找 META-INF/services/类,解析类的内容,构造 Class ,初始化,返回,就这么简单了。

4、SPI 的缺点以及 Dubbo 是如何重构 SPI 的

通过前面的分析,可以发现,JDK SPI 在查找扩展实现类的过程中,需要遍历 SPI 配置文件中定义的所有实现类,该过程中会将这些实现类全部实例化。如果 SPI 配置文件中定义了多个实现类,而我们只需要使用其中一个实现类时,就会生成不必要的对象。例如,在 Dubbo 中,org.apache.dubbo.rpc.Protocol 接口有 InjvmProtocol、DubboProtocol、RmiProtocol、HttpProtocol、HessianProtocol、ThriftProtocol 等多个实现,如果使用 JDK SPI,就会加载全部实现类,导致资源的浪费。

Dubbo SPI 不仅解决了上述资源浪费的问题,还对 SPI 配置文件扩展和修改。

首先,Dubbo 按照 SPI 配置文件的用途,将其分成了三类目录。

META-INF/services/ 目录:该目录下的 SPI 配置文件用来兼容 JDK SPI 。

META-INF/dubbo/ 目录:该目录用于存放用户自定义 SPI 配置文件。

META-INF/dubbo/internal/ 目录:该目录用于存放 Dubbo 内部使用的 SPI 配置文件。

然后,Dubbo 将 SPI 配置文件改成了 KV 格式,例如:

dubbo=org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol

其中 key 被称为扩展名(也就是 ExtensionName),当我们在为一个接口查找具体实现类时,可以指定扩展名来选择相应的扩展实现。例如,这里指定扩展名为 dubbo,Dubbo SPI 就知道我们要使用:org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol 这个扩展实现类,只实例化这一个扩展实现即可,无须实例化 SPI 配置文件中的其他扩展实现类。

在 Flink 源码中,有很多这样的 SPI 扩展点

在 flink-clients 模块中

阅读 Flink 源码前必会的知识 SPI 和 Classloader

执行器工厂的接口,有本地执行器的实现和远程执行器工厂类的实现,这些都是通过 SPI 来实现的。

另外,在 Flink Clients 入口类 CliFronted 中,也使用了经典的 ContextClassLoader 用法,使用反射的方式来执行用户程序中编写的 main 方法

阅读 Flink 源码前必会的知识 SPI 和 Classloader

下一篇文章,我们来分析 Flink-Clients 的源码实现,敬请期待了

上一篇:Java 类加载器(ClassLoader)的实际使用场景有哪些?


下一篇:java之类加载机制