文章目录

  • 阅读Flink源码前必会的知识
  • Java 8 异步编程 CompletableFuture 全解析
  • 大纲
  • 一、异步编程
  • 二、抛出一个问题:如何实现烧水泡茶的程序
  • 三、Java5 的 Future 实现的异步编程
  • 四、CompletableFuture 类功能概览
  • 五、CompletableFuture 接口精讲
  • 1、提交执行的静态方法
  • (1)run 和 supply 的区别
  • (2)一个参数和两个参数的区别
  • 2、串行关系 api
  • (1) thenApply 和 thenApplyAsync 使用
  • (2)thenApply 和 thenApplyAsync 的区别
  • (3)thenCompose 的使用
  • 3、And 汇聚关系 Api
  • (1)thenCombine 的使用
  • (2)thenAcceptBoth
  • (3)runAfterBoth
  • 4、Or 汇聚关系 Api
  • (1)applyToEither
  • (2)acceptEither
  • (3)runAfterEither
  • 5、处理异常
  • 六、烧水泡茶程序的实现
  • 1、使用 Thread 多线程和 CountDownLatch 来实现
  • 2、使用 CompletableFuture 来实现
  • 七、总结
  • 命令行解析库 Apache Commons Cli
  • 一、介绍一下 Apache Commons Cli,有一个感官的认识
  • 二、核心概念
  • 三、用一个例子就能理解全部了
  • SPI 和 ClassLoader
  • 一、ClassLoader 类加载器
  • 1、Java 中的类加载器以及双亲委派机制
  • Java 中的类加载器有四种,分别是:
  • 双亲委派模式的好处是什么?
  • 2、双亲委派机制源码浅析
  • 三、SPI 技术
  • 1、什么是 SPI,为什么要有 SPI
  • 2、如何实现 SPI
  • 3、SPI 源码浅析
  • 4、SPI 的缺点以及 Dubbo 是如何重构 SPI 的
  • 四、Flink 源码中使用到 SPI 和 Classloader 的地方


