【CompletableFuture详解】换一种理解角度 - 被忽略的函数式接口

CompletableFuture

前言

很多文章在介绍CompletableFuture时都是照功能将方法划分为不同类型,直接解释具体方法的使用规则。可如果你本就英语不好,看到几个差不多的单词排列组合成几十种方法是不是头皮发麻了?又或者你经常搞混或忘记相似方法,实战根本不知道怎么用。进一步讲,当嵌套了多组括号和回调,且由于不同方法的入参和返回值不同,经常代码检查报错摸不着头脑。

如果你有这些感觉,那么看完本文对你一定会有收获。本文不会直接告诉你那么多API具体如何使用,而是教会你如何高效区分和掌握不同API的用法。

简介

JDK8中新增了CompletableFuture类作为异步任务编排的解决方案,它可以被认为是Future的扩展,采用了函数式编程思想简化了任务编排和回调地狱,本文的CompletableFuture会以JDK8为准。

函数式接口

如何做到快速掌握用法?其实很简单,就是被忽略的函数式接口。很多文章认为直接把方法翻译为具体功能更简单易懂,其实不然,其听我慢慢道来。

我们先来看看thenCombine方法的声明:

java 复制代码
public <U,V> CompletableFuture<V> thenCombine
        (CompletionStage<? extends U> other,
         BiFunction<? super T,? super U,? extends V> fn) {
    return doThenCombine(other.toCompletableFuture(), fn, null);
}

这?这一坨都是啥?不急,你不需要理解任何东西,混个眼熟就行,根据功能的不同,CompletableFuture中API的函数式接口入参只有以下六种:

函数式接口 入参 返回值 解释
Runnable - - 可运行函数:无入参,无结果
Supplier - T 提供者函数:仅返回结果
Consumer T - 消费者函数:仅消费参数
Function<T, R> T R 一元函数:输入参数,产生结果
BiFunction<T, U, R> T, U R 二元函数:输入两个参数,产生结果
BiConsumer<T, U> T, U - 二元消费者:输入两个参数,无结果

函数式接口是JDK8新增的一个有且仅有一个抽象方法的接口,用于规定函数式方法,可适用于lambda表达式的书写。当然在本文你可以简单理解为用于规定任务的函数特点(参数和返回值)。

那么请猜一下下面这个函数式接口的含义是什么?

java 复制代码
Function<? super T, ? extends CompletionStage<U>>

根据上表,它表示一个一元函数,入参为T以及其子类,返回值为CompletionStage<U>类型及其子类。

CompletionStage<U>是何方神圣?它即是CompletableFuture除了Future以外实现的另一个接口,它被描述为异步任务的阶段,定义的任务的阶段操作都会返回一个CompletionStage类型,可链式调用编排任务。

那么回到刚刚的thenCombine方法,看看它的入参和返回值:

java 复制代码
// thenCombine入参
(CompletionStage<? extends U> other, BiFunction<? super T,? super U,? extends V> fn)
// thenCombine返回值
CompletableFuture<V>

接受一个CompletionStage和一个BiFunction(二元函数),返回一个CompletionStage

注意以下几部分:

  • 返回值的泛型<V>,它是参数二BiFunction<? super T,? super U,? extends V>二元函数定义的返回值泛型。
  • 参数一的泛型<U>,它是参数二BiFunction<? super T,? super U,? extends V>二元函数定义的第二个入参泛型。
  • 那么二元函数剩下的那个<T>代表什么呢?没错!(突然激动)正是调用者CompletableFuture本身的泛型!

最后再看看这个函数的名称thenCombine:"然后结合",那么这个API的功能就显而易见了:调用者本身作为一个任务产生一个结果T,方法参数传入另一个任务产生结果U,通过二元函数BiFunction结合两个结果产生最终的返回值V

让我们看看这个方法实际的用法,其中两个任务是同时进行的,后面会讲到:

