CompletableFuture:自顶向下

1 写在前面

1.1 序

        伴随着时代的发展,CPU由最初的单核处理器进化到多核处理器,再到现在的超线程处理器(也就是俗称的8核16线程,按理来说核数应该等于线程数,但超线程通过任务调度机制,最大化单核性能从而达到双线程水平)。厂商努力在硬件方面不断提升,我们软件层面也是要充分利用的嘛,而想要提高性能、缩短响应时间、最大化CPU运行效率,最简单粗暴的方式就是多线程和异步

         多线程相信大家都知道,而异步的概念也很简单——与“同步”正相反,利用多线程或中间件,将整个流程中的某些动作,由阻塞的同步操作变为非阻塞的异步操作。

        举个生活中的例子,穿袜子、穿鞋子、戴帽子是出门前的整个大流程;其中穿袜子和穿鞋子是必须阻塞的同步操作,因为总不能把袜子套鞋子外面吧?但戴帽子是可以独立于其他动作的,因此可以对其进行异步调用,拉个新的右手线程来戴帽子。

        通过这个例子大致能明白:为什么要用异步调用?什么时候用异步调用?怎么实现异步调用?答案很简单。

  • 部分场景对于响应时间有较高的要求,我们必须通过异步调用,来并发执行一些动作来节省时间;就比如你上班快迟到了,恨不得边走路边穿裤子刷牙吃早饭...
  • 某些非常耗时但又不是很紧要(也有可能是很重要的,这就需要设计非常严密的补偿或一致性机制)的操作,完全没必要在那硬等结果,比如去寄信一般都是扔邮箱就走,不可能站那等着信寄到;这种场景下就可以考虑异步调用。
  • 利用多线程或者消息队列,比较传统的是在本服务内起个新线程,胜在简单好使。但是线程多了服务器压力会很大,这时可以引入新的消息中间件进行解耦和异步调用,但也需要考虑系统复杂性提升的成本和隐患。

1.2 传统多线程

        本文并不讨论消息队列异步场景,有兴趣可以参考笔者之前关于RabbitMQ的文章(RabbitMQ异步与重试机制)。

        回忆一下传统的多线程启动方式,由于Thread和Runnable无法获取返回值,因此用比较灵活的Future + Callable。使用方法也很简单,重写Callable的run()方法塞进FutureTask内,这个FutureTask就可以管理Callable对应任务的状态了;但此时新线程还没有启动,还需要将FutureTask塞进一个新线程里并启动。

    private static void test6() {
        Stopwatch stopwatch = Stopwatch.createStarted();

        //创建含有返回值的任务
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int r = 0;
                for (int i = 0; i < 10; i++) {
                    Thread.sleep(100);
                    r++;
                }
                return r;
            }
        };

        //让FutureTask管理该任务
        FutureTask<Integer> futureTask = new FutureTask<>(callable);

        //放入新线程并启动
        Thread thread = new Thread(futureTask);
        thread.start();

        //阻塞获取返回值
        try {
            Integer integer = futureTask.get();
            System.out.println(integer);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }

        long elapsed = stopwatch.stop().elapsed(TimeUnit.MILLISECONDS);
        System.out.println("cost: "  + elapsed);
    }

        流程很清晰明了,最后获取返回值为自增的10,阻塞耗时1100ms也符合预期。看起来好像Future也够用,但如果考虑一些比较复杂的场景,就显得有些捉襟见肘了。

        比如整个方法要完成煮饭、吃饭、洗衣服三件事,很明显煮饭和洗衣服是可以并行的,因此我们在两个线程内分别执行;但是吃饭依赖于煮饭的结果,所以要先等待煮饭执行完毕,这倒也不是什么大事,Future的get()方法可以阻塞获取线程的执行返回值,isDone()方法可以获取线程当前的执行状态。因此可以这样实现:

//伪代码
fun1() {
    //洗衣服
    new Thread("洗衣服").start();
    
    //煮饭
    FutureTask cook = new FutureTask("煮饭");
    new Thread(cook).start();
    //阻塞获取煮饭结果
    cook.get();
    //煮完后开始吃
    eat();
}

fun2() {
    //洗衣服
    new Thread("洗衣服").start();
    
    //煮饭
    FutureTask cook = new FutureTask("煮饭");
    new Thread(cook).start();
    //自旋获取执行结果
    while (true) {
        if (cook.isDone()) {
            break;
        }
    }
    //阻塞获取煮饭结果
    cook.get();
    //煮完后开始吃
    eat();
}

        代码看起来其实还好,但如果再复杂一点,现在只是吃饭需要等煮饭的结果,再加一个切菜、再加一个洗菜、再加一个买菜...那就会变成地狱般的代码,这里一个while自旋、那里一个get阻塞。

        为解决这种复杂的依赖关系和链式调用,CompletableFuture闪亮登场!

1.3 初识CompletableFuture

        CompletableFuture的功能之强大是难以想象的,例如最基础的主线程等待多个异步任务完成,只需要一个简单的allOf()方法,而传统的Future需要使用无数个自旋锁;还可以随意指定不同任务之间的依赖关系,比如C依赖B的结果、B依赖A的结果,这样一条由C指向A的链条只需要这样一行代码:

    private static void test7() {
        CompletableFuture.runAsync(() -> System.out.println("hello"))
                .thenRun(() -> System.out.println("world"))
                .thenRun(() -> System.out.println(":)"));

        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

        这样就实现了依次打印“hello”、“world”、“:)”(下面的sleep是为了让线程不要立即退出),这对于Future来说是难以想象的。这种只是最基础的应用,我们还可以将上一个异步任务的返回值作为下一个异步任务的参数;甚至可以任意组合多个异步任务,这几个异步任务全部/任意一个执行完后、再执行另外的任务。除了组合异步任务,还可以自定义任务是同步还是异步,对任务的异常进行管理,自定义任务回调等等等等——可谓是想怎么玩就怎么玩。

        来总结一下,什么时候可以使用CompletableFuture:

  • 多并行任务阻塞,比如一个方法需要完成3件可并行的事,这3件事全部完成前阻塞主线程;用Future不是说不能实现,就是逻辑可能复杂些,但用CompletableFuture就是一个allOf()的事。
  • 复杂的异步任务编排,比如我想拿A任务的结果作为B任务处理的参数,再使B任务完成后异步完成C任务,这个用Future写就有点力不从心了。而CompletableFuture提供了强大的同步、异步任务编排和链式调用,非常好用。

        下面我们就来正式介绍强大的CompletableFuture

2 CompletableFuture:自顶向下

        既然“自顶向下”的牛都吹出来了,那笔者就先介绍CompletableFuture的强大之处和魅力所在,再循序渐进地分析实现方式和源码。

2.1 提交任务:有/无返回值

        最基础的肯定是提交任务嘛,来看看提交任务的几个静态方法:

ff52161dc5aa480e824ad2f351f3abbf.png

        主要看supplyAsync()和runAsync(),看屁股后面的返回值大概能猜到,runAsync()是无返回值的,因为他返回了个“Void”泛型的CompletableFuture,那supplyAsync()自然就是有返回值的。且这两个方法都有两种重载形式:

7bea2f6fb4e245fbb74e1230c4efcdaf.png

         异步任务的逻辑肯定是必传的,后面还有个非必传的“Executor”。接触过线程池的小伙伴肯定不陌生,Executor是ThreadPoolExecutor的顶级接口,线程是非常稀缺且昂贵的资源,能复用我们要尽量复用。CompletableFuture自然也采用了线程复用的思想,内部默认使用进程公用线程池ForkJoinPool

    private static final boolean useCommonPool =
        (ForkJoinPool.getCommonPoolParallelism() > 1);

    /**
     * Default executor -- ForkJoinPool.commonPool() unless it cannot
     * support parallelism.
     */
    private static final Executor asyncPool = useCommonPool ?
        ForkJoinPool.commonPool() : new ThreadPerTaskExecutor();

    /** Fallback if ForkJoinPool.commonPool() cannot support parallelism */
    static final class ThreadPerTaskExecutor implements Executor {
        public void execute(Runnable r) { new Thread(r).start(); }
    }

        和线程池有关的这3部分我们挨个看。

        先是一个标志位“useCommonPool”,其判断条件是“ForkJoinPool.getCommonPoolParallelism() > 1”,这个方法的返回值简单来说,就是当前机器CPU数 - 1;那就意味着机器起码得3核,标志位才会为true。

        “asyncPool”依靠该标志位,true则使用进程公用的ForkJoinPool,false则使用一个内部类ThreadPerTaskExecutor——这个就比较可怕了,看逻辑是来一个任务开一个线程,程序早晚爆炸。但实际上用ForkJoinPool也并不理想,因为这个线程池是整个进程共用的,如果很多地方都使用CompletableFuture提交任务,且都没有指定线程池,那所有任务都落在这个线程池,他也受不了啊。

        因此,请尽量使用自定义线程池来提交任务!