阅读Flink源码前必会的知识

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bexsnuF1-1652708639196)(https://seniscz.oss-cn-shanghai.aliyuncs.com/TyporaImg/flink-header-logo.svg)]

Java 8 异步编程 CompletableFuture 全解析

大纲

flink源码编译完后运行报错 flink源码怎么看_异步编程

一、异步编程

通常来说,程序都是顺序执行,同一时刻只会发生一件事情。如果一个函数依赖于另一个函数的结果,它只能等待那个函数结束才能继续执行,从用户角度来说,整个程序才算执行完毕。

但现在的计算机普遍拥有多核 CPU,在那里干等着毫无意义,完全可以在另一个处理器内核上干其他工作,耗时长的任务结束之后会主动通知你。这就是异步编程的出发点:充分使用多核 CPU 的优势,最大程度提高程序性能。

一句话来说:所谓异步编程,就是实现一个无需等待被调用函数的返回值而让操作继续运行的方法。

二、抛出一个问题:如何实现烧水泡茶的程序

flink源码编译完后运行报错 flink源码怎么看_大数据_02

最后我们会使用传统方式和 Java8 异步编程方式分别实现,来对比一下实现复杂度。

三、Java5 的 Future 实现的异步编程

Future 是 Java 5 添加的类,用来描述一个异步计算的结果。你可以使用 isDone() 方法检查计算是否完成,或者使用 get() 方法阻塞住调用线程,直到计算完成返回结果,也可以使用 cancel() 方法停止任务的执行。

public static void main(String[] args) throws InterruptedException, ExecutionException {
        ExecutorService es = Executors.newFixedThreadPool(5);
        Future<Integer> f = es.submit(() -> 100);
        System.out.println(f.get());
        es.shutdown();
    }

虽然 Future 提供了异步执行任务的能力,但是对于结果的获取却是很不方便,只能通过阻塞或者轮询的方式得到任务的结果。阻塞的方式显然和我们异步编程的初衷相违背,轮询的方式又会耗费无谓的 CPU 资源,而且也不能及时的获取结果。

当然,很多其他的语言采用回调的方式来实现异步编程,比如 Node.js;Java 的一些框架,比如 Netty,Google Guava 也扩展了 Future 接口,提供了很多回调的机制,封装了工具类,辅助异步编程开发。

Java 作为老牌编程语言,自然也不会落伍。在 Java 8 中,新增了一个包含 50 多个方法的类:CompletableFuture,提供了非常强大的 Future 扩展功能,可以帮助我们简化异步编程的复杂性,提供函数式编程的能力。

四、CompletableFuture 类功能概览

如下图是 CompletableFuture 实现的接口:

flink源码编译完后运行报错 flink源码怎么看_flink_03

它实现了 Future 接口,拥有 Future 所有的特性,比如可以使用 get() 方法获取返回值等;还实现了 CompletionStage 接口,这个接口有超过 40 个方法,功能太丰富了,它主要是为了编排任务的工作流。

我们可以把工作流和工作流之间的关系分类为三种:串行关系,并行关系,汇聚关系。

  • 串行关系

提供了如下的 api 来实现(先大致浏览一遍):

CompletionStage<R> thenApply(fn);
CompletionStage<R> thenApplyAsync(fn);
CompletionStage<Void> thenAccept(consumer);
CompletionStage<Void> thenAcceptAsync(consumer);
CompletionStage<Void> thenRun(action);
CompletionStage<Void> thenRunAsync(action);
CompletionStage<R> thenCompose(fn);
CompletionStage<R> thenComposeAsync(fn);
  • 并行关系

flink源码编译完后运行报错 flink源码怎么看_flink_04

多线程异

  • 汇聚关系

flink源码编译完后运行报错 flink源码怎么看_异步编程_05

汇聚关系,又分为 AND 汇聚关系和 OR 汇聚关系:

AND 汇聚关系,就是所有依赖的任务都完成之后再执行;OR 汇聚关系,就是依赖的任务中有一个执行完成,就开始执行。

AND 汇聚关系由这些接口表达:

CompletionStage<R> thenCombine(other, fn);
CompletionStage<R> thenCombineAsync(other, fn);
CompletionStage<Void> thenAcceptBoth(other, consumer);
CompletionStage<Void> thenAcceptBothAsync(other, consumer);
CompletionStage<Void> runAfterBoth(other, action);
CompletionStage<Void> runAfterBothAsync(other, action);

OR 汇聚关系由这些接口来表达:

CompletionStage applyToEither(other, fn);
CompletionStage applyToEitherAsync(other, fn);
CompletionStage acceptEither(other, consumer);
CompletionStage acceptEitherAsync(other, consumer);
CompletionStage runAfterEither(other, action);
CompletionStage runAfterEitherAsync(other, action);

五、CompletableFuture 接口精讲

1、提交执行的静态方法

方法名

描述

runAsync(Runnable runnable)

执行异步代码,使用 ForkJoinPool.commonPool() 作为它的线程池

runAsync(Runnable runnable, Executor executor)

执行异步代码,使用指定的线程池

supplyAsync(Supplier supplier)

异步执行代码,有返回值,使用 ForkJoinPool.commonPool() 作为它的线程池

supplyAsync(Supplier supplier, Executor executor)

异步执行代码,有返回值,使用指定的线程池执行

上述四个方法,都是提交任务的,runAsync 方法需要传入一个实现了 Runnable 接口的方法,supplyAsync 需要传入一个实现了 Supplier 接口的方法,实现 get 方法,返回一个值。

(1)run 和 supply 的区别

run 就是执行一个方法,没有返回值,supply 执行一个方法,有返回值。

(2)一个参数和两个参数的区别

第二个参数是线程池,如果没有传,则使用自带的 ForkJoinPool.commonPool() 作为线程池,这个线程池默认创建的线程数是 CPU 的核数(也可以通过 JVM option:-Djava.util.concurrent.ForkJoinPool.common.parallelism 来设置 ForkJoinPool 线程池的线程数)

2、串行关系 api

这些 api 之间主要是能否获得前一个任务的返回值与自己是否有返回值的区别。

api

是否可获得前一个任务的返回值

是否有返回值

thenApply



thenAccept



thenRun

不能


thenCompose



(1) thenApply 和 thenApplyAsync 使用

thenApply 和 thenApplyAsync 把两个并行的任务串行化,另一个任务在获得上一个任务的返回值之后,做一些加工和转换。它也是有返回值的。

public class BasicFuture4 {

    @Data
    @AllArgsConstructor
    @ToString
    static class Student {
        private String name;
    }
    
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<Student> future = CompletableFuture.supplyAsync(() -> "Jack")
                .thenApply(s -> s + " Smith")
                .thenApply(String::toUpperCase)
                .thenApplyAsync(Student::new);
        System.out.println(future.get());
    }

}

