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


    一、本文大纲

    image-20210127211959822

    二、ClassLoader 类加载器

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

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

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

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

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

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

    image-20210127155540938

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

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

    第一,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 参数,作为父类加载器。

    image-20210127200114958

    而在初始化 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 属性。

    image-20210127200352104

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

    image-20210127201719158

    从上面的运行结果中,我们也可以轻易看到不同的类加载器,是从不同的路径下加载不同的资源。而即便我们只是写一个 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实战代码

    image-20210127152133086

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

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

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

    image-20210127152921020

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

    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 是被传到这个地方

    image-20210127203656598

    那么不传这个 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 模块中

    image-20210127205932913

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

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

    image-20210127210255532

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

  • 相关阅读:
    数据库中Schema和Database有什么区别
    VS2015智能提示由英文改为中文
    分配数据库角色权限
    【转载】使用局部标准差实现图像的局部对比度增强算法
    RS485的常用电路设计
    c++对txt文件的读取与写入 【转载】
    OpencV使用fitEllipse拟合椭圆后,获取椭圆参数 【转载】
    C++指定编译代码语句(模块)
    C++自动创建文件夹
    vs2015中复制C++ DLL 和.pdb文件到C#工程中bin目录的设置方法
  • 原文地址:https://www.cnblogs.com/nicekk/p/14337224.html
Copyright © 2020-2023  润新知