佩服,CompletableFuture讲这么详细

作者:潘吉祥


在上一篇中,我们学会了使用CompletableFuture来实现我们的异步API,事实上CompletableFuture非常强大,他还有更多实用的方法。接下来,我们将详细学习关于CompletableFuture静态API的用法。

 

创建方法:supplyAsync

改进版的getVideosAsync方法可以这样写:

public static Future<List<Video>>getVideosAsync() {
    return CompletableFuture.supplyAsync(()-> selectVideos());
}

 

由示例可见,我们使用了CompletableFuture的静态工厂方法supplyAsync,此方法接受一个生产者(Supplier)作为参数,并返回一个CompletableFuture对象实例,该实例包装了生产者方法返回的类型数据。生产者方法的执行则默认交由名为ForkJoinPool的线程池进行执行,说默认是因为supplyAsync还有一个重载的方法(supplyAsync(Supplier<U> supplier, Executor executor)),接受一个自定义线程池的参数。这里稍加留意,因为这个重载的方法在开发中大有用处。

 

上面示例中的代码和上一篇中手动创建的CompletableFuture是完全等价的,然而代码量却少了很多,足够简洁、优雅。

 

现在,假设我们的“用户推荐视频列表”所调用的方法都是阻塞的,那么我们需要将它改造为异步的方式请求,避免被单一的请求阻塞整个服务调用链,以此提高我们系统的吞吐量。

 

干掉阻塞

首先我们来想象一下,我们的电影列表现在不只一个了,而是增加到4个。

final List<String> videos = Arrays.asList("夏洛特", "春物",
        "某科学的超电磁炮",
        "秒速五厘米");



 

那么使用顺序方式的调用:

public static List<Video> getVideos() {
    return selectVideos();
}
public static List<Video> selectVideos() {
    List<Video> videoList = videos.stream()
            .map(name -> {
                try {
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                return new Video(name);
            }).collect(Collectors.toList());
    return videoList;
}



测试效果

long start = System.nanoTime();
List<Video> videos = Video.getVideos();
long time = (System.nanoTime() - start) / 1_000_000;
System.out.println(videos);
System.out.println("视频列表查询异步返回耗时 " + time + "毫秒");

[Video{夏洛特}, Video{春物}, Video{某科学的超电磁炮}, Video{秒速五厘米}]

视频列表查询异步返回耗时 4050毫秒

跟我们预期的一样,花费了4秒多一点,因为它们是顺序执行的,每个执行都会阻塞1秒,总耗时会大于单个查询之和。那么我们如何改进这样的代码呢?

 

方式一:并行操作

public static List<Video> selectVideos() {
//   并行查询
    List<Video> videoList = videos.parallelStream()
            .map(name -> {
                try {
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                return new Video(name);
            }).collect(Collectors.toList());
    return videoList;
}
执行结果如下

[Video{夏洛特}, Video{春物}, Video{某科学的超电磁炮}, Video{秒速五厘米}]
视频列表查询异步返回耗时 1070毫秒



我们对selectVideos方法进行了改造,只是由原来的顺序流stream变成了并行流parallelStream,此方法内部使用了fork/join框架进行并行任务执行。效果看起来很不错!只用了顺序执行的1/4的时间!那这就是最好的吗,让我们使用CompletableFuture异步来试试。

 

方式二:CompletableFuture异步

public static List<Video> selectVideos() {
    List<CompletableFuture<Video>> futureList = videos.stream()
            // 将name映射为CompletableFuture<Video>
            .map(name -> CompletableFuture.supplyAsync(() -> {
                try {
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                return new Video(name);
                //汇总CompletableFuture<Video>
            })).collect(Collectors.toList());
    return futureList.stream()
            // 将CompletableFuture<Video>中取出Video
            .map(CompletableFuture::join)
            // 汇总为Video列表
            .collect(Collectors.toList());
}

注意,这里先后使用了两个stream流,而不是直接在一个流上完成所有的操作。很重要的一个原因是因为流的延迟处理特性。假如我们在一个流上处理所有操作,一次完整的操作包括创建CompletableFuture查询并获取数据,接着才能进行下一次的轮回操作。也就是说获取四个视频的操作是顺序进行的,很明显这会造成阻塞。(有兴趣的读者可以试一下在一个流上完成所有操作,结果会是4秒多)

 

在第二个流中我们使用了CompletableFuture.join方法,该方我们等价于get方法,同属阻塞方法,唯一不同的是join方法不会抛出错误,因此不必在函数参数中写臃肿的try代码块。

 

执行效果如下:

[Video{夏洛特}, Video{春物}, Video{某科学的超电磁炮}, Video{秒速五厘米}]
视频列表查询异步返回耗时 1048毫秒

好像有点失望,它的结果与第一种并行的方式大同小异,反而第二种异步的方式看起来做了大量的操作,性能却没有比并行提高多少。

 

但是,这就是全部了吗?那像类似的场景我们直接选择并行流更加方便省事吗?经验告诉我们,一切的事情往往没有看起来那么简单。

 

揭秘真相

上次的测试过程中,并行流的结果似乎表现了他能够为四个视频分别分配一个线程,因此在一秒多的时间返回。这里我非常在意视频的个数,假如说我们的视频增加到8个呢?

 

毫无疑问,顺序顺序执行的话,它的结果肯定会大于8秒:

[{夏洛特}, {春物},{某科学的超电磁炮}, {秒速五厘米},{言叶之庭}, {星之声},{徒然喜欢你}, {just because}]
视频列表查询异步返回耗时8046毫秒

那并行流呢?

[{夏洛特}, {春物},{某科学的超电磁炮}, {秒速五厘米},{言叶之庭}, {星之声},{徒然喜欢你}, {just because}]
视频列表查询异步返回耗时1067毫秒

居然还是这么优秀,在一秒多的时间内返回了!

 

再来测测CompletableFuture

[{夏洛特}, {春物},{某科学的超电磁炮}, {秒速五厘米},{言叶之庭}, {星之声},{徒然喜欢你}, {just because}]
视频列表查询异步返回耗时2050毫秒

居然比并行流慢了一秒钟!结果出来了:我们要使用并行流,放弃CompletableFuture异步。

 

打住打住,真相怎么可能这么简单粗暴呢,让我们严谨地将视频个数增加到10个再来看看!

 

顺序执行就不必了,肯定会是大于10秒钟的,让我们来关注一下并行流的效果:

[{夏洛特}, {春物},{某科学的超电磁炮}, {秒速五厘米},{言叶之庭}, {星之声},{徒然喜欢你}, {just because}, {天气之子},{君的名字}]
视频列表查询异步返回耗时2060毫秒

这厮终于慢了1秒!

 

再看CompletableFuture,读者可能会觉得更慢?来看结果:

[{夏洛特}, {春物},{某科学的超电磁炮}, {秒速五厘米},{言叶之庭}, {星之声},{徒然喜欢你}, {just because}, {天气之子},{君的名字}]
视频列表查询异步返回耗时2061毫秒

居然又和并行流不相伯仲了。这么看来,始终没有测出我们想象中的结果,揭秘的时刻来了:就以上的测试代码来说,不管是并行流还是CompletableFuture,他们内部都是采用的相同配置的线程池(ForkJoinPool),使用固定数量的线程,至于具体的数量是多少,这取决于Runtime.getRuntime().availableProcessors()的大小,即当前计算机的CPU个数。

 

当视频个数为8的时候并行流依然能够以一秒多的时间返回,是因为笔者电脑是8核。就内部线程池而言,CompletableFuture比并行流更加具有优势,因为它直接允许添加自定义的Executor,以更加灵活地适应程序的不同环境下的需求。相比较而言,并行流并没与提供类似的API,或者说并行流本身的设计并不想让开发者去修改这个的参数。

 

说得这么神奇,那CompletableFuture真的能更“优秀”?

 

自定义Executor

由测试数据而言,当视频数量达到10的时候,似乎并行流和CompletableFuture都无法在两秒内的时间返回。此时,自定义Executor应该是个好办法,问题又来了,那我们的自定义Executor的线程数量到底配多少合适呢?

 

配多少合适不是我说了算,也不是读者们说了算,业界大佬早已提供了标准的公式,这是我复制过来的:Nthreads = NCPU * UCPU * (1 + W/C)

 

解释如下:NCPU   是计算机的CPU核数;UCPU  代表期望的CPU利用率,既然是某某率,它势必是介于0和1之间的数字;W/C 是数据等待时间和计算时间的比率。

 

通常来说,在网络应用中,IO阻塞是经常发生的事情,我们的程序99%的时间通常是在获取数据和传输数据(除了一些以计算、算法为主的应用)。拿获取视频列表来说,它的时间99.9%消耗在获取数据上,所以我们可以得出W/C接近100。

 

如果我们期望我们的CPU利用率达到100%,我的电脑有8个cup,要创建800个线程的线程池。但事实上,我们的视频一共有10个,创建800个线程实属浪费,因为剩下的线程根本没有用武之地。因此,在本示例中,我应该设置为视频个数,假设我们的视频个数是变化的,那么我们不能直接设死为10,应该根据视频个数而动态变化。但是也不能无限制地根据视频个数上升,我们需要设置上限,防止线程过多撑爆服务器。下面给出自定义Executor代码:

private final Executor executor =
        Executors.newFixedThreadPool(
                // 当视频数量小于100的时候取视频数量,大于100的时候取100
                Math.min(videos.size(), 100),
                new ThreadFactory() {
                    public Thread newThread(Runnable r) {
                        Thread t = new Thread(r);
                        // 将线程设置为守护线程
                        t.setDaemon(true);
                        return t;
                    }
                });

上面我们创建了固定数量的Executor,且线程数量始终在100以内取视频数量大小,另外值得注意的是,我们创建的全部属于守护线程。我们都知道,线程是属于JVM级别的,如果我们在我们的web应用中私自启动了线程,那这个线程不会随着web应用程序的关闭而停止。相对地如果我们将将它设置为守护线程,那么随着程序的退出,他也会被自动回收。而且着两者并没有性能上的差异。

 

一切就绪,让我们把自定义Executor设置给视频查询方法:

public static List<Video> selectVideos() {
    System.out.println(Runtime.getRuntime().availableProcessors());
    List<CompletableFuture<Video>> futureList = videos.stream()
            // 将name映射为CompletableFuture<Video>
            .map(name -> CompletableFuture.supplyAsync(() -> {
                try {
                    Thread.sleep(1000L);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                return new Video(name);
                //汇总CompletableFuture<Video>
                // 使用重载方法传入自定义executor
            },executor)).collect(Collectors.toList());
    return futureList.stream()
            // 将CompletableFuture<Video>中取出Video
            .map(CompletableFuture::join)
            // 汇总为Video列表
            .collect(Collectors.toList());
}

 

让我们再来测试一下它的效果如何:

 

[{夏洛特}, {春物},{某科学的超电磁炮}, {秒速五厘米},{言叶之庭}, {星之声},{徒然喜欢你}, {just because}, {天气之子},{君的名字}]
视频列表查询异步返回耗时1052毫秒

居然!这次的查询既然在两秒之内就返回了!这可是查询10个视频,有点东西……

 

一般来说,这样的效果(两秒内返回)能够保持一段时间,如果在视频数量在100之内。有兴趣的读者可以试一试,这里不再演示。事实证明,在需要处理大量阻塞操作的时候,自定义Executor的CompletableFuture更加有效!

 

那到目前为止,可能有的读者会有这样的疑问,实际开发中我们应该使用并行流呢还是使用CompletableFuture?可能有的读者隐约有些想法了,这里直接给出参考建议:

 

并行流

如果你的任务属于计算密集型(CPU密集型)的操作,那么建议使用并行流。因为这类操作很少会有IO,我们的目的只是想充分利用CPU,那么对应机器的CPU核数的并行效率是最高的,而且使用简单。假设我们能够修改并行流的线程参数,那么很少IO阻塞的任务会额外增加线程切换的开销,这反而会起到反作用。

 

CompletableFuture

如果你的任务涉及较多的IO,通常来说是网络(socket)IO,此时我们的服务器的CPU资源大多是在等待中的,那么使用CompletableFuture更好,它更加灵活,根据你当前的实际情况配置对应的线程数量,提高服务的吞吐量。

 

关于并行和并发

通常来说,parallelStream方法我们称之为是并行,CompletableFuture为并发,主要的原因是二者的执行器(Executor)的区别。parallelStream的Executor默认固定线程池数量是当前硬件的CPU数量,且不推荐去更改。假如我们的计算器有8个CPU,我们可以说创建的8个线程在执行任务的时候是并行的。

 

CompletableFuture支持自定义Executor,当自定义的线程超过8个的时候,此时不是所有的线程都是并行的,因此并发来说更合适。当然了,如果我们自定义的线程还是8个,我们也可以说是并行的。

 

总结一下,到目前为止,我们已经学习了如何使用CompletableFuture设计异步API,提升我们服务的吞吐。但就目前而言,我们只是对单个服务的操作,比如只是对“视频列表”的改造,那么视频服务如何与后面的用户信息筛选、历史浏览记录等组合运行呢?

 

点个关注,在第四话,我们将学习到CompletableFuture对多个异步任务的流水编排。


【推荐阅读】
8年开发,连登陆接口都写这么烂...

面试被问:Thread.sleep(0) 到底有什么用?

腾讯,干掉 Redis 项目,正式开源、太牛逼啦!

SpringBoot集成WebSocket,实现后台向前端推送信息
CTO:再写if-else,逮着罚款1000!
Java日志体系整理,必看权威总结

52条SQL语句性能优化策略,建议收藏
听说又有兄弟因为用YYYY-MM-dd 被锤了...

又发现一款牛逼的 API 敏捷开发工具
排名前 16 的 Java 工具类,哪个你没用过?


评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

码农code之路

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值