结果可以看到,输入是一个字符串,拼接了一个字符串,转换成大写,new 了一个 Student 对象返回。

BasicFuture4.Student(name=JACK SMITH)

和 thenApply 一起的还有 thenAccept 和 thenRun,thenAccept 能获得到前一个任务的返回值,但是自身没有返回值;thenRun 不能获得前一个任务的返回值,自身也没有返回值。

(2)thenApply 和 thenApplyAsync 的区别

这两个方法的区别,在于谁去执行任务。如果使用 thenApplyAsync,那么执行的线程是从 ForkJoinPool.commonPool() 或者自己定义的线程池中取线程去执行。如果使用 thenApply,又分两种情况,如果 supplyAsync 方法执行速度特别快,那么 thenApply 任务就使用主线程执行,如果 supplyAsync 执行速度特别慢,就是和 supplyAsync 执行线程一样。

可以使用下面的例子演示一下:

package com.dsj361.future;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

/**
 * @Author wangkai
 */
public class BasicFuture8 {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        System.out.println("----------supplyAsync 执行很快");
        CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
            System.out.println(Thread.currentThread().getName());
            return "1";
        }).thenApply(s -> {
            System.out.println(Thread.currentThread().getName());
            return "2";
        });
        System.out.println(future1.get());

        System.out.println("----------supplyAsync 执行很慢");
        CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
            }
            System.out.println(Thread.currentThread().getName());
            return "1";
        }).thenApply(s -> {
            System.out.println(Thread.currentThread().getName());
            return "2";
        });
        System.out.println(future2.get());
    }
}

执行结果:

----------supplyAsync 执行很快
ForkJoinPool.commonPool-worker-1
main
2
----------supplyAsync 执行很慢
ForkJoinPool.commonPool-worker-1
ForkJoinPool.commonPool-worker-1
2
(3)thenCompose 的使用

假设有两个异步任务,第二个任务想要获取第一个任务的返回值,并且做运算,我们可以用 thenCompose。此时使用 thenApply 也可以实现,看一段代码发现他们的区别:

public class BasicFuture9 {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<String> future = getLastOne().thenCompose(BasicFuture9::getLastTwo);
        System.out.println(future.get());

        CompletableFuture<CompletableFuture<String>> future2 = getLastOne().thenApply(s -> getLastTwo(s));
        System.out.println(future2.get().get());
    }

    public static CompletableFuture<String> getLastOne(){
        return CompletableFuture.supplyAsync(()-> "topOne");
    }

    public static CompletableFuture<String> getLastTwo(String s){
        return CompletableFuture.supplyAsync(()-> s + "  topTwo");
    }
}

可以看到使用 thenApply 的时候,需要使用两个 get() 方法才能获取到最终的返回值,使用 thenCompose 只要一个即可。

3、And 汇聚关系 Api
(1)thenCombine 的使用

加入我们要计算两个异步方法返回值的和,就必须要等到两个异步任务都计算完才能求和,此时可以用 thenCombine 来完成。