java 复制代码
// supplyAsync异步执行第一个任务
CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> {
    print("厨师做饭");
    return new Food("蛋炒饭", 40);
    // thenCombine结合另一个supplyAsync异步任务
}).thenCombine(CompletableFuture.supplyAsync(() -> {
    print("厨师煲汤");
    return new Food("鱼汤", 20);
    // 二元函数接收两个任务结果,处理并返回最终结果(使用Lambda表达式简化匿名内部类的书写)
}), (rice, soup) -> {
    print("顾客吃饭:" + rice.getName() + " 顾客喝汤:" + soup.getName());
    return rice.getPrice() + soup.getPrice();
});
print("餐厅收款:" + future2.join() + "元");

如果你还是有点晕,让我们再看下面两个方法:

java 复制代码
// thenCompose
public <U> CompletableFuture<U> thenCompose(Function<? super T, ? extends CompletionStage<U>> fn) {
    return doThenCompose(fn, null);
}
// thenApply
public <U> CompletableFuture<U> thenApply(Function<? super T,? extends U> fn) {
    return doThenApply(fn, null);
}

这两个方法的功能都是作为任务的回调函数,通过一元函数Function处理任务结果,产生最终结果,他们有什么区别呢?

注意看一元函数Function

  • thenCompose方法一元函数返回值的泛型为<? extends CompletionStage<U>>
  • thenApply方法一元函数返回值的泛型为<? extends U>

也就是说,thenCompose必须返回另一个CompletionStage任务结果,而thenApply可以返回任意形式的结果。这和它们的名称也是一致的,Compose为组合(任务),Apply为接受。

还有一个类似功能的回调方法:

java 复制代码
public CompletableFuture<Void> thenAccept(Consumer<? super T> action) {
    return doThenAccept(action, null);
}

那么它与前两者的区别也很明显了,thenAccept的入参是Consumer接口,不产生返回值。

什么是恍然大明白!看到这里你已经完全掌握CompletableFuture了(开个玩笑)

接下来会带你全方面认识CompletableFuture,如果你能理解上文说所的,那么继续往下看你就可以很轻松地理解区分CompletableFuture的其他方法。

方法详解

CompletableFuture的API可以粗略分为:创建任务、获取结果、任务编排、特殊处理,其中任务编排包括任务连接和任务组合两种。

创建任务

API 入参 返回值 解释
supplyAsync Supplier CompletableFuture 异步任务,有返回值
runAsync Runnable CompletableFuture 异步任务,无返回值
completedFuture U CompletableFuture 直接获取一个任务结果
  • supplyAsync方法和runAsync方法通过实现不同的函数式接口获得一个异步任务,还记得函数式接口那张表吗,Supplier提供一个返回值,而Runnable不提供。
  • completedFuture方法可以直接传递一个处理完的结果,不需要实现一个函数式接,可以用于将结果封装为任务。
java 复制代码
// 异步创建一个有返回值的任务
CompletableFuture<Food> future1 = CompletableFuture.supplyAsync(() -> {
    print("厨师做饭");
    return new Food("蛋炒饭", 40);
});
// 异步创建一个没有返回值的任务
CompletableFuture<Void> future2 = CompletableFuture.runAsync(() -> System.out.println("厨师做饭"));                                               
// 直接创建一个任务
CompletableFuture<Object> future3 = CompletableFuture.completedFuture(new Food("蛋炒饭", 40));

获取结果

CompletableFuture实现了Future接口获取返回值的方法:

java 复制代码
V get() throws InterruptedException, ExecutionException;

V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;

除此之外,你还可以使用join方法,区别是join帮你捕获了异常:

java 复制代码
// 等待任务完成获取返回值,需要手动捕获或抛出异常
try {
    Food food = future1.get();
} catch (InterruptedException | ExecutionException e) {
    throw new RuntimeException(e);
}
// 等待任务完成获取返回值,不需要手动处理异常
Food food = future1.join();

