
作者:潘吉祥
在上一篇中,我们学会了使用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 工具类,哪个你没用过?
1055

被折叠的 条评论
为什么被折叠?