public static void main(String[] args) throws ExecutionException, InterruptedException {
    CompletableFuture<Integer> thenComposeOne = CompletableFuture.supplyAsync(() -> 192);
    CompletableFuture<Integer> thenComposeTwo = CompletableFuture.supplyAsync(() -> 196);
    CompletableFuture<Integer> thenComposeCount = thenComposeOne
        .thenCombine(thenComposeTwo, (s, y) -> s + y);

    thenComposeOne.thenAcceptBoth(thenComposeTwo,(s,y)-> System.out.println("thenAcceptBoth"));
    thenComposeOne.runAfterBoth(thenComposeTwo, () -> System.out.println("runAfterBoth"));

    System.out.println(thenComposeCount.get());
}

可以看到 thenCombine 第二个参数是一个 Function 函数,前面两个异步任务都完成之后,使用这个函数来完成一些运算。

(2)thenAcceptBoth

接收前面两个异步任务的结果,执行一个回调函数,但是这个回调函数没有返回值。

(3)runAfterBoth

接收前面两个异步任务的结果,但是回调函数,不接收参数,也不返回值。

4、Or 汇聚关系 Api
public class BasicFuture11 {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<Integer> thenComposeOne = CompletableFuture.supplyAsync(() -> 192);
        CompletableFuture<Integer> thenComposeTwo = CompletableFuture.supplyAsync(() -> 196);
        CompletableFuture<Integer> thenComposeCount = thenComposeOne
                .applyToEither(thenComposeTwo, s -> s + 1);

        thenComposeOne.acceptEither(thenComposeTwo,s -> {});
        
        thenComposeOne.runAfterEither(thenComposeTwo,()->{});

        System.out.println(thenComposeCount.get());
    }
}
(1)applyToEither

任何一个执行完就执行回调方法,回调方法接收一个参数,有返回值

(2)acceptEither

任何一个执行完就执行回调方法,回调方法接收一个参数,无返回值

(3)runAfterEither

任何一个执行完就执行回调方法,回调方法不接收参数,也无返回值

5、处理异常

上面我们讲了如何把几个异步任务编排起来,执行一些串行或者汇聚操作。还有一个重要的地方,就是异常的处理。

先看下面的例子:

public static void main(String[] args) throws ExecutionException, InterruptedException {
    CompletableFuture.supplyAsync(() -> {
        System.out.println("execute one ");
        return 100;
    })
        .thenApply(s -> 10 / 0)
        .thenRun(() -> System.out.println("thenRun"))
        .thenAccept(s -> System.out.println("thenAccept"));

    CompletableFuture.runAsync(() -> System.out.println("other"));
}

结果:

execute one 
other

可以发现,只要链条上有一个任务发生了异常,这个链条下面的任务都不再执行了。

但是 main 方法上的接下来的代码还是会执行的。

所以这个时候,需要合理的去处理异常来完成一些收尾的工作。

public class BasicFuture12 {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture.supplyAsync(() -> {
            System.out.println("execute one ");
            return 100;
        })
                .thenApply(s -> 10 / 0)
                .thenRun(() -> System.out.println("thenRun"))
                .thenAccept(s -> System.out.println("thenAccept"))
                .exceptionally(s -> {
                    System.out.println("异常处理");
                    return null;
                });

        CompletableFuture.runAsync(() -> System.out.println("other"));
    }
}

可以使用 exceptionally 来处理异常。

使用 handle() 方法也可以处理异常。但是 handle() 方法的不同之处在于,即使没有发生异常,也会执行。

六、烧水泡茶程序的实现

1、使用 Thread 多线程和 CountDownLatch 来实现
public class MakeTee {

    private static CountDownLatch countDownLatch = new CountDownLatch(2);

    static class HeatUpWater implements Runnable {

        private CountDownLatch countDownLatch;

        public HeatUpWater(CountDownLatch countDownLatch) {
            this.countDownLatch = countDownLatch;
        }
        @Override
        public void run() {
            try {
                System.out.println("洗水壶");
                Thread.sleep(1000);
                System.out.println("烧开水");
                Thread.sleep(5000);
                countDownLatch.countDown();
            } catch (InterruptedException e) {
            }

        }
    }

    static class PrepareTee implements Runnable {
        private CountDownLatch countDownLatch;