任务编排

任务编排的API都有三种形式,分别为:

java 复制代码
xxx(args...);
xxxAsync(args...);
xxxAsync(args..., Executor executor);
  • 其中不带Async的为原始方法,默认使用CompletableFuture自带的线程池ForkJoinPool
  • 带有Async的被看作是另一个任务阶段,带有Executor的方法可以传入自定义的线程池用于执行任务,带有Executor的方法使用ForkJoinPool

这些方法返回值都是CompletionStage类型,具体泛型根据参数中的函数式接口返回值判断,若是Runnable则为<Void>,否则为函数式接口返回值泛型,例如<U><V>

以刚刚的thenApply为例:

java 复制代码
// xxx(args...);
CompletionStage<U> thenApply(Function<? super T,? extends U> fn);
// xxxAsync(args...);
CompletionStage<U> thenApplyAsync(Function<? super T,? extends U> fn);
// xxxAsync(args..., Executor executor);
CompletionStage<U> thenApplyAsync(Function<? super T,? extends U> fn, Executor executor);

之前创建任务的两个方法supplyAsync和runAsync都有对应的Executor重载方法,但没有非Async方法。后面的方法都以不带Async的为例。

任务连接

API 入参
thenCompose Function<? super T, ? extends CompletionStage>
thenAccept Consumer<? super T>
thenApply Function<? super T,? extends U>
thenRun Runnable

以上4种方法都是等待任务结束后执行的回调方法,它们的用法区别为:任务之间的交互性质不同(参数和返回值),选用不同的函数式接口实现。

例如下面的代码,顾客需要接收前面厨师返回的蛋炒饭,吃完返回金钱,因此选用thenCompose这一使用Function函数式接口的方法。

java 复制代码
// thenCompose连接两个任务
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    print("厨师做饭");
    return new Food("蛋炒饭", 40);
}).thenCompose(food -> CompletableFuture.supplyAsync(() -> {
        print("顾客吃饭:" + food.getName());
        // TODO 扣减金钱操作
        return food.getPrice();
}));

你也许会好奇,thenApply方法不也是Function吗,该怎么选,偷偷告诉你在前面理解函数式接口一节中已经讲述了这几个方法之间的区别了,如果你已经忘了,那......建议反复阅读第二节。 其实使用thenApply也是可以的,只不过thenCompose需要明确返回另一个CompletionStage任务对象。

任务组合

API 入参1 入参2 解释
runAfterBoth CompletionStage<?> Runnable 两个任务都完成后执行可运行函数
thenCombine CompletionStage<? extends U> BiFunction<? super T,? super U,? extends V> 两个任务都完成后返回值传递给二元函数
applyToEither CompletionStage<? extends T> Function<? super T, U> 任意任务完成后返回值传递给一元函数
acceptEither CompletionStage<? extends T> Consumer<? super T> 任意任务完成后返回值传递给消费者

注意几点:

  • 这四种方法都是用来组合两个CompletionStage任务的,第一个任务即调用者,第二个任务作为参数传入。
  • 前两种方法需要实现不关心参数的Runnable,一个是需要实现带有两个参数的BiFunction,对应功能为等待两个任务都完成。
  • 后两种方法传入的任务泛型为<T>,即CompletableFuture调用者本身泛型,也就是说两个任务的返回类型要保持一致,这与其功能是一致的,即任意任务结束后返回值传递给后面定义的函数处理。
java 复制代码
// 使用applyToEither组合两个任务。
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    print("一号公交车正在赶来");
    return 1;
}).applyToEither(CompletableFuture.supplyAsync(() -> {
    print("二号公交车正在赶来");
    return 2;
    // 开启了第三个任务用于实现一元函数
}), number -> CompletableFuture.supplyAsync(() -> {
    print("上了" + number + "号公交");
    return number;
}).join());

