1 了解parallelStream
parallelStream怎么实现的并行处理呢?
其底层是Fork/Join并行计算框架的默认线程池,默认线程池的数量就是处理器的数量,可以使用系统属性:-Djava.util.concurrent.ForkJoinPool.common.parallelism={N} 调整。
1.1 并发问题
HashMap
for (Clazz clazz : byClazzIds) {
map.put(clazz.getId(), clazz.getTeacherNames());
}
List
rows.stream().forEach(clazz -> {
String teaNames = map.get(clazz.getId());
if (StringUtils.isEmpty(teaNames)) {
unBindedClazzIds.add(clazz.getId());
} else {
clazz.setTeacherNames(teaNames);
}
});
如果为了提升性能直接将串行的stream()修改为并行的parallelStream(),这样做可以吗?
https://blog.csdn.net/weixin_58670730/article/details/125945959
1.2 共用默认线程池导致的阻塞问题
什么时候使用默认的Fork/Join,什么时候使用自定义的forkJoin
并行流1:教师端rpc获取班级,5秒延迟,此时会占用所有的线程资源
并行流2:学生获取题组下所有题目的详情
并行流3:学生端加载班课时并行处理报名记录
都使用默认的Fork/Join,此时如果并行流1中线程被占用,则并行流2和3的任务会一直处于阻塞状态,即原本不相关的业务因为共用了默认的Fork/Join的线程,会相互影响。
rpc\IO阻塞,使用锁(可能导致死锁)一定要自定义新的ForkJoin
parallelStream的坑, 相互阻塞问题
https://blog.51cto.com/u_14355948/2709545?b=totalstatistic
1.3 并发结果未同步问题
问题代码:
ForkJoinPool forkJoinPool = new ForkJoinPool(10);
forkJoinPool.execute(() -> {
// 已结课 并且大于30天
rows.parallelStream().forEach(item -> {
item.setClassImId(classIdToImId.getOrDefault(item.getId(), ""));
// *特殊:主讲老师的课程列表中,在线大班的班级卡片 不展示未读消息数量。
Integer capacityLabel = item.getCapacityLabel();
if (Objects.equals(teacherType, ZeroOrOneEnum.ZERO.getValue()) && Objects.equals(capacityLabel, ZeroOrOneEnum.ONE.getValue())) {
item.setUnreadMsgNum(0);
} else if (Objects.equals(type, ZeroOrOneEnum.ZERO.getValue()) && DateFormatUtil.isGtMonth(item.getEndTime())) {
// 已结课 并且大于30天
item.setUnreadMsgNum(0);
} else {
item.setUnreadMsgNum(imBiz.getClassIdUnreadMsgNum(item.getId(), classIdToImId));
}
});
});
dto.setRows(rows);
正确代码:
ForkJoinPool forkJoinPool = new ForkJoinPool(10);
forkJoinPool.submit(() -> {
// 已结课 并且大于30天
log.info("线程名称P"+ Thread.currentThread().getName());
rows.parallelStream().forEach(item -> {
log.info("线程名称P"+ Thread.currentThread().getName());
item.setClassImId(classIdToImId.getOrDefault(item.getId(), ""));
// *特殊:主讲老师的课程列表中,在线大班的班级卡片 不展示未读消息数量。
Integer capacityLabel = item.getCapacityLabel();
if (Objects.equals(teacherType, ZeroOrOneEnum.ZERO.getValue()) && Objects.equals(capacityLabel, ZeroOrOneEnum.ONE.getValue())) {
item.setUnreadMsgNum(0);
} else if (Objects.equals(type, ZeroOrOneEnum.ZERO.getValue()) && DateFormatUtil.isGtMonth(item.getEndTime())) {
// 已结课 并且大于30天
item.setUnreadMsgNum(0);
} else {
item.setUnreadMsgNum(imBiz.getClassIdUnreadMsgNum(item.getId(), classIdToImId));
}
});
}).invoke();
dto.setRows(rows);
由于 execute 方法是异步的,它不会等待任务执行完成就会立即返回,因此可能会导致程序在任务执行之前就已经退出。
submit 方法返回一个 ForkJoinTask 对象,可以使用该对象的 invoke 方法来阻塞当前线程,直到任务执行完成。
1.4 线程池的重复创建销毁导致的性能问题
forkJoinPool.shutdown();
在使用 ForkJoinPool 时,我们通常会在程序的最后调用 shutdown 方法来关闭线程池,以便释放线程和其他资源。如果不调用 shutdown 方法,线程池会一直保持打开状态,直到程序退出或被强制关闭。
forkJoinPool不应该在方法内部创建,应该是静态变量,且不销毁
2 了解CompletableFuture
2.1为什么要引入 CompletableFuture
Java 的 1.5 版本引入了 Future,你可以把它简单的理解为运算结果的占位符,它提供了两个方法来获取运算结果。
get():调用该方法线程将会无限期等待运算结果。
get(long timeout, TimeUnit unit):调用该方法线程将仅在指定时间 timeout 内等待结果,如果等待超时就会抛出 TimeoutException 异常。
Future 可以使用 Runnable 或 Callable 实例来完成提交的任务,通过其源码可以看出,它存在如下几个问题:
1)阻塞 调用 get() 方法会一直阻塞,直到等待直到计算完成,它没有提供任何方法可以在完成时通知,同时也不具有附加回调函数的功能。
2)链式调用和结果聚合处理 在很多时候我们想链接多个 Future 来完成耗时较长的计算,此时需要合并结果并将结果发送到另一个任务中,该接口很难完成这种处理。
3)异常处理 Future 没有提供任何异常处理的方式。
以上这些问题在 CompletableFuture 中都已经解决了,CompletableFuture 类通过实现了Future 接口和CompletionStage接口解决上面的问题,接下来让我们看看如何去使用 CompletableFuture。
2.2创建CompletableFuture对象
四种方式:
// 使用默认线程池
static CompletableFuture
static CompletableFuture supplyAsync(Supplier supplier)
// 可以指定线程池
static CompletableFuture
static CompletableFuture supplyAsync(Supplier supplier, Executor executor)
说明:
1)runAsync未指定线程池,默认情况下 CompletableFuture 会使用公共的 ForkJoinPool 线程池,这个线程池默认创建的线程数是 CPU 的核数(也可以通过 JVM option:-Djava.util.concurrent.ForkJoinPool.common.parallelism 来设置 ForkJoinPool 线程池的线程数)。如果所有 CompletableFuture 共享一个线程池,那么一旦有任务执行一些很慢的 I/O 操作,就会导致线程池中所有线程都阻塞在 I/O 操作上,从而造成线程饥饿,进而影响整个系统的性能。
所以,强烈建议你要根据不同的业务类型创建不同的线程池,以避免互相干扰
2)runAsync没有返回值,而supplyAsync有返回值,因为Runnable 接口的run() 方法没有返回值,而 Supplier 接口的 get() 方法是有返回值的。
2.3 任务时序的理解
假设有一个烧水泡茶的整体任务,其分工和任务如下:任务 1 负责洗水壶、烧开水,任务 2 负责洗茶壶、洗茶杯和拿茶叶,任务 3 负责泡茶。其中任务 3 要等待任务 1 和任务 2 都完成后才能开始。这个分工如下图所示.
任务是有时序关系的,比如有串行关系、并行关系、汇聚关系等。其中洗水壶和烧开水就是串行关系,洗水壶、烧开水和洗茶壶、洗茶杯这两组任务之间就是并行关系,而烧开水、拿茶叶和泡茶就是汇聚关系。
CompletableFuture实现的CompletionStage 接口可以清晰地描述任务之间的这种时序关系。
2.4 CompletableFuture的对任务时序处理的支持
1)串行时序
CompletionStage 接口里面描述串行关系,主要是 thenApply、thenAccept、thenRun 和 thenCompose 这四个系列的接口。
thenApply 系列函数里参数 fn 的类型是接口 Function
thenAccept 系列方法里参数 consumer 的类型是接口Consumer
thenRun 系列方法里 action 的参数是 Runnable,所以 action 既不能接收参数也不支持返回值,所以 thenRun 系列方法返回的也是CompletionStage
这些方法里面 Async 代表的是异步执行 fn、consumer 或者 action。其中,需要你注意的是 thenCompose 系列方法,这个系列的方法会新创建出一个子流程,最终结果和 thenApply 系列是相同的。
CompletionStage
CompletionStage
CompletionStage
CompletionStage
CompletionStage
CompletionStage
CompletionStage
CompletionStage
通过下面的示例代码,你可以看一下 thenApply() 方法是如何使用的。首先通过 supplyAsync() 启动一个异步流程,之后是两个串行操作,整体看起来还是挺简单的。不过,虽然这是一个异步流程,但任务①②③却是串行执行的,②依赖①的执行结果,③依赖②的执行结果。
CompletableFuture
CompletableFuture.supplyAsync(
() -> "Hello World") //①
.thenApply(s -> s + " QQ") //②
.thenApply(String::toUpperCase);//③
System.out.println(f0.join());
// 输出结果
HELLO WORLD QQ
2)AND 汇聚关系
CompletionStage 接口里面描述 AND 汇聚关系,主要是 thenCombine、thenAcceptBoth 和 runAfterBoth 系列的接口,这些接口的区别也是源自 fn、consumer、action 这三个核心参数不同。
CompletionStage
CompletionStage
CompletionStage
CompletionStage
CompletionStage
CompletionStage
3)OR 汇聚关系
CompletionStage 接口里面描述 OR 汇聚关系,主要是 applyToEither、acceptEither 和runAfterEither 系列的接口,这些接口的区别也是源自 fn、consumer、action 这三个核心参数不同
CompletionStage applyToEither(other, fn);
CompletionStage applyToEitherAsync(other, fn);
CompletionStage acceptEither(other, consumer);
CompletionStage acceptEitherAsync(other, consumer);
CompletionStage runAfterEither(other, action);
CompletionStage runAfterEitherAsync(other, action);
2.5 异常处理
虽然上面我们提到的 fn、consumer、action 它们的核心方法都不允许抛出可检查异常,但是却无法限制它们抛出运行时异常,例如下面的代码,执行 7/0 就会出现除零错误这个运行时异常。非异步编程里面,我们可以使用 try{}catch{}来捕获并处理异常,那在异步编程里面,异常该如何处理呢?
CompletableFuture
f0 = CompletableFuture.
.supplyAsync(()->(7/0))
.thenApply(r->r*10);
System.out.println(f0.join());
CompletionStage 接口给我们提供的方案非常简单,比 try{}catch{}还要简单,下面是相关的方法,使用这些方法进行异常处理和串行操作是一样的,都支持链式编程方式。
CompletionStage exceptionally(fn);
CompletionStage
CompletionStage
CompletionStage
CompletionStage
下面的示例代码展示了如何使用 exceptionally() 方法来处理异常,exceptionally() 的使用非常类似于 try{}catch{}中的 catch{},但是由于支持链式编程方式,所以相对更简单。既然有 try{}catch{},那就一定还有 try{}finally{},whenComplete() 和 handle() 系列方法就类
似于 try{}finally{}中的 finally{},无论是否发生异常都会执行 whenComplete() 中的回调函数 consumer 和 handle() 中的回调函数 fn。whenComplete() 和 handle() 的区别在于whenComplete() 不支持返回结果,而 handle() 是支持返回结果的。
CompletableFuture
f0 = CompletableFuture
.supplyAsync(()->7/0))
.thenApply(r->r*10)
.exceptionally(e->0);
System.out.println(f0.join());
3 Fork/Join并行计算框架
分治任务模型
这里你需要先深入了解一下分治任务模型,分治任务模型可分为两个阶段:一个阶段是任务分解,也就是将任务迭代地分解为子任务,直至子任务可以直接计算出结果;另一个阶段是结果合并,即逐层合并子任务的执行结果,直至获得最终结果。如下图:
Fork/Join 的使用
Fork/Join 是一个并行计算的框架,主要就是用来支持分治任务模型的,这个计算框架里的Fork 对应的是分治任务模型里的任务分解,Join 对应的是结果合并。Fork/Join 计算框架主要包含两部分,一部分是分治任务的线程池 ForkJoinPool,另一部分是分治任务 ForkJoinTask,他俩的关系类似于 ThreadPoolExecutor 和 Runnable 的关系,都可以理解为提交任务到线程池,只不过分治任务有自己独特类型 ForkJoinTask。
ForkJoinTask 是一个抽象类,它的方法有很多,最核心的是 fork() 方法和 join() 方法,其中 fork() 方法会异步地执行一个子任务,而 join() 方法则会阻塞当前线程来等待子任务的执行结果。ForkJoinTask 有两个子类——RecursiveAction 和 RecursiveTask,它们都是用递归的方式来处理分治任务的。
这两个子类都定义了抽象方法 compute(),不过区别是 RecursiveAction 定义的 compute() 没有返回值,而 RecursiveTask 定义的 compute() 方法是有返回值的。这两个子类也是抽象类,在使用的时候,需要你定义子类去扩展。
接下来我们就来实现一下,看看如何用 Fork/Join 这个并行计算框架计算斐波那契数列(下面的代码源自 Java 官方示例)。
static void main(String[] args){
// 创建分治任务线程池
ForkJoinPool fjp = new ForkJoinPool(4);
// 创建分治任务
Fibonacci fib = new Fibonacci(30);
// 启动分治任务
Integer result = fjp.invoke(fib);
// 输出结果
System.out.println(result);
}
// 递归任务
static class Fibonacci extends RecursiveTask
final int n;
Fibonacci(int n){this.n = n;}
protected Integer compute(){
if (n <= 1)
return n;
Fibonacci f1 = new Fibonacci(n - 1);
// 创建子任务
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
// 等待子任务结果,并合并结果 f1通过join() 方法则会阻塞当前线程来等待子任务的执行结果
return f2.compute() + f1.join();
}
}
ForkJoinPool 工作原理
一般的线程池都是有多个工作线程,但是这些工作线程都共享一个任务队列。
ThreadPoolExecutor 内部只有一个任务队列,而 ForkJoinPool 内部有多个任务队列,当我们通过 ForkJoinPool 的 invoke() 或者 submit() 方法提交任务时,ForkJoinPool 根据一定的路由规则把任务提交到一个任务队列中,如果任务在执行过程中会创建出子任务,那么子任务会提交到工作线程对应的任务队列中。
如果工作线程对应的任务队列空了,是不是就没活儿干了呢?不是的,ForkJoinPool 支持一种叫做“任务窃取”的机制,如果工作线程空闲了,那它可以“窃取”其他工作任务队列里的任务,例如上图中,线程 T2 对应的任务队列已经空了,它可以“窃取”线程 T1 对应的任务队列的任务。如此一来,所有的工作线程都不会闲下来了。
ForkJoinPool 中的任务队列采用的是双端队列,工作线程正常获取任务和“窃取任务”分别是从任务队列不同的端消费,这样能避免很多不必要的数据竞争。
4 总结
CompletableFuture和并行流都是以 ForkJoinPool 为基础的。默认情况下所有并行流和CompletableFuture计算都共享一个 ForkJoinPool,这个共享的 ForkJoinPool 默认的线程数是 CPU 的核数;如果所有的计算都是 CPU 密集型计算的话,完全没有问题,但是如果存在 I/O 密集型的并行流计算,那么很可能会因为一个很慢的 I/O 计算而拖慢整个系统的性能。所以建议使用CompletableFuture和并行流时应该使用指定的线程池。