        public PrepareTee(CountDownLatch countDownLatch) {
            this.countDownLatch = countDownLatch;
        }

        @Override
        public void run() {
            try {
                System.out.println("洗茶壶");
                Thread.sleep(1000);
                System.out.println("洗茶杯");
                Thread.sleep(1000);
                System.out.println("拿茶叶");
                Thread.sleep(1000);
                countDownLatch.countDown();
            } catch (InterruptedException e) {
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        new Thread(new HeatUpWater(countDownLatch) ).start();
        new Thread(new PrepareTee(countDownLatch)).start();
        countDownLatch.await();
        System.out.println("准备就绪,开始泡茶");
    }
}

这里我们使用两个线程,分别执行烧水和泡茶的程序,使用 CountDownLatch 来协调两个线程的进度,等到他们都执行完成之后,再执行泡茶的动作。

可以看到这种方法,多了很多不必要的代码,new Thread,人工维护 CountDownLatch 的进度。

2、使用 CompletableFuture 来实现
public class MakeTeeFuture {

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        CompletableFuture<Void> future1 = CompletableFuture.runAsync(() -> {
            try {
                System.out.println("洗水壶");
                Thread.sleep(1000);
                System.out.println("烧开水");
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        CompletableFuture<Void> future2 = CompletableFuture.runAsync(() -> {
            try {
                System.out.println("洗茶壶");
                Thread.sleep(1000);
                System.out.println("洗茶杯");
                Thread.sleep(1000);
                System.out.println("拿茶叶");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        CompletableFuture<Void> finish = future1.runAfterBoth(future2, () -> {
            System.out.println("准备完毕,开始泡茶");
        });
        System.out.println(finish.get());
    }
}

这个程序极度简单,无需手工维护线程,给任务分配线程的工作也不需要关注。

同时语义也更加清晰,future1.runAfterBoth(future2,…) 能够清晰的表述“任务 3 要等到任务 1 和任务 2 都完成之后才能继续开始”

然后代码更加简练并且专注于业务逻辑,几乎所有的代码都是业务逻辑相关的。

七、总结

本文介绍了异步编程的概念,以及 Java8 的 CompletableFuture 是如何优雅的处理多个异步任务之间的协调工作的。CompletableFuture 能够极大简化我们对于异步任务编排的工作,Flink 在提交任务时,也是使用这种异步任务的方式,去编排提交时和提交后对于任务状态处理的一些工作的。

命令行解析库 Apache Commons Cli

一、介绍一下 Apache Commons Cli,有一个感官的认识

我们在使用一些开源项目时,会敲一些命令,有简单的,比如 hadoop version;有复杂的,比如 flink run … ,hdfs dfs -ls ,等等。一般这些命令后面有很多的参数,特别是在往 Yarn 上提交 Flink 任务时,后面要设置很多属性。

这些命令实际上最终还是会启动虚拟机来执行的,如果你用 vi 看过这些命令,就可以发现,最终都是调用的 java xxxx,来执行的。那么命令后面的参数会传到 main 方法里,然后 main 方法的代码再去解析这些个参数。

参数比较少,一两个月还好。参数一多,那解析起来简直就是灾难了,会有很多 switch ,if else 。

Apache Commons Cli 这个库就是为了解决这个问题的。它封装了命令行的基本元素,帮助程序去解析传递给程序的命令行参数。也可以打印帮助信息来说明这个命令行的有效参数。

二、核心概念

核心概念只有三个,那就是命令行程序的处理流程

  • 定义阶段

首先我们要通过 Options 对象,来创建实例,把我们可能要处理的所有参数,都预先定义好。这样一方面,可以打印出命令行参数的使用帮助,另一方面才能处理掉参数的所有情况;

  • 解析阶段

解析阶段,就是把用户命令行传到程序里的参数,通过 CommandLineParser 类解析出来,解析出来 CommandLine 对象。

  • 询问阶段

询问阶段,就是把程序中需要用的参数从 CommandLine 中解析出来,并根据不同的参数,走到不同的应用代码分支。这个阶段在用户应用逻辑中实现。

三、用一个例子就能理解全部了

上面我们并没有讲太多代码细节,因为把全局的概念弄清楚,搞懂代码其实就没什么难度了。最后用一个例子,把上面的知识点都串起来。

package com.hudsun.flink.commonscli;

import org.apache.commons.cli.*;


public class CommonsCli01 {

    public static void main(String[] args1) throws ParseException {
        String[] arg = { "-h", "-c", "config.xml" };
        testOptions(arg);
    }

    public static void testOptions(String[] args) {
        // Options 准备阶段,准备好所有的选项
        Options options = new Options();
        Option opt = new Option("h", "help", false, "Print help");
        opt.setRequired(false);
        options.addOption(opt);

        opt = new Option("c", "configFile", true, "Name server config properties file");
        opt.setRequired(false);
        options.addOption(opt);

        opt = new Option("p", "printConfigItem", false, "Print all config item");
        opt.setRequired(false);
        options.addOption(opt);

        // 帮助打印
        HelpFormatter hf = new HelpFormatter();
        hf.setWidth(110);

        // 解析从命令行传过来的参数
        CommandLine commandLine = null;
        CommandLineParser parser = new PosixParser();
        try {
            // 得到 CommandLine 对象
            commandLine = parser.parse(options, args);

            // 根据不同的参数处理不同的业务逻辑
            // h 打印帮助信息
            if (commandLine.hasOption('h')) {
                // 打印使用帮助
                hf.printHelp("testApp", options, true);
            }

            // 打印opts的名称和值
            System.out.println("--------------------------------------");
            Option[] opts = commandLine.getOptions();
            if (opts != null) {
                for (Option opt1 : opts) {
                    String name = opt1.getLongOpt();
                    String value = commandLine.getOptionValue(name);
                    System.out.println(name + "=>" + value);
                }
            }
        }
        catch (ParseException e) {
            hf.printHelp("testApp", options, true);
        }
    }
}

SPI 和 ClassLoader

一、ClassLoader 类加载器

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

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

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

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

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

flink源码编译完后运行报错 flink源码怎么看_flink_06

类加载器采用了双亲委派模式,其工作原理是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行。

如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器。

如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

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

第一,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源码编译完后运行报错 flink源码怎么看_flink源码编译完后运行报错_07

而在初始化 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源码编译完后运行报错 flink源码怎么看_异步编程_08

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

public static void main(String[] args) {
        System.out.println("Hello World! \n");

        String path1 = System.getProperty("sun.boot.class.path");
        System.out.println("BootStrapClassLoader : " + path1);

        String path2 = System.getProperty("java.ext.dirs");
        System.out.println("ExtClassLoader:" + path2);

        String path3 = System.getProperty("java.class.path");
        System.out.println("AppLcassLoader: " + path3);


    }

flink源码编译完后运行报错 flink源码怎么看_System_09

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

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

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

    @Override
    public void doSomething() {
        System.out.println("Hello,MyService A!");
    }
}
/**
 * 实现类 B
 */
public class MyServiceB implements MyService {
    @Override
    public void doSomething() {
        System.out.println("Hello,MyService B!");
    }
}

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

public class TestMyServiceSPI {

    public static void main(String[] args) {
        ServiceLoader<MyService> services = ServiceLoader.load(MyService.class);
        Iterator<MyService> iterator = services.iterator();
        while (iterator.hasNext()) {
            MyService myService = iterator.next();
            myService.doSomething();
        }
    }
}

flink源码编译完后运行报错 flink源码怎么看_大数据_10

flink源码编译完后运行报错 flink源码怎么看_flink源码编译完后运行报错_11

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

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源码编译完后运行报错 flink源码怎么看_flink源码编译完后运行报错_12

那么不传这个 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 和 Classloader 的地方

在 Flink 源码中,有很多这样的 SPI 扩展点在 flink-clients 模块中

flink源码编译完后运行报错 flink源码怎么看_flink_13

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

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

flink源码编译完后运行报错 flink源码怎么看_flink_14