2.1.1 题外话:ForkJoinPool

        可能平时用不太到ForkJoinPool,但既然提到了,就插几句题外话。

        ForkJoinPool和普通的线程池是非常不同的,他用于解决大型计算问题,Fork代表分叉、Join代表合并,这个池子解决的任务就是可以被拆解、再被合并的问题。听说过分布式计算引擎的小伙伴也许知道“MapReduce”采取分治的思想,将一个巨大的问题分解成许多个子问题,再将子问题提交给集群内的服务器进行运算,最后将结果统计起来得到答案。类似于上小学时老师数人,男生站一排女生站一排,分别清点人数相加。

        ForkJoinPool也是如此,他能够支持你在遇到大问题时,将其拆成小问题并fork()提交进工作队列,再分别计算通过join()获取结果;不仅如此,他还采用了精彩的“任务窃取机制”——ForkJoinPool每个内部线程都有属于自己的工作队列,即10个线程对应10条队列。这与常规线程池是非常不同的,常规线程池是所有线程从同一个工作队列里取任务执行,ForkJoinPool这样做就是为了实现“任务窃取”。

        “任务窃取”顾名思义,我没事干了我就去你那偷个任务,刚说到ForkJoinPool每个线程有自己的队列,那肯定就是去别人的队列偷一个嘛。但这个问题没那么简单,常规线程池工作队列是FIFO先进先出,从头部取任务执行,这也是队列的基本定义;但付出的代价就是要加锁,不然并发获取任务和插入任务肯定无法保证线程安全。

        而ForkJoinPool这种单个任务计算用时很短、频繁切换任务的特性,如果用常规队列,那大部分时间都耗在获取锁释放锁上了。这个问题是怎么解决的?我们来看看源码。

//成员变量
volatile WorkQueue[] workQueues;     // main registry