如果一元函数使用代码块,不开启第三个任务,则上公交车的任务与先赶来的公交车的任务处于同一阶段,不会开启新线程。如果此时改用applyToEitherAsync则会直接开启第三任务线程。

java 复制代码
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
    print("一号公交车正在赶来");
    return 1;
}).applyToEither(CompletableFuture.supplyAsync(() -> {
    print("二号公交车正在赶来");
    return 2;
    // 上了哪辆车,就属于哪个任务
}), number -> {
    print("上了" + number + "号公交");
    return number;
});

任务组合还有两个常用的方法:

API 入参 解释
allOf CompletableFuture<?>... cfs 所有任务完成后产生结果
anyOf CompletableFuture<?>... cfs 任意任务结束后产生结果

这两个方法可以解决多个任务相互组合的情景,以上面公交车为例,有三个公交车的情况可以这样写:

java 复制代码
CompletableFuture<Integer> bus1 = CompletableFuture.supplyAsync(() -> {
    print("一号公交车正在赶来");
    sleep(2);
    return 1;
}, executor);
CompletableFuture<Integer> bus2 = CompletableFuture.supplyAsync(() -> {
    print("二号公交车正在赶来");
    sleep(1);
    return 2;
}, executor);
CompletableFuture<Integer> bus3 = CompletableFuture.supplyAsync(() -> {
    print("三号公交车正在赶来");
    sleep(2);
    return 3;
}, executor);
CompletableFuture.anyOf(bus1, bus2, bus3).thenApply(number -> {
    print("上了" + number + "号公交");
    return number;
});

或者简写成:

java 复制代码
CompletableFuture.anyOf(
    CompletableFuture.supplyAsync(() -> {
        print("一号公交车正在赶来");
        return 1;
    }, executor),
    CompletableFuture.supplyAsync(() -> {
        print("二号公交车正在赶来");
        return 2;
    }, executor),
    CompletableFuture.supplyAsync(() -> {
        print("三号公交车正在赶来");
        return 3;
    }, executor)
).thenApply(number -> {
    print("上了" + number + "号公交");
    return number;
});

上面介绍的10种任务编排方法,每种方法有3种形式,一共会产生30种方法,根据函数式接口和函数名能够迅速区分开它们的用法。

特殊处理

如果任务里出现了异常该如何通知其他任务,如何进行处理呢?CompletableFuture提供了集中特殊处理的方法:

java 复制代码
public CompletionStage<T> whenComplete(BiConsumer<? super T, ? super Throwable> action);

public <U> CompletionStage<U> handle(BiFunction<? super T, Throwable, ? extends U> fn);

public CompletionStage<T> exceptionally(Function<Throwable, ? extends T> fn);

这几个方法可以实现回调函数处理异常,其中whenCompletehandle同样有对应的xxxAsync形式。前面任务若抛出异常则交给Throwable参数,若无异常则为null。

相信你已经可以通过函数式接口很轻松的区分它们的用法了。

除此之外,CompletableFuture还提供了其他一些操作例如取消和超时操作等,不再演示。

原理详解

ForkJoinPool

CompletableFuture默认提供的线程池为ForkJoinPool,他的核心线程数为处理器数量减一,例如8核16线程的电脑对应的最大线程数为15。

为了达到业务线程隔离的目的,通常推荐使用自定义Executor的异步方法,也就是xxxAsync带有Executor参数的方法,且子任务和父任务应该使用不同的线程池,防止线程池循环引用导致死锁。

Async方法理解

刚刚等待两辆公交车的例子中使用了applyToEither,哪辆公交车先到达,乘客的函数任务线程就会沿用公交车对应的线程,这很好理解,但如果是厨师的例子呢,顾客函数等待做饭和煲汤两个任务:

java 复制代码
CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> {
    print("厨师做饭");
    sleep(200);
    return new Food("蛋炒饭", 40);
}, EXECUTOR).thenCombine(CompletableFuture.supplyAsync(() -> {
    print("厨师煲汤");
    sleep(1000);
    return new Food("鱼汤", 20);
}, EXECUTOR), (rice, soup) -> {
    print("顾客吃饭:" + rice.getName() + " 顾客喝汤:" + soup.getName());
    return rice.getPrice() + soup.getPrice();
});
print("餐厅收款:" + future2.join() + "元");

这里我让做饭随眠200毫秒,煲汤随眠1000毫秒,打印出结果(睡眠和打印都是自定义的方法):

rust 复制代码
pool-1-thread-1 -> 厨师做饭
pool-1-thread-2 -> 厨师煲汤
pool-1-thread-2 -> 顾客吃饭:蛋炒饭 顾客喝汤:鱼汤
main -> 餐厅收款:60元

结果顾客线程与睡眠时间较长的任务使用了同一个线程吗,我们把睡眠时间交换一下同样成立:

rust 复制代码
pool-1-thread-1 -> 厨师做饭
pool-1-thread-2 -> 厨师煲汤
pool-1-thread-1 -> 顾客吃饭:蛋炒饭 顾客喝汤:鱼汤
main -> 餐厅收款:60元

如果我们把thenCombine换成thenCombineAsync,且使用自定义线程池EXECUTOR,可以看到函数任务在第三个线程执行了:

rust 复制代码
pool-1-thread-1 -> 厨师做饭
pool-1-thread-2 -> 厨师煲汤
pool-1-thread-3 -> 顾客吃饭:蛋炒饭 顾客喝汤:鱼汤
main -> 餐厅收款:60元

通过这两个简单的例子我们很容易猜到非Async方法的任务线程会保持与唤醒这个它的任务线程处于同一个CompletionStage任务阶段,那么他底层是如何实现的呢?

Completion

实际上,CompletableFuture维护一个CompletionNode链栈,每个栈结点中有一个Completion类型的抽象父类,用于注册不同的阶段操作,可以理解为"观察者"。

java 复制代码
volatile CompletionNode completions; // list (Treiber stack) of completions

static final class CompletionNode {
    final Completion completion;
    volatile CompletionNode next;
    CompletionNode(Completion completion) { this.completion = completion; }
}

@SuppressWarnings("serial")
abstract static class Completion extends AtomicInteger implements Runnable {
}

CompletableFuture内部有众多Completion类型的内部类实现,例如:

java 复制代码
static final class ThenApply<T,U> extends Completion {
    final CompletableFuture<? extends T> src;
    final Function<? super T,? extends U> fn;
    final CompletableFuture<U> dst;
    final Executor executor;
// 省略内部方法......
    
static final class ThenCombine<T,U,V> extends Completion {
    final CompletableFuture<? extends T> src;
    final CompletableFuture<? extends U> snd;
    final BiFunction<? super T,? super U,? extends V> fn;
    final CompletableFuture<V> dst;
    final Executor executor;
// 省略内部方法......

这里以依旧以thenCombine厨师为例,我们跟进源码看看:

继续进入doThenCombine方法:

  • 1631:程序判断了两个任务的result是否都有值,不满足条件进入if语句内(提前设置任务随眠)。
  • 1632:这里创建了一个ThenCombine类并把自身任务、另一个任务、二元函数fn等信息存入,这个ThenCombine就是上面提到的Completion的一个具体类型。
  • 1633:创建了一个CompletionNode结点保存ThenCombine,即入栈。

继续往后走,程序依赖于Unsafe类,对两个任务的返回值、异常进行了一系列的CAS判断,如果在这期间两个任务都执行完毕,且均未抛出异常,程序则会执行二元函数BiFunction的apply方法,也就是lambda表达式内的方法,并把两个任务的结果传递进去:

如果把随眠时间调整长一些重新,程序注册完Completion后很快就退出了方法thenCombine

然后在supplyAsync任务中打上断点,可以看见执行execAsync方法前创建了一个任务AsyncSupply对象,它是实际上需要执行的任务:

继承实现关系为:AsyncSupply --继承--> Async --实现--> Runnable

execAsync方内即执行了AsyncSupply任务:

那么AsyncSupply做了什么?它执行了函数式接口Supplier的具体实现方法fn,获取了返回值u后传递给了internalComplete方法:

这里同样用到了Unsafe类,继续进入postComplete方法:

注意为了保证执行到此处的任务是最后一个任务,取消任务二的随眠时间,或者跳过第一个任务的断点

这里程序进行了循环弹栈操作,两个任务结果都能够通过Completion获取。

上面运行的run方法即ThenCombine的run方法,我们进入run方法看看:

还记得刚刚注册Completion的代码吗,由于我们选用了非Async方法,传入的Executor为null,于是直接在当前线程调用了二元函数fn的apply方法(随眠时间较长的任务一),这就解释了为什么非Async方法执行的函数式接口方法与上一阶段的任务处于同一个线程了,实际上CompletableFuture把它们当作任务的同一阶段了。

请注意下面三种情况,加深理解:

java 复制代码
// 情况一=================================================
CompletableFuture.supplyAsync(() -> {
    // 任务一
    return 1;
}).thenCombine(CompletableFuture.supplyAsync(() -> {
    // 任务二
    return 2;
}), (f1, f2) -> {
    // 代码块与后结束的任务处于同一线程
    return f1 * f2;
});
// 情况二=================================================
CompletableFuture.supplyAsync(() -> {
    // 任务一
    return 1;
}).thenCombine(CompletableFuture.supplyAsync(() -> {
    // 任务二
    return 2;
}), (f1, f2) -> CompletableFuture.supplyAsync(() -> {
    // 开启了第三个supplyAsync异步任务,处于新线程
    return f1 * f2;
}));
// 情况三=================================================
CompletableFuture.supplyAsync(() -> {
    // 任务一
    return 1;
}).thenCombine(CompletableFuture.supplyAsync(() -> {
    // 任务二
    return 2;
}), (f1, f2) -> {
    // 外层代码块与后结束的任务处于同一线程
    return CompletableFuture.supplyAsync(() -> {
        // 开启了第三个supplyAsync异步任务,处于新线程
        return f1 * f2;
    }).join();
});

总结

CompletableFuture的任务在不同阶段的操作依赖于CompletionStage阶段。

而任务编排方法中,相互关联的任务被互相注册为对应类型的Completion并压入CompletionNode栈,在完成任务后根据Completion判断任务是否需要进入下一阶段,弹栈通知注册任务执行相应的方法,也可能是执行后续阶段的方法。

这个过程中CompletableFuture依赖于Unsafe类的CAS操作,大部分操作实现类无锁并发。

当然上述流程省略了大量细节,CompletableFuture的运行机制远比这些复杂的多,欢迎纠错和补充。

相关推荐
憨子周1 小时前
2M的带宽怎么怎么设置tcp滑动窗口以及连接池
java·网络·网络协议·tcp/ip
霖雨2 小时前
使用Visual Studio Code 快速新建Net项目
java·ide·windows·vscode·编辑器
SRY122404192 小时前
javaSE面试题
java·开发语言·面试
Fiercezm3 小时前
JUC学习
java
无尽的大道3 小时前
Java 泛型详解:参数化类型的强大之处
java·开发语言
ZIM学编程3 小时前
Java基础Day-Sixteen
java·开发语言·windows
我不是星海3 小时前
1.集合体系补充(1)
java·数据结构
P.H. Infinity3 小时前
【RabbitMQ】07-业务幂等处理
java·rabbitmq·java-rabbitmq
爱吃土豆的程序员3 小时前
java XMLStreamConstants.CDATA 无法识别 <![CDATA[]]>
xml·java·cdata
2401_857610034 小时前
多维视角下的知识管理:Spring Boot应用
java·spring boot·后端