//内部类
static final class WorkQueue {
    ForkJoinTask<?>[] array;   // the elements (initially unallocated)
}

        首先是成员变量工作队列workQueues,因为每个线程都有自己的工作队列,自然是一个数组,这个很好理解。主要看里面都放了什么,放了一个内部类WorkQueue,WorkQueue里面放任务的是“ForkJoinTask<?>[] array”数组。

        看到这里是不是有点懂了?常规线程池消费任务只能从单条队列头部取,先进先出;而ForkJoinPool消费任务从所拥有的队列尾部取,后进先出,偷任务从他人队列头部取。

        这样做的好处是:正常消费和偷任务消费实现了无锁并发,你从尾部消费、我从头部偷,我们各不冲突、互不争抢;极大程度减小了获取任务可能造成的阻塞。

        分析到这里,只能说不得不佩服Doug Lea大师的思想...

        ForkJoinPool的使用方法也要注意,最好给他内部提交特定的任务,即ForkJoinTask的实现类,因为我们要使用fork()/join(),提交普通的Thread就没有意义了。ForkJoinTask主要有两个实现类RecursiveTask和RecursiveAction,一个有返回值一个没有返回值

        下面我们给一个小Demo,这个Demo主要是对列表内所有数字求和,将任务拆分直至不可再拆分,最后求和返回结果。

    public static void tes1() {
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        List<Integer> list = new ArrayList<>();
        for (int i = 1; i < 11; i++) {
            list.add(i);
        }

        SumTask sumTask = new SumTask(list);
        ForkJoinTask<Long> submit = forkJoinPool.submit(sumTask);
        try {
            Long num = submit.get();
            System.out.println("===============================");
            System.out.println("求和结果为: " + num);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }

    static class SumTask extends RecursiveTask<Long> {

        List<Integer> list;

        SumTask(List<Integer> list) {
            this.list = list;
        }

        @Override
        protected Long compute() {
            int size = list.size();
            if (size < 4) {
                System.out.println("已拆分至最小运算单元");
                return list.stream().reduce(0, (i1, i2) -> i1 + i2).longValue();
            }

            System.out.println("任务拆分");
            int mid = size / 2;
            SumTask sumTask1 = new SumTask(new ArrayList<>(list.subList(0, mid)));
            SumTask sumTask2 = new SumTask(new ArrayList<>(list.subList(mid, size)));

            sumTask1.fork();
            sumTask2.fork();
            return sumTask1.join() + sumTask2.join();
        }
    }

        先初始化了一个长度为10的递增列表,在RecursiveTask里将其平均分为两个子列表,在每个子列表长度小于4以前递归拆分并fork()提交,直至其小于4开始计算返回结果;是一个很标准的拆分过程,来看看运行结果:

任务拆分
任务拆分
已拆分至最小运算单元
任务拆分
已拆分至最小运算单元
已拆分至最小运算单元
已拆分至最小运算单元
===============================
求和结果为: 55

        答案无疑是正确的,我们来分析一下运行过程:

  1. 长度为10的列表传入,进行第一次拆分,拆分成两个长度为5的列表并fork()。
  2. 两个长度为5的列表仍可以拆分,因此继续拆分,分别拆分成长度为2、3的列表并fork()。
  3. 我们设置不可拆分条件为size < 4,很明显这两个列表都不能再拆分,因此直接开始计算;第一个长度为5的列表拆成的两个列表计算结果、加上第二个长度为5的俩,一共进行了4次子问题计算,最后子问题4合2、2合1,返回结果。

        所以一共拆分了3次,形成了4个不可拆分子问题。ForkJoinPool牛逼吧?

2.1.2 再说回来

        都快扯到非洲去了,还是回到我们的CompletableFuture吧。提交一个异步任务其实没啥好说的,但我们还是看看源码逻辑。

        supplyAsync()方法的入参是一个函数式接口Supplier,可以通过Lambda表达式来描述需要实现的功能,一般会以匿名内部类的形式出现;我们传入的Supplier实例,正是异步线程需要执行的任务,这里就不赘述了,主要看看提交给CompletableFuture后都发生了什么。

    //将函数式接口和默认线程池传给asyncSupplyStage()方法
    public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier) {
        return asyncSupplyStage(asyncPool, supplier);
    }

    //将异步任务和新建的CompletableFuture传递给AsyncSupply
    static <U> CompletableFuture<U> asyncSupplyStage(Executor e,
                                                     Supplier<U> f) {
        if (f == null) throw new NullPointerException();
        CompletableFuture<U> d = new CompletableFuture<U>();
        e.execute(new AsyncSupply<U>(d, f));
        return d;
    }

        逻辑没什么稀奇的,就是将任务实例和新建的CompletableFuture封装进一个内部类AsyncSupply,再提交给线程池执行,最后返回这个新建的对象d。问题是这个AsyncSupply是干嘛地的呢?我们点进去看看。

        //实例化时,给内部的CompletableFuture和Supplier赋值
        static final class AsyncSupply<T> extends ForkJoinTask<Void>
            implements Runnable, AsynchronousCompletionTask {
        CompletableFuture<T> dep; Supplier<T> fn;
        AsyncSupply(CompletableFuture<T> dep, Supplier<T> fn) {
            this.dep = dep; this.fn = fn;
        }

        //run()方法即为线程要执行的逻辑
        public void run() {
            CompletableFuture<T> d; Supplier<T> f;
            //判断任务不为null且执行逻辑不为null
            if ((d = dep) != null && (f = fn) != null) {
                dep = null; fn = null;
                //判断任务的结果是否为null,为null表示还未被执行,可以进入执行逻辑
                if (d.result == null) {
                    try {
                        //f.get() 调用自定义任务逻辑
                        d.completeValue(f.get());
                    } catch (Throwable ex) {
                        d.completeThrowable(ex);
                    }
                }
                d.postComplete();
            }
        }

        首先,既然他能被线程池处理,那他应该也类似于一个线程吧?没错,他实现了Runnable接口。其次,需要指定线程执行的逻辑吧?确实,他内部也有run()方法的实现,实例化完AsyncSupply以后,紧接着就要调用这个run()方法了。先判断两个成员变量不为null、且该任务结果为null(因为要保证任务未执行),至于干了什么,我们继续往里点。

        先明确一下:

  • 成员变量dep是我们传入的CompletableFuture对象;
  • 成员变量fn是我们传入的函数式接口;
  • run()方法里的d是一会儿用来承接传入dep的引用、释放掉原dep对象空间占用的对象;
  • f与d同理用于承接fn。

        强调这点是因为Doug Lea大神的代码风格很抽象,变量名就爱起豆大点字母,看多了确实容易搞混;我们实际操作的是两个内部变量f和d。

//入参为异步任务返回值
d.completeValue(f.get());

/** Completes with a non-exceptional result, unless already completed. */
final boolean completeValue(T t) {
    return UNSAFE.compareAndSwapObject(this, RESULT, null,
                                           (t == null) ? NIL : t);
}

//Unsafe魔法类方法
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

        completeValue()方法里调用了f.get(),意思就是调用了我们刚自定义的任务实现,并将其返回值传入了completeValue()。然后他拿着任务的返回值,调用了魔法类Unsafe的native方法compareAndSwapObject()。看过JUC源码的小伙伴应该知道,这个就是大名鼎鼎的“CAS”,作用就是对比符合预期就交换、不符合预期就自旋重试,用这种方式实现了无锁并发。

        来看看CAS方法的4个入参:

  1. 源对象,需要被修改部分的对象。
  2. 被修改内容所在偏移位,比如被修改的是一个int成员变量,他保存在对象1000位的位置上,偏移位即为1000。
  3. 预期值,既然是对比并交换,肯定要先与一个预期值对比,等于预期值说明没有并发修改现象(这种说法并不完善,还会存在“ABA”现象,即先改变再改回来,但已经被解决),不等于则需要自旋重试。
  4. 修改值,符合预期后要修改成的值。

        那么对于这里的方法调用来说:

  • this就是一会要返回的CompletableFuture对象;
  • RESULT静态成员变量,是异步任务返回值所处在CompletableFuture对象内存地址的偏移量;
  • null意为对于返回值预期值为null,因为我们认为该任务在CAS之前还未完成和结果赋值;
  • 最后那个三元运算,判断任务返回值是否为null,不为null则赋值为返回值,为null则赋值为一个封装了异常的内部类AltResult(异常也为null,因为没有显式抛异常)

        这步执行完后CompletableFuture的返回值就赋值完成了,有了异步任务返回值。至于这里为什么不自旋,猜测是因为并不涉及多线程共享变量,我们每次提交任务都会新建一个CompletableFuture与AsyncSupply对象,不存在竞争关系就不需要自旋重试。

        赋值完后调用postComplete(),看这个方法的注释可以看出来,是用来触发其他可触及的依赖任务;但我们现在并没有依赖任务,且这里面的逻辑非常复杂,涉及到线程启动机制和很多Native方法,一会儿分析源码的时候细说。

* Pops and tries to trigger all reachable dependents.  Call only
* when known to be done.

         看了这么多,最简单的提交异步任务其实总结起来,也就是这么几步:

  1. 创建CompletableFuture对象;
  2. 提交异步任务执行;
  3. 获取并修改CompletableFuture结果属性;
  4. 查看当前任务有没有依赖任务,有则重复这个流程、无则回调启动任务的方法(也就是例子中的main方法,这个原理也和Native方法有关,后面再说)。

        启动完就可以调用get()方法获取结果,结果为null就阻塞等待,不为null就直接返回,这个比较常规就不赘述了。与其相似的runAsync()方法,唯一区别就是返回值,内部的实现也是类似的。

2.2 组合任务

        CompletableFuture最大的魅力便在于流程编排,可以用各种方式组合同步/异步任务,这也是其最复杂高深的部分,我们从简单到复杂一一介绍。

        注意,基本所有方法都有同步和异步两种形式,区别在方法后缀带不带“Async”,我们只着重介绍同步的方法,异步方法逻辑一致不作赘述。

2.2.1 单依赖

        最简单的情景,一个任务依赖于另一个,如当A完成时启动B,则称之为B依赖于A。

        先来介绍最常用的“then”一族:thenApply/thenAccept/thenRun/thenCompose,既然带有前缀“then”很显然就是“然后执行”的意思,他们的区别并不大,只有一些入参和出参的不同。

        在正式介绍他们的用法和异同之前,要给大家引申一个JDK 8的新特性——函数式接口。函数式接口有4种:Supplier/Consumer/Predicate/Function,以我们上面提到的Supplier为例来看看源码。大部分函数式接口就是这么简单,里面有一个需要自己实现的方法,区别只在是否有入参和是否有返回值。

@FunctionalInterface
public interface Supplier<T> {

    /**
     * Gets a result.
     *
     * @return a result
     */
    T get();
}
  • Supplier供应者,就如同一个不求任何回报的工人,什么都不给他也可以产出结果(不传参并不意味着不可以使用参数,全局变量或成员变量是可以使用的!),因此他没有入参但是有返回值
  • Consumer消费者,消费者当然是来你这拿东西的,因此需要传入参数且不需要返回值
  • Function函数,函数是为了解决一个问题,因此需要问题本身和解决后的结果,需要传入参数也有返回值
  • Predicate断言,用过Gateway的同学应该知道“断言工厂”这个概念,本质就是一个只返回True/False的判断逻辑。可以通过自定义判断逻辑并传入参数,利用Predicate来判断参数是否符合条件,还可以使用and/or/negate 与或非来自己组装断言逻辑。如predicate1判断年龄、predicate2判断性别,利用and组装两个断言再调用test方法判断,即可筛选出符合条件的人群。

        函数式接口之间的区别,正与不同CompletableFuture的区别息息相关。

a4c51892fe1f45168df7da72c128b0b0.png

        可以看到这几个then族的方法,入参正是函数式接口,而出参则为有返回值或无返回值的CompletableFuture。再结合函数式接口入参出参的差异,他们的区别也就很明显了:

  1. thenRun()接收一个Runnable实现类,没有入参也没有出参。
  2. thenApply()接收一个Function实现类,有入参也有出参。
  3. thenAccept()接收一个Consumer实现类,有入参但没有出参。
  4. thenCompose()接收一个Function实现类,有入参也有出参。

        是不是和函数式接口的差异一模一样呢?

        细心的小伙伴可能会有几个疑问(其实是我自己),依赖的源任务可能有返回值,但也可能没有,这个时候再用Consumer或Function接收到的入参是啥呢?没错就是null,不过一般写程序的时候也不会这么写,明知道源任务没返回值还接收他干啥。

        还有个问题,thenApply()和thenCompose()看起来好像差不多啊,都有入参有出参。来看看具体使用的代码,其实还是能看出一点区别的。

        future3.thenCompose(new Function<Object, CompletionStage<String>>() {
            @Override
            public CompletionStage<String> apply(Object o) {
                return CompletableFuture.supplyAsync(() -> {
                    System.out.println(o);
                    return "oooo";
                });
            }
        });
        future3.thenApply(new Function<Object, Object>() {
            @Override
            public Object apply(Object o) {
                System.out.println(o);
                return "00000o0o0o0o";
            }
        });

        他们俩区别在于Function接口的返回值,thenApply()中返回的是对源任务返回值操作后的结果,而thenCompose()中返回的是对源任务进行操作的CompletableFuture对象。区别就是:thenApply()更接近与是对原CompletableFuture的一次转化,而thenCompose()是返回一个全新的对象,区别也确实不大。

        最后一个问题,线程内出现异常怎么办?大家都知道,通常情况下子线程的异常如果不捕获,主线程是不会感知到的;因此最推荐的做法是在每个子线程中捕获异常并处理,但这时就存在一个两难的境地——源任务出现异常依赖任务是否还要执行?catch住异常依赖任务是不知道的,还会继续执行;不catch程序又会爆炸,也不合适。

        上面提到的then那些方法,只有源任务正常返回才会执行,他们需要源任务的返回值,因此不出现异常是前提。那如果我们还需要考虑到异常的情况,就可以用比较特殊的exceptionally/whenComplete/handleexceptionally顾名思义是捕获异常时调用的,在链条内任一任务出现异常时,exceptionally就会被触发。而whenComplete和handle从入参可以看出其不同:

        future3.whenComplete(new BiConsumer<Object, Throwable>() {
            @Override
            public void accept(Object o, Throwable throwable) {

            }
        });
        future3.handle(new BiFunction<Object, Throwable, Object>() {
            @Override
            public Object apply(Object o, Throwable throwable) {
                return null;
            }
        });

        不仅有源任务的返回值,还多了一个Throwable类型入参,当源任务抛出异常但没有被捕获,就会被传递到入参中,我们可以根据是否有异常进行酌情处理。其次,handle有返回值,whenComplete没有返回值。

2.2.2 单依赖实现原理1:创建依赖

        CompletableFuture最核心的两部分:依赖关系的创建、依赖任务的触发,笔者也会着重于这两部分。以常用的thenApply()为例,我们来分析源码。

    //fn为自定义函数式接口    
    public <U> CompletableFuture<U> thenApply(
        Function<? super T,? extends U> fn) {
        return uniApplyStage(null, fn);
    }

    private <V> CompletableFuture<V> uniApplyStage(
        Executor e, Function<? super T,? extends V> f) {
        if (f == null) throw new NullPointerException();
        CompletableFuture<V> d =  new CompletableFuture<V>();
        if (e != null || !d.uniApply(this, f, null)) {
            UniApply<T,V> c = new UniApply<T,V>(e, d, this, f);
            push(c);
            c.tryFire(SYNC);
        }
        return d;
    }

        uniApplyStage()方法封装了d作为依赖任务用于返回,主要来看看uniApply()方法,入参的this就是源任务(this代表当前对象的引用,方法调用方式是“future3.uniApply()”,因此调用的对象为future3,也就是源任务对象),我们绑定依赖肯定是绑在源任务上嘛。先不看方法内部的逻辑,返回值是一个boolean值,首先看看这个返回值会产生什么影响。

        如果返回了true,取反为false,就不会进入if逻辑;反之执行if逻辑。这点很重要!我们必须结合返回值的差异理解方法的内部逻辑!

    //a:源任务,f:函数式接口,c:null
    final <S> boolean uniApply(CompletableFuture<S> a,
                               Function<? super S,? extends T> f,
                               UniApply<S,T> c) {
        Object r; Throwable x;
        //判断源任务、源任务的结果、函数式接口是否为null,如果为null返回false
        if (a == null || (r = a.result) == null || f == null)
            return false;
        //判断当前依赖任务结果是否为null,为null代表任务未执行,可以继续
        tryComplete: if (result == null) {
            //判断源任务结果是否为AltResult对象,并判断内部是否封装了异常结果,如是将异常赋值给结果
            if (r instanceof AltResult) {
                if ((x = ((AltResult)r).ex) != null) {
                    completeThrowable(x, r);
                    break tryComplete;
                }
                r = null;
            }
            try {
                if (c != null && !c.claim())
                    return false;
                //s为源任务的结果,将源任务结果作为依赖任务的参数,传给function执行并替换依赖任务的结果
                @SuppressWarnings("unchecked") S s = (S) r;
                completeValue(f.apply(s));
            } catch (Throwable ex) {
                completeThrowable(ex);
            }
        }
        return true;
    }
  1. 进入方法先判断了源任务是否为null、源任务的结果是否为null(为null说明源任务还未执行完毕)、函数式接口是否为null,如果有一个满足则返回false
  2. 判断依赖任务的成员变量result(依赖任务结果)是否为null,也就是判断依赖任务是否执行完毕,未执行则继续。
  3. 判断源任务结果是否为AltResult对象,该对象内部封装了任务抛出的异常。如源任务抛出了异常,则修改源任务结果为异常对象,并跳出指定层数(又学到个新写法,非循环内也可以使用带标识的break,来跳到指定位置)。跳出tryComplete,直接到最后返回true。
  4. 进入最后一个try方法块,首先判断入参UniApply对象c是否为null,我们传入的就是null,因此先跳过这一块,后面再说
  5. 变量s指向源任务的结果,并将s传入函数式接口作为入参并调用apply()方法——不要忘了thenApply()方法接收一个function,其入参为源任务的结果。执行完后将function返回值赋值给依赖任务的结果。如依赖任务产生异常,则catch住并将异常对象赋值给依赖任务。最后返回true

        整个流程结束,再来关注返回true/false的场景,以及对应的结果。

  • 当入参存在空(入参基本判空)、或者源任务未完成时,返回false。
  • 反之入参校验正常、源任务已完成时,会去调用依赖任务的逻辑,成功执行后返回ture。

        而是否进入if逻辑是反过来的,即:源任务未完成时进入逻辑,源任务 + 依赖任务都完成时不进入逻辑、直接返回依赖任务对象;再结合thenApply()的定义“源任务执行完毕后执行依赖任务”,大概能猜到,if逻辑内是对“依赖任务暂时无法执行”的一种补偿处理逻辑。大胆假设,既然我现在没法执行,我就先给依赖任务存起来呗,等源任务执行完了再触发依赖任务

        那么我们可以认为,if内的逻辑就是在“建立依赖关系”,带着这样的认知,我们继续往下看。

//uniApply()返回false则进入方法逻辑,即源任务还未执行完毕时,依赖任务也暂时无法执行
if (e != null || !d.uniApply(this, f, null)) {
    //e 线程池,d 依赖任务,this 源任务,f 函数式接口
    UniApply<T,V> c = new UniApply<T,V>(e, d, this, f);
    push(c);
    c.tryFire(SYNC);
}

        首先实例化了一个UniApply对象,将线程池、依赖任务、源任务、函数式接口塞了进去。

//调用父类构造器,简单的赋值
//src 源任务,dep 依赖任务,fn 函数式接口
static final class UniApply<T,V> extends UniCompletion<T,V> {
    Function<? super T,? extends V> fn;

    UniApply(Executor executor, CompletableFuture<V> dep,
        CompletableFuture<T> src,
        Function<? super T,? extends V> fn) {
        super(executor, dep, src); this.fn = fn;
    }

    //......
}

//父类,包含了源/依赖任务、线程池
abstract static class UniCompletion<T,V> extends Completion {
    Executor executor;                 // executor to use (null if none)
    CompletableFuture<V> dep;          // the dependent to complete
    CompletableFuture<T> src;          // source for action

    UniCompletion(Executor executor, CompletableFuture<V> dep,
                      CompletableFuture<T> src) {
    this.executor = executor; this.dep = dep; this.src = src;

    //......
}

//顶级父类
abstract static class Completion extends ForkJoinTask<Void>
        implements Runnable, AsynchronousCompletionTask {
    volatile Completion next;      // Treiber stack link

    //......
}

        UniApply是什么?我们往里点发现他的顶级抽象类是Completion,通过观察Completion本身和他的一堆实现类,可以发现以下几点:

  • 继承了ForkJoinTask也实现了Runnable,说明他是一个可以被任何线程池执行的任务。
  • 拥有一个成员变量“next”,类似链表结构,指向下一个同类型对象。
  • 包含源任务、依赖任务、线程池、函数式接口。
  • CompletableFuture有一个成员属性“stack”,就是Completion类型的,注释解释他是“依赖操作的Treiber栈顶部”(Treiber栈是一种无锁并发栈,核心操作就是自旋 + CAS,上面已经介绍过)。

        结合这几点特征,我们可以将Completion看作一个“有上下文状态的任务链表”,这个“上下文状态”就包含了我们一直提到的UniApply的成员变量“源任务、依赖任务、函数式接口、线程池”。

        Completion对象创建完,下一步将对象作为入参调用push()方法,我们继续看。

//源任务未完成时,将UniApply对象放入栈中临时存储
/** Pushes the given completion (if it exists) unless done. */
final void push(UniCompletion<?,?> c) {
    if (c != null) {
        while (result == null && !tryPushStack(c))
            lazySetNext(c, null); // clear on failure
    }
}

//注释意为:“如果成功将c入栈,则返回true”
//h为CompletableFuture成员变量stack栈顶的Completion
//c为依赖任务构建的UniApply对象(也是Completion实现类)
/** Returns true if successfully pushed c onto stack. */
final boolean tryPushStack(Completion c) {
    Completion h = stack;
    lazySetNext(c, h);
    return UNSAFE.compareAndSwapObject(this, STACK, h, c);
}

//调用魔法类的putOrderedObject,意图是将依赖对象的NEXT属性,赋值为当前CompletableFuture栈顶的Completion
//赋值完毕后,依赖任务对应的Completion对象的next,指向了CompletableFuture原有的Completion对象
//这样就成功构建了一个后进先出的栈,新来的任务永远在栈顶
static void lazySetNext(Completion c, Completion next) {
    UNSAFE.putOrderedObject(c, NEXT, next);
}
  1. 当依赖任务不为null且源任务未完成时,将依赖任务UniApply对象作为入参调用tryPushStack()方法。
  2. tryPushStack()方法注释意为:“如果成功将c入栈,则返回true”,看来他的作用就是将依赖任务入栈。先用变量h接收源任务成员变量“stack”,stack我们上面提到也是一个Completion对象,将c和h传入lazySetNext()方法。
  3. lazySetNext()直接调用了UNSAFE魔法类的putOrderedObject()(不用太纠结这个方法的实现,只需要知道他是一个实时性较低的修改操作,修改后不会立刻被其他线程看到,性能较高。与JMM的一些特性,包括主存/工作内存、读写屏障、volatile等有关),意图将依赖任务的next属性赋值为源任务的stack,符合栈“先进后出”的特性,新来的依赖任务会放在栈顶。
  4. 赋值完成后,依赖任务的next属性,就存放的是源任务原有的stack属性(也就是源任务的依赖任务栈栈顶)。由于原本的源任务并没有依赖任务,因此依赖任务的next为null,源任务的stack被赋值为新增的依赖任务;如果源任务原本有依赖任务,在此基础上又关联一个依赖任务,新依赖任务的next属性就存放了原有的依赖任务,形成了一个依赖任务栈。
  5. 最后调用CAS,将新的依赖任务替换进了源任务的stack。

        是不是快绕晕了?我们举个栗子来帮助大家理解。

        前面提及的任务编排都类似于链表,而还有的场景会成为二叉树——如下图的依赖关系,CompletableFuture A有两个依赖任务,分别是A2和B;B又有两个依赖任务,B2和C。A -> A2 -> A3和A -> B -> C是链式依赖,A -> A2 + B和B -> B2 + C是二叉树依赖。那么当A执行完后,A2和B都要被触发;B执行完后,B2和C都要被触发。

e1e6fd6d2ba34efe8def29e9d7cc45e3.png

        有这样一个场景:饭做好以后,我和女朋友都要吃饭。我们来看看这种场景下,CompletableFuture内部发生了什么。

CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> {
    System.out.println("开始做饭");
    try {
        Thread.sleep(10000000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return "西红柿炒鸡蛋";
}, executorService);

CompletableFuture<String> f2 = f1.thenApply(r -> {
    System.out.println("我吃: " + r);
    return "吃饱了";
});

CompletableFuture<String> f3 = f1.thenApply(r -> {
    System.out.println("女朋友吃: " + r);
    return "吃饱了";
});

        我们让f1阻塞住,再给f1加上f2/f3俩任务的依赖关系,来debug一下。

c792ff5e39694c3da59880f45807e148.png

        箭头1对应的this源任务f1,由于任务在阻塞中,因此未执行完毕result为null;其次f1还未创建完依赖任务,因此stack也为null。箭头2对应的是要操作的依赖任务f2,此时刚创建好,所以也为null。uniApply()方法刚看过了,当源任务未执行完时会进入if逻辑、执行完时会执行依赖任务,因此我们继续看if里面的逻辑。

305e6a615d5541e8a2bd703f55f9d682.png

         创建了如上图的UniApply对象,把源任务、依赖任务、线程池、函数式接口塞进去,没啥问题。主要看看push()方法调用完后,发生了点什么。

cf518cbf35554d31b1650f83dcaab489.png

        h为源任务当前的stack,c是依赖任务UniApply对象,现在要将c的next属性设置为h,再将源任务的stack赋值为c。上图是入栈前,我们再看看入栈后。

ccdab72aa3c8486789d5b1faab4595c3.png

         可以看到源任务f1的stack不为null了,变成放着源任务和依赖任务的UniApply对象,说明此时我们创建好了依赖关系。但UniApply对象的next属性仍然为null,因为在创建依赖前f1的stack本就为null

        我们再来创建f3的依赖关系,和上面是同样的流程,创建完后再看f1的状态。

35f63c4f904e49dabbb5547599806f17.png

        再捋一遍流程:

  1. 最开始f1的stack和next都为null
  2. 在关联完f2后,f1的stack放着f2对应的UniApply对象,f2的next为null
  3. 在关联完f3后,f1的stack放着f3对应的UniApply,f3的next里放着f2对应的UniApply

        可以总结出规律,CompletableFuture的stack内永远放着最新关联、亟待执行的Completion对象,又通过Completion的next属性,实现了类似多叉树的结构——多个任务同时依赖同一个源任务,但后来的任务先执行。通过执行结果,可以清晰地发现这一点。

88e70a9e294d4fc39349ab5b12940814.png

         明明是先提交的我吃饭、后提交的女朋友吃饭,但实际执行结果是女朋友先吃我后吃,说明确实是一个后进先出的栈结构。这样的结构设计是十分合理且精妙的,一个源任务对应多个依赖任务为二叉树或多叉树结构,以栈的形式存放在next属性中(实际next中的每个任务都是平级的,只是顺序上是后进先出);链式的依赖任务是链表结构,也以栈的形式存在dep属性中(任务具有依赖关系,src执行完后才能执行dep)。

        试想一下如果没有next这个属性,我们上面这个例子会是什么样的结果呢?

        f1先绑定f2后绑定f3,如果没有next来存放先绑定的f2,f2就会直接被丢失只留下f3,等于我不吃饭只有女朋友吃饭,我会死。因此利用这种巧妙的设计来支持多叉树结构的是十分必要的。

        到这里完整的CompletableFuture结构已经构建好了,源任务f1的依赖任务f3放在stack栈中,源任务f1的另一个依赖任务f2放在stack的next中(先f3后f2是因为先建立f2依赖、后建立f3依赖,栈是后进先出所以栈顶是f3),依赖任务f2的依赖任务f4放在f2的dep中......再复杂的流程编排也是同样的逻辑。

        还记得我们最开始说的“CompletableFuture最核心的两部分:依赖关系的创建、依赖任务的触发”,依赖关系创建好了,if内的最后一个方法tryFire()干的活八九不离十就是触发任务。其实看方法名大概能猜到,tryFire直译过来是“尝试开火”,就如同将CompletableFuture这把枪里的依赖任务子弹、一发一发打出来。

//调用依赖任务的tryFire()方法
//传入静态变量SYNC = 0,意为同步,异步情况下会传入ASYNC = 1
final CompletableFuture<V> tryFire(int mode) {
    CompletableFuture<V> d; CompletableFuture<T> a;
    if ((d = dep) == null ||
        //再次调用依赖任务的uniApply()方法,不同的是根据mode区分传入null还是传入当前UniApply对象
        //如uniApply()返回false,方法返回null
        !d.uniApply(a = src, fn, mode > 0 ? null : this))
        return null;
    dep = null; src = null; fn = null;
    //返回依赖方法的
    return d.postFire(a, mode);
}


    // Modes for Completion.tryFire. Signedness matters.
    static final int SYNC   =  0;
    static final int ASYNC  =  1;
    static final int NESTED = -1;

        tryFire()方法传入了一个成员变量0(主要是和同步/异步/嵌套执行模式有关,我们先按下不表),进入方法后又一次调用了依赖任务的uniApply()方法——但不一样的是,最后一个入参UniApply对象的取值成了一个三元表达式、而非之前的固定传null,而根据传入的0可以得出,这次入参不再是null,而是放着依赖任务的UniApply

        uniApply()方法上面已经看过了,但上次我们跳过了UniApply对象相关的逻辑,并说下次再讲,时机到了!

//c不为null且claim()方法返回false时,方法返回false
if (c != null && !c.claim())
    return false;
@SuppressWarnings("unchecked") S s = (S) r;
completeValue(f.apply(s));
//......

//验证依赖任务能否执行
//验证的方式是自旋CAS设置CompletableFuture的status属性,任务是否被执行过的标志位,0代表未执行、1代表执行
//如果CAS成功返回true,将任务提交给线程池;失败返回false,已被执行过不允许执行
final boolean claim() {
    Executor e = executor;
    if (compareAndSetForkJoinTaskTag((short)0, (short)1)) {
        if (e == null)
            return true;
        executor = null; // disable
        e.execute(this);
    }
    return false;
}

         这次c不为null了,会去调用claim()方法。正如他的名字,就是用来验证该任务能否被执行,评判的标准是CompletableFuture的成员属性status如果status标志位为0,说明未被执行过,为1则反之。claim()内部调用的compareAndSetForkJoinTaskTag()方法就是在自旋CAS这个标志位,想将其从0修改为1,如果修改成功,说明未被执行过,就执行一下吧。

        那么此时的e为null吗?答案是yes,因为我们调用的是同步的thenApply(),本身是不会传入线程池的,成员变量executor也就为null。因此在判断“e == null”后,claim()方法会返回true,则会调用函数式接口的apply()方法。如果调用的是带Async的异步方法,此时e不为null,就会将“this”提交给线程池。

        this是啥?往前追溯一下发现是放有依赖任务的Completion对象,把这个对象提交进去又发生了什么?我们知道线程池提交进入的任务肯定是一个线程,线程就一定会实现run()方法,那我们就看看Completion和他的子类有没有run()方法的实现。

//ASYNC = 1
public final void run()                { tryFire(ASYNC); }

        哦豁,点进Completion一看果然有,而这次传入了静态变量ASYNC,其值为1。再回到tryFire()方法中的三元表达式,当mode为1时,则最后一个入参为null。而不传入UniApply对象,也就不会走claim()方法那段逻辑,而是直接将源任务的结果传进函数式接口并执行;而整个过程是线程池在处理,这个依赖任务是异步执行的。也就是同步/异步标志位和是否有线程池的差异,正对应了SYNC/ASYNC两个不同标志位,将整个流程串了起来。

        说完同步和异步的差异,我们再回到刚才的逻辑。此时claim()方法返回true,继续向下执行函数式接口并将结果赋值给依赖任务,最后uniApply()方法返回true。来到tryFire()方法的最后一行,调用依赖任务的postFire()方法,并传入了源任务和标志位0;到这一步时,源任务和依赖任务都已执行完,下面是收尾的工作,来看看都有哪些工作。

    //注释意为:
    //“UniCompletion成功后由依赖方进行后期处理tryFire
    //尝试清理源a的堆栈,然后运行postComplete或将其返回给调用者,具体取决于模式”
    /**
     * Post-processing by dependent after successful UniCompletion
     * tryFire.  Tries to clean stack of source a, and then either runs
     * postComplete or returns this to caller, depending on mode.
     */
    final CompletableFuture<T> postFire(CompletableFuture<?> a, int mode) {
        //判断源任务、源任务的依赖任务栈是否为null
        if (a != null && a.stack != null) {
            //postComplete中以NEST模式调用或源任务未执行完毕
            if (mode < 0 || a.result == null)
                a.cleanStack();
            else
                a.postComplete();
        }
        //判断依赖任务结果、依赖任务的依赖任务栈是否为null
        if (result != null && stack != null) {
            if (mode < 0)
                return this;
            else
                postComplete();
        }
        return null;
    }

         能走进postFire()方法,前提是uniApply()返回了true,这意味着依赖任务没有入栈而是直接被执行,否则前一步就返回null了。

  1. 源任务a的栈实际为null,因此不会进入第一个if逻辑,但我们也可以看看。
  2. 第二个内部if判断mode是否为NESTED(NESTED为嵌套模式,值为-1,在源任务完成时调用的postComplete会用到)、或源任务是否执行。此时进入方法mode对应0,且源任务已执行完,所以会调用源任务的postComplete()方法,postComplete()用于触发该任务的其他依赖任务,我们一会儿再说;cleanStack()的作用,是用于清除源任务栈中已完成的任务
  3. 之后判断依赖任务是否完成、依赖任务的栈是否为null,如果mode为NESTED,则返回当前依赖任务对象(也是postComplete()方法内会依赖这个返回值,一会儿再说);反之则继续触发当前任务的其他依赖任务。

        到这里tryFire()方法的逻辑也走完了,实际上这个方法整了这么一堆,只干了一件事:触发源任务和依赖任务的依赖任务(因为mode为0,所以实际调用了源任务和依赖任务的postComplete()方法,该方法就是用来触发依赖任务的)。

        问题是为什么?还记得我们在看supplyAsync()方法提交任务时,任务执行完也会调用postComplete(),为什么在将依赖任务放入源任务的栈中,还要调用一次依赖任务的postComplete()?

        聚焦到最初的绑定方法uniApplyStage(),我们再来分析分析:

    private <V> CompletableFuture<V> uniApplyStage(
        Executor e, Function<? super T,? extends V> f) {
        if (f == null) throw new NullPointerException();
        CompletableFuture<V> d =  new CompletableFuture<V>();
        if (e != null || !d.uniApply(this, f, null)) {
            UniApply<T,V> c = new UniApply<T,V>(e, d, this, f);
            push(c);
            c.tryFire(SYNC);
        }
        return d;
    }

        我们捋一遍常规的逻辑:

  • f1提交异步执行 -> 主线程绑定f2到f1 -> 主线程绑定f3到f1
  • f1仍然在执行中,很慢很慢,所以f2和f3都已经入栈。
  • f1可算执行完毕了,f1自行调用postComplete(),依次触发f3、f2。

        这是我们脑中会出现的最普通的情况,再看看下面这种情况:

  • f1提交异步执行,但是只打印了一个helloworld,所以很快就结束了;调用了一下postComplete()发现也妹依赖任务啊,完事了。
  • f2试图绑定到f1,调用uniApply()方法,发现f1已经完成了,直接调用f2,不会进入依赖任务入栈的逻辑。
  • f3试图绑定到f1,同理。

        此时由于源任务执行过快,导致依赖任务还没绑定完,源任务就完成了执行 + 触发的操作,看似会导致依赖任务永远不会被触发。但实际上,uniApply()方法在尝试创建依赖关系时,检查了源任务是否执行完毕,如执行完毕就自己触发自己,因此也不会出现问题。

        再看这种情况:

  • f1提交异步执行,任务需要3秒完成。
  • 2.999秒时,f2试图绑定到f1,此时调用uniApply()方法,发现源任务f1还未执行完毕,准备将f2封装起来入栈。
  • 3.000秒时,f1执行完毕,调用了postComplete()方法。注意!!!此时push()方法还未开始执行,f1的栈内还是空的,所以什么也没触发。
  • 3.010秒时,push()方式执行完毕,f2入栈。

        现在明白tryFire()方法的意义了吗?f1已经尝试触发过了,f2这会儿入栈,那我缺的触发这块的逻辑谁给我补啊?而Doug Lea早已考虑到了所有情景,不会重复、也不会漏任何一个任务,甚至最后还会帮你触发一下其他的依赖任务。

2.2.3 单依赖实现原理2:触发依赖

        经过上面的步骤,相信大家已经清晰明了整个依赖的创建过程,不论是多叉树的平行依赖、还是栈式的链式依赖,都有严密的逻辑来保证不丢失、不重复。主要是利用stack存放Completion子类对象来存储链式依赖next属性存放同一个源任务的多个平级依赖任务

        最最最重要的postComplete()方法,之前也是三过方法而不入,现在就来揭开他的神秘面纱。

    //注释翻译:
    //“出栈、并且尝试触发所有可触及依赖,仅在已知被完成时触发。”
    /**
     * Pops and tries to trigger all reachable dependents.  Call only
     * when known to be done.
     */
    final void postComplete() {
        //注释翻译:
        //“在每个步骤中,变量f持有当前依赖用于出栈和运行。
        //同一时间只会沿着一条链路拓展,促使其他人避免无边界的递归。”
        /*
         * On each step, variable f holds current dependents to pop
         * and run.  It is extended along only one path at a time,
         * pushing others to avoid unbounded recursion.
         */
        CompletableFuture<?> f = this; Completion h;
        while ((h = f.stack) != null ||
               (f != this && (h = (f = this).stack) != null)) {
            CompletableFuture<?> d; Completion t;
            if (f.casStack(h, t = h.next)) {
                if (t != null) {
                    if (f != this) {
                        pushStack(h);
                        continue;
                    }
                    h.next = null;    // detach
                }
                f = (d = h.tryFire(NESTED)) == null ? this : d;
            }
        }
    }

        养成先看注释的好习惯, 外面的注释意为:“出栈、并且尝试触发所有可触及依赖,仅在已知被完成时触发。”,意思就是把已完成任务的所有依赖任务,都给他触发一遍,这个大家都知道。

        里面的注释意为:“在每个步骤中,变量f持有当前依赖用于出栈和运行。同一时间只会沿着一条链路拓展,促使其他人避免无边界的递归。”,这部分看起来是在解释依赖触发的逻辑,后面我们结合代码来理解。

        首先让变量f指向this(this就是源任务对象),并创建一个空的变量h存放Completion对象用于后续操作(记住!!!我们后面执行的都是h对象)

        while循环内是一个或判断,先看前面。将f的stack赋给了h,此时h内存放了源任务的依赖任务;然后判断h是否为null,当源任务有依赖任务时stack不为空,h也就不为null,所以会进入while内部。

        再看或后面的条件“f != this”,上面不是给f赋值成this了,咋还会不等于this呢?自然是循环体内有对f赋值的操作,导致再次来到条件判断时f不等于this了,我们一会儿再说。与条件内的“(h = (f = this).stack) != null”,将f又赋值为this,h赋值为this的stack,然后再判断是否为null;这个看起来似乎是对f和h引用的重置,刚不是“f != this”吗?我再赋值成this,再看栈里是否有任务,和或前面的条件一个意思,栈不为空、有任务需要执行时再进入while。

        进入while循环内部,新建CompletableFuture对象引用d、Completion对象引用t。第一个if条件内先将依赖任务h的next属性赋值给t,然后将依赖任务和依赖任务的next属性传入casStack()方法。

//f:当前任务, h:栈顶依赖任务, t:栈顶依赖任务的next任务
if (f.casStack(h, t = h.next)) {
    //......
}

//CAS改变当前任务栈,将h替换为t
final boolean casStack(Completion cmp, Completion val) {
    return UNSAFE.compareAndSwapObject(this, STACK, cmp, val);
}

        逻辑就是将栈顶的依赖任务出栈、将栈顶任务的next任务入栈,我们马上要执行栈顶任务,因而将其取出放在h中、并将next依赖任务压入栈顶。

        下一步去判断t(栈顶依赖任务的next属性)是否为null,不为null则进入逻辑;然后判断f是否为this当前任务,这个逻辑又出现了,如果不是当前任务就将h(栈顶依赖任务)重新入栈并重新循环。这段逻辑必须要结合f对象引用的变化才能理解,因此咱们还是最后再说。最后将h的next属性置为null,因为已经存进变量t并入栈了,可以释放掉空间。

f = (d = h.tryFire(NESTED)) == null ? this : d;

        看到最后,果然有对f的重新赋值。tryFire()方法和传入的NESTED标志位(值为-1)我们刚刚提到过,传入标志位不同时有不同的作用,我们再来回顾一下。tryFire()内部调用的uniApply()就不细说了,快看吐了。主要看看传入NESTED(-1)与之前传入SYNC(0),任务执行逻辑的差异。 

    //注释意为:
    //“UniCompletion成功后由依赖方进行后期处理tryFire
    //尝试清理源a的堆栈,然后运行postComplete或将其返回给调用者,具体取决于模式”
    /**
     * Post-processing by dependent after successful UniCompletion
     * tryFire.  Tries to clean stack of source a, and then either runs
     * postComplete or returns this to caller, depending on mode.
     */
    final CompletableFuture<T> postFire(CompletableFuture<?> a, int mode) {
        //判断源任务、源任务的依赖任务栈是否为null
        if (a != null && a.stack != null) {
            //postComplete中以NEST模式调用或源任务未执行完毕
            if (mode < 0 || a.result == null)
                a.cleanStack();
            else
                a.postComplete();
        }
        //判断依赖任务结果、依赖任务的依赖任务栈是否为null
        if (result != null && stack != null) {
            if (mode < 0)
                return this;
            else
                postComplete();
        }
        return null;
    }

        判断mode < 0,在传入SYNC(0)时,条件不成立,会走else逻辑;体现在方法里,会调用源任务和当前依赖任务的postComplete(),最终返回null。前面解释过,能走到这里说明源任务已经完成了执行 + 尝试触发依赖任务的逻辑,此时后续绑定的任务没有人可以触发,因此需要依赖任务检查源任务和自身的栈内是否有未触发任务、并自己尝试触发

        而传入NESTED(-1)时,条件成立,会走if逻辑;此时,会清除源任务栈中已完成的任务及死去的节点(为啥会死我也不是很清楚,判断逻辑是Completion对象的dep属性是否为null)并重新构建栈,并在最后返回当前依赖任务对象。NESTED模式最大的差异是走了常规的逻辑,即源任务先绑定成功、源任务执行完毕、触发依赖任务——postComplete()方法会帮源任务触发所有栈内任务,不需要帮忙,也就不需要再次在postFire()方法中调用postComplete()。试想一下,如果这里又走到了else逻辑里,就形成了递归调用,永远也走不出来了。

        还记不记得之前提到的postComplete()方法注释,其中的“促使其他人避免无边界的递归”正对应这点。

f = (d = h.tryFire(NESTED)) == null ? this : d;

        如当前依赖任务已完成且他没有其他依赖任务,则返回null,如仍有依赖则将当前依赖任务返回去。再看这个三元表达式,如果tryFire()方法返回值是this,则f会被赋值为他的依赖任务h,也就不等于源任务this了。

        问题的重点是:为什么将f的指向改变为当前操作任务h?改变f的指向会造成什么影响?

        为了想明白这个问题,笔者也是在纸上画了许久,设计了多种复杂场景来佐证自己的猜想,我们慢慢盘。

2.2.4 单依赖实现原理3:举个栗子

        假设我们有上图这样的场景,并做一些约定与解释:

CF1是最起始的任务,我们称其为“根任务”,这点是不会改变的,但“源任务”会根据当前执行的任务产生变化。

CF2、CF4、CF5是CF1的依赖任务,由于依赖是后进先出的栈结构,因此放入CF1依赖的顺序为CF5 -> CF4 -> CF2,这应该不难理解。

CF6、CF7分别是CF4、CF5的依赖任务。

CF2有两个依赖任务CF3和CF9。

CF8是CF3的依赖任务。   

        结合上图笔者的笔记,来捋一捋整个执行的过程中,每个变量的值是如何变化的,又带来了怎样的影响。

     1. 起始的任务状态如图,stack内存放了依赖栈顶的依赖任务,而next则存放了栈顶后面的任务。横向可以看作一条完整的、可执行到底的链式依赖,类似于根节点 -> 子节点 -> 叶子结点;纵向是一个任务的多个平级依赖任务栈结构。

     2. 首先执行CF1,CF1执行完后调用postComplete()方法,此时f = CF1、h = CF2(CF1的栈顶任务)

     3. 调用f的casStack(),意图将f的栈顶任务出栈、将下一个任务入栈,即h的next内存放的任务,而h马上会被执行(调用tryFire()方法)。此时t = CF4,CF1的stack内存放着CF4。

     4. 判断得出t不为null(即当前源任务有多个平级的依赖任务),但f不为this,不满足条件。

     5. 执行h(即CF2)的tryFire()方法,因CF2的stack内有任务,方法返回this,即CF2。三元表达式判断后,此时f = d = CF2

     6. 回到while条件判断,h = CF2.stack = CF3,casStack()方法又要将CF2的栈顶任务出栈、将下一个任务入栈。此时t = CF9,CF2的stack内存放着CF9。

     7. 重复第4步,此时t不为null,f不为this(根任务CF1),此时会调用pushStack()方法,将h(即CF3)放入CF1栈顶。执行pushStack()方法前后任务结构变化如上图,CF3被放入了CF1栈顶,且在CF2的任务栈stack与next未清空前(即全部放入CF1栈前),会不断重复这个过程,直至CF2所有任务(也就是f,当前源任务)全部放入CF1(根任务)栈中。

     7.5.  再试想一下,如果CF2有三个依赖任务,如上图的CF3、CF9、CF10;那么CF3、CF9就会依次被放入CF1栈中,CF2的stack中存放CF10。结构如上图。

     8. 当CF2只剩一个任务CF10时,CF10的next为null,则会继续向下执行到tryFire()方法。方法内部判断CF10的stack中是否有任务,如有三元表达式会将f赋值为当前任务CF10。此时f = CF10,如CF10的stack不为null、且栈顶任务的next不为null,即CF10有多个平级的依赖任务,则会重复第7步,不断将next任务放入根任务(CF1)栈中。(f变为CF10,以CF10为起点将next任务放入CF1的栈中,直至只剩一个任务,继续向下执行

     8.5. 最终,所有任务会按照执行顺序放入根任务CF1的栈中,一条具有正确执行顺序的任务链表形成了,而原先深度未知的树被取代了。防止递归深度无法控制,只执行一条完整依赖,其余的都回归到根任务,以根任务为出发点链式执行。

     9. 原本是以“根节点 -> 子节点 -> 叶子结点”的树形结构执行,这种方式每次执行到叶子结点后,都要回退到上一级子节点,再寻找上一级的next任务(即同一个任务平级的依赖任务)。如果树很深的话,一层一层向下又回退会产生很深的递归。

        如上图的树形结构图,最简单的执行逻辑是这样的

  1. 沿着线路1向最深处的叶子结点执行。
  2. 然后返回次一级,则线路2,查看次一级是否还有其他子节点。
  3. 发现有子节点,则按照线路3向下执行。
  4. 再返回次一级,发现没有子节点,再返回次一级,即4/5线路。
  5. ······

        优化后的执行逻辑是这样的

  1. 根任务执行完后到任务0,发现任务0有1、2、3三个依赖任务,将1、2放入根任务栈顶,执行任务3,入栈后的结构如右图的第一个红框部分。
  2. 任务3执行完后,回到根任务的栈顶,2被执行后执行任务1。同理可得任务4会被放入根任务栈顶,执行任务5。入栈后的结构如右图的第二个红框部分。
  3. 最后任务7执行,任务6入栈,以此类推。

        执行顺序是完全一致的,且避免了过深的递归和回退,感兴趣的同学可以按照流程图创建相同的依赖任务关系测试一下,debug和返回结果和上述分析是一样的,有助于大家的理解。

2.2.5 双依赖

        一个任务依赖于两个任务的完成,如当A和B都完成后、启动C,则称之为C依赖于A和B。我们以thenCombine()方法为例,这个方法有两个入参,一个是另一个依赖任务,另一个是函数式接口,用于消费两个依赖任务的返回值,用法还是比较简单的。

         我们主要关注其中的实现原理,其实在分析完单依赖后,双依赖也是类似的。

    //传入依赖任务B、有两个入参的函数式接口
    public <U,V> CompletableFuture<V> thenCombine(
        CompletionStage<? extends U> other,
        BiFunction<? super T,? super U,? extends V> fn) {
        return biApplyStage(null, other, fn);
    }

    private <U,V> CompletableFuture<V> biApplyStage(
        Executor e, CompletionStage<U> o,
        BiFunction<? super T,? super U,? extends V> f) {
        CompletableFuture<U> b;
        //判空
        if (f == null || (b = o.toCompletableFuture()) == null)
            throw new NullPointerException();
        CompletableFuture<V> d = new CompletableFuture<V>();
        if (e != null || !d.biApply(this, b, f, null)) {
            BiApply<T,U,V> c = new BiApply<T,U,V>(e, d, this, b, f);
            bipush(b, c);
            c.tryFire(SYNC);
        }
        return d;
    }

        看一眼这个代码,大伙应该就明白一切了 ,和thenApply()是基本一模一样。不太一样的地方就是单依赖的方法名都是“uni”开头的,而双依赖都是“bi”开头的,我猜他们分别对应了“union”(联合)和“binary”(二元的),也就是单和双嘛。

        看看比较重要的biApply()方法:

    //入参为源任务、传入任务、函数式接口、null
    final <R,S> boolean biApply(CompletableFuture<R> a,
                                CompletableFuture<S> b,
                                BiFunction<? super R,? super S,? extends T> f,
                                BiApply<R,S,T> c) {
        Object r, s; Throwable x;
        //判断源任务、传入任务是否全部完成,未完成则返回false
        if (a == null || (r = a.result) == null ||
            b == null || (s = b.result) == null || f == null)
            return false;
        //判断当前任务是否被执行过
        tryComplete: if (result == null) {
            if (r instanceof AltResult) {
                if ((x = ((AltResult)r).ex) != null) {
                    completeThrowable(x, r);
                    break tryComplete;
                }
                r = null;
            }
            if (s instanceof AltResult) {
                if ((x = ((AltResult)s).ex) != null) {
                    completeThrowable(x, s);
                    break tryComplete;
                }
                s = null;
            }
            try {
                //如传入BiApply对象,说明是异步执行,调用claim()方法,详情见上文thenApply()的解析
                if (c != null && !c.claim())
                    return false;
                @SuppressWarnings("unchecked") R rr = (R) r;
                @SuppressWarnings("unchecked") S ss = (S) s;
                //将两个任务的返回值传入函数式接口,设置结果
                completeValue(f.apply(rr, ss));
            } catch (Throwable ex) {
                completeThrowable(ex);
            }
        }
        return true;
    }

        和uniApply()的逻辑基本一致,只不过将一个源任务变成了两个判断源任务是否完成、当前任务是否被执行过, 没完成就返回false,完成就设置值返回ture。

        由于源任务未完成,需要将他们封装成一个对象放起来,就有了if逻辑内的BiApply对象和bipush()方法,BiApply对象也就比UniApply对象多存了一个源任务。比较不同的是bipush()方法,传入了另一个源任务和BiApply对象,我们看看方法逻辑。

    //将Completion对象推入this(源任务A)和b(源任务B),除非他们都完成了
    /** Pushes completion to this and b unless both done. */
    final void bipush(CompletableFuture<?> b, BiCompletion<?,?,?> c) {
        if (c != null) {
            Object r;
            //如果源任务A未完成,就将BiApply对象入栈
            while ((r = result) == null && !tryPushStack(c))
                lazySetNext(c, null); // clear on failure
            //如果源任务B未完成,就将BiApply对象包装一下入栈
            if (b != null && b != this && b.result == null) {
                //如果源任务A完成,直接将c入源任务B的栈,未完成则封装入栈
                Completion q = (r != null) ? c : new CoCompletion(c);
                while (b.result == null && !b.tryPushStack(q))
                    lazySetNext(q, null); // clear on failure
            }
        }
    }

        当源任务A未完成时,就尝试将带有依赖任务的BiCompletion推入栈;当源任务B未完成时,如果源任务A已完成则直接将依赖任务推入B的栈,反之则封装成CoCompletion对象实际上CoCompletion对象最后也是调用了BiCompletion的tryFire()方法,具体用意不明)推入栈。

        逻辑很清晰,我们简单分析反证一下:

  • 如果源任务A已经完成了,再将依赖任务入栈没有意义,因为已经触发过依赖栈了。
  • 如果源任务B已经完成了,同理任务A。
  • 依赖任务需要A和B都完成才可以执行,这是BiCompletion tryFire()方法的逻辑。那么当A完成尝试触发依赖任务C,C发现B还未完成,就先歇着;此时B的依赖栈中肯定有任务C,B完成时又会尝试触发C,此时A早已完成了,C成功被执行。当然,A和B的顺序反过来也是一个逻辑。

2.2.6 多依赖

        一个任务依赖于多个任务的状态,含两种情景:

  1. 所有任务全部完成后启动,如A、B、C都完成后启动D。
  2. 所有任务中,有任意任务完成就启动。如A或B或C,其中一个完成后启动D。

780774e7254e4409a91ce2db8e73c10b.png

        继续看静态方法,anyOf()allOf(),他们的参数都是可变长度的CompletableFuture对象数组。看这个名字和参数,应该能猜到是对CompletableFuture的一种组合,anyOf是任意一个、allOf是所有,可以实现多个任务状态的管理

        先来看allOf()里面,直接调用了andTree()方法,参数为CompletableFuture对象数组、起始位置0、结束位置列表长度 - 1。看看andTree()方法的注释,作用一眼便知:“递归构造一颗Completion树”。

    //构建一颗具有“与”关系的树   
    public static CompletableFuture<Void> allOf(CompletableFuture<?>... cfs) {
        return andTree(cfs, 0, cfs.length - 1);
    }


    /** Recursively constructs a tree of completions. */
    static CompletableFuture<Void> andTree(CompletableFuture<?>[] cfs,
                                           int lo, int hi) {}


    //传入依赖任务数组,起始位0,结束位数组长度
    /** Recursively constructs a tree of completions. */
    static CompletableFuture<Void> andTree(CompletableFuture<?>[] cfs,
                                           int lo, int hi) {
        CompletableFuture<Void> d = new CompletableFuture<Void>();
        //数组为空时直接返回
        if (lo > hi) // empty
            d.result = NIL;
        else {
            CompletableFuture<?> a, b;
            //求中间数
            int mid = (lo + hi) >>> 1;
            //二分法,将数组以mid下标分为左右两部分,递归创建CompletableFuture,这个CF对象放置着源任务和a/b两个依赖任务
            if ((a = (lo == mid ? cfs[lo] :
                      andTree(cfs, lo, mid))) == null ||
                (b = (lo == hi ? a : (hi == mid+1) ? cfs[hi] :
                      andTree(cfs, mid+1, hi)))  == null)
                throw new NullPointerException();
            if (!d.biRelay(a, b)) {
                BiRelay<?,?> c = new BiRelay<>(d, a, b);
                a.bipush(b, c);
                c.tryFire(SYNC);
            }
        }
        return d;
    }

        andTree()方法的逻辑那可太复杂了,我坐那看了几天才大概搞懂,首先要记住这个方法要达成的目的“递归构造一颗Completion对象的树”,再倒推代码是在干嘛。

  1. 方法入参:cfs = 依赖任务对象数组、lo = 0(左索引)、hi = cfs数组长度 - 1(右索引)。
  2. 创建一个CompletableFuture对象d用于放置依赖任务并返回。
  3. 判断lo是否大于hi,如果满足条件说明数组为空,直接返回一个空的CF对象。
  4. 求数组下标的中间值(向右移1位运算),以这个中间值为基准,将数组分成左右两部分分别操作,标准的二分。
  5. 判断左索引lo是否等于中间值mid,如果等于说明不能再进行二分,直接将数组lo位置的CF对象赋值给a;如果不等于,将mid作为右边界传入递归二分,直到不能二分为止。
  6. 同第5步,只不过是将右边界固定,每次将mid + 1作为左边界,直至不能二分。
  7. 当递归二分至极限,a/b被赋值为mid位置和mid + 1位置的CF对象,每步创建的CF对象d就排上用场了。先查看a/b是否已完成,如果已经完成就没必要放入这棵树里了,因为不具备判断任务是否全部完成的意义。如果未完成,就把他们3个塞进BiRelay里,再把这个BiRelay对象放入a/b的栈,等待a/b完成时触发通知。
  8. 递完了该归了,这个就比较抽象了,画个图帮助大伙理解吧。

         如上图,我们传入了8个CF对象,初始左边界为0、右边界为7,此时mid = 3。

  1. 第一次二分分出了[0, 3],发现还能分;继续分分出了[0, 1],这下不能分了,0赋值给a1赋值给b,塞进BiRelay对象返回。
  2. 递到底了,现在要归,归到[0, 3]那一层。方法继续向下进行,发现右边还可以二分,继续分成[2, 3],又分到底了,再赋值并塞进BiRelay对象返回。
  3. 这次又归到[0, 3]这一层,但此时a和b都有值了,分别是[0, 1]和[2, 3]封装的BiRelay对象,再把这两个对象封装成BiRelay对象返回。
  4. 再归到[0, 7]这一层,左半边[0, 3]已经处理完并返回了,开始处理右半边[4, 7],那就和上面是一样的步骤啦。

        这个图画出来以后,思路就很清晰了。

  • 以两个任务为一组,当[0, 1]任务组完成,BiRelay对象会触发通知[0, 3],而此时[2, 3]还未完成,继续等待;
  • [2, 3]也完成了,[0, 3]发现两个源任务都完成,便可以尝试触发自己的依赖任务[0, 7],但此时同级的源任务[4, 7]还未完成,仍要再等等;
  • 右半边也是一样的道理,[4, 5]和[6, 7]完成时分别会通知[4, 7],都完成则通知[0, 7],此时[0, 3]也是完成状态,则触发[0, 7]的依赖任务——也就是allOf()方法产生的CF对象依赖栈内的任务,例如下面的形式。
CompletableFuture<Void> allOf = CompletableFuture.allOf(cf1, cf2, cf3, cf4, cf5);
allOf.thenRun(() -> {
    System.out.println("Hello, World");
});

        这样做的好处就是:每个结点(非叶子结点)的完成状态,只依赖他的两个子结点的状态。正如上文的二叉树结构图,想知道每个子节点是否完成,只需要他的子节点完成时通知他、并判断自己的两个子节点是否全部完成即可;如果是链表或者其他什么结构,则需要遍历整个数组的状态、或是通知数组内所有任务。

        allOf和anyOf的逻辑其实大部分都是相似的,都是在建立一棵树,只不过触发上级节点的条件不同,我们看看下面这段代码的区别。

    //BiRelay对象触发方法
    boolean biRelay(CompletableFuture<?> a, CompletableFuture<?> b) {
        Object r, s; Throwable x;
        //源任务有任何一个未完成,返回false继续等待
        if (a == null || (r = a.result) == null ||
            b == null || (s = b.result) == null)
            return false;

    //OrRelay对象触发方法
    final boolean orRelay(CompletableFuture<?> a, CompletableFuture<?> b) {
        Object r;
        //源任务全部为未完成,再返回false继续等待
        //换句话说,只要有一个完成,就触发源任务
        if (a == null || b == null ||
            ((r = a.result) == null && (r = b.result) == null))
            return false;

        allOf使用BiRelay,anyOf使用OrRelay,区别就是触发依赖任务的条件,一个是全完成再触发、一个是有完成的就触发,正对应两者使用上的区别。 

2.3 守护线程问题

        在进行测试时经常会发现,开启的线程明明逻辑还没有走完,但main方法还是自己结束了。使用CompletableFuture时也会发生这种现象,但在指定自定义线程池后这种现象又消失了。

        这个问题很明显是涉及“守护线程”,简单来说就是守护线程会在所有非守护线程结束后自己终止;比如垃圾回收线程就是一个标准的守护线程,因为都没有其他线程了,自然也就没有垃圾回收的必要,但垃圾回收线程如果一直空转程序又无法终止。同学们可以自己做个实验,自定义一个阻塞的线程后setDaemon为true,此时再启动程序会立即停止。

        那排查的思路就很清晰了,去看看CompletableFuture默认的线程池ForkJoinPool是不是有守护线程相关的设置。

final WorkQueue registerWorker(ForkJoinWorkerThread wt) {
    UncaughtExceptionHandler handler;
    wt.setDaemon(true);
    ......
}

        这一看果然嘛,在注册工作线程时将每个线程都设置为了守护线程,而其他线程池是没有的。因此更加推荐大家使用自定义线程池了,总没有坏处嘛。

3 写在结尾

        本篇整体耗时一月有余,绞尽脑汁才堪堪令自己满意。学习Doug Lea大师优雅的设计,狂喜之余甚至产生了一份自惭形秽的情绪,CompletableFuture同时兼顾的功能性、健壮性、高性能,其中的种种,在我认知中是不属于人类的智慧。但幸运的是,我拆开了这份天才赠予的礼物,一个多月时间的努力雕琢,力求不亵渎这份礼物的原貌,最终可以自信的说,我没有辜负标题的“自顶向下”。

        我们不是天才,但可以跟着巨人的脚印,无限进步。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值