并行流
并行流把数据拆成多个分区,由多个任务分别计算,再合并结果。它适合容易拆分、计算量足够且没有共享副作用的 CPU 运算。
本文沿着拆分、调度和合并的顺序解释执行过程,再分析顺序、共享状态和阻塞 I/O 的影响。最后说明服务端怎样判断并行流的收益,以及公共线程池带来的资源限制。
1. 面试核心
先记住这几条,再读正文- 数据拆分。 并行流用
Spliterator.trySplit()划分数据范围,再按终止操作的规则合并各分区结果。 - 工作窃取。 Fork/Join 工作线程从自己的双端队列处理任务,空闲线程窃取其他队列的待执行任务,以缓解负载不均。卡 · Spliterator 拆分
- 结果顺序。 有序并行流可以保留最终结果的遇到顺序,同时并发计算不同元素。
- 公共池隔离。 OpenJDK 21 中,普通线程发起的并行流使用公共
ForkJoinPool,阻塞任务会影响其他使用者。 - 并行收益。 并行流的计算收益取决于拆分质量、单项计算量和可用 CPU,调度、合并与共享状态竞争都会增加成本。卡 · 外部可变状态
2. Spliterator 拆分
把计算交给多个线程前,需要先把输入分给不同任务。Spliterator 是能遍历并拆分数据源的接口。并行流水线调用 trySplit(),尝试把一个数据范围分成更小的范围:
[0 ................ 15]
/ \
[0 ...... 7] [8 ...... 15]
/ \ / \
[0..3] [4..7] [8..11] [12..15]
图中 16 项数据被逐步拆成四个分区,每个分区处理 4 项。数组和 ArrayList 可以按索引均匀拆分,成本较低。链表、迭代器和 I/O 数据源需要更多遍历或读取,拆分成本更高。即使元素数量相同,单项耗时差异也会让任务完成时间不一致。
Spliterator 还通过特征声明数据是否有顺序、大小是否已知、元素是否互异。Stream 实现据此选择拆分与合并策略,但数据源本身仍需能够有效拆分。
3. ForkJoinPool
拆出的任务由 Fork/Join 模型调度。Fork/Join 把大任务分成子任务,并在子任务完成后组合结果,ForkJoinPool 则提供执行这些任务的工作线程。
OpenJDK 21 中,普通线程发起的并行流使用公共池 ForkJoinPool.commonPool(),调用线程也可参与计算。Fork/Join 工作线程内启动的子任务可以使用该线程所在的池。这些是实现层的行为,Stream API 本身没有指定执行器的参数。
一次计算按拆分、执行、窃取和合并展开:
- 大任务递归拆成较小任务。
- 每个 Worker,也就是工作线程,优先处理自己双端队列中的任务。
- 空闲 Worker 从其他工作线程的队列中窃取尚未执行的任务。
- 子任务结果按终止操作的规则合并。
双端队列支持从两端取任务。在默认调度模式下,工作线程优先取自己最近加入的任务,窃取者从另一端取得较早的任务。这样能减少同一端的竞争,也能让空闲线程分担积压的工作。
工作窃取重新分配的是尚未执行的任务。数据无法继续拆分,或某个任务长期等待网络响应时,它仍会限制整体完成速度。下面的金额汇总更适合按独立分区计算:
long total = orders.parallelStream()
.filter(Order::confirmed)
.mapToLong(Order::amountInCents)
.sum();
示例计算已确认订单的金额总和。采用并行流前,需要满足以下计算与资源条件:
- 数据量足够,拆出的任务能分担有效工作。
- 过滤和金额计算彼此独立,不修改共享对象。
- 计算收益能覆盖拆分、调度和合并成本。
- 当前进程还有可用 CPU 核心。
4. 执行顺序
遇到顺序指数据源或流水线约定的元素顺序,例如 List 的索引顺序。并行流可以按这个顺序组织结果,但不同元素的计算会交错进行。下面用 forEach 观察执行顺序:
List.of(1, 2, 3, 4)
.parallelStream()
.forEach(System.out::println);
四个数的打印顺序不确定,因为 forEach 允许不同任务直接执行打印动作。改用 forEachOrdered 后,打印动作会按遇到顺序执行。这需要等待前面的动作完成,因此增加协调成本,并限制终端动作的并行空间。
有序流的 findFirst 需要找到按顺序排在最前面的结果,findAny 则可以返回任意分区的结果。业务确实不依赖顺序时,可用 unordered() 放宽约束,让执行器有更多选择。
5. 外部可变状态
并行任务如果修改同一个外部对象,就会争用共享状态。下面的任务都向同一个 ArrayList 写入,存在数据竞争:
List<String> names = new ArrayList<>();
users.parallelStream()
.map(User::displayName)
.forEach(names::add);
多个任务同时修改 ArrayList,会造成数据丢失、容器状态损坏或异常。换成同步列表能保护单次写入,但所有写入都要竞争同一把锁。使用 toList(),可以让框架组织结果收集:
List<String> names = users.parallelStream()
.map(User::displayName)
.toList();
这段代码直接得到显示名列表,调用方无需维护外部共享容器。
普通并行 Collector 可以隔离各分区的容器,再合并结果,规则见 Collector。
除了共享容器,还要检查以下线程上下文与调用约束:
ThreadLocal保存的是所属线程的值,请求线程中的数据不会自动出现在 Worker 中。- 绑定当前线程的事务或安全上下文,也需要明确的传播方案。
- 非线程安全客户端和格式化器,应由不同任务隔离使用或增加同步。
- 外部系统若要求严格调用顺序,任务就必须遵守这项约束。
同一条并行流水线可由调用线程和多个 Worker 共同执行,所以测试时只观察请求线程不足以判断这些行为。
6. 阻塞 I/O
并行计算依靠 Worker 持续执行任务。如果任务大部分时间都在等待数据库或网络,等待期间也会占用这些线程:
List<Profile> profiles = userIds.parallelStream()
.map(profileClient::load)
.toList();
若 load 发起阻塞网络请求,每个等待响应的任务都会占用一个 Worker。其他并行流和使用公共池的任务因此需要排队。请求量增大后,等待队列会变长,尾延迟也会增加。尾延迟指最慢的那部分请求耗时,例如第 99 百分位延迟。
需要并发 I/O 时,应显式规定以下容量与失败处理约束:
- 限制同时发出的请求数,明确最大并发量。
- 配置连接池容量,约束同时使用的外部连接数。
- 设置每次调用与端到端超时,避免请求长期等待。
- 定义取消和异常传播方式,让调用方知道任务是否完成。
- 为不同请求或业务隔离执行资源,减少相互影响。
Java 21 虚拟线程、有界执行器和异步客户端都可用于并发 I/O,具体选择取决于调用方式。无论采用哪一种,都需要限制并发、超时和排队规模,避免请求量超过数据库或远程服务的容量。
7. commonPool 竞争
公共 ForkJoinPool 是 JVM 进程内的共享资源,多个模块的任务会使用同一批工作线程。一个模块提交大量长任务,就会挤占其他模块的执行机会。java.util.concurrent.ForkJoinPool.common.parallelism 调整的是整个进程的公共池并行度,无法分别约束每个业务接口。
需要隔离资源时,应显式创建执行器并安排任务。把并行流包在自建 ForkJoinPool.submit 中,可以依赖特定 OpenJDK 实现使用所在池。不过,Stream API 没有承诺这种绑定关系,因此升级时仍需测试确认。
并行流 API 未提供队列长度、拒绝策略、业务优先级和请求级并发上限的配置入口。需要过载保护与隔离时,应由业务明确管理执行器及其容量。
8. 适用条件
并行流是否更快,要比较并行计算节省的时间与额外调度成本。以下数据与计算特征有利于获得收益:
- 数据规模足够大,拆分近似均匀。
- 每个元素执行独立的 CPU 计算。
- 计算过程不依赖外部可变状态和线程绑定上下文。
- 合并操作满足结合律,也就是改变括号分组后结果等价,而且合并成本较低。
- 机器有空闲核心,应用允许这项计算使用它们。
以下成本与资源限制则会削弱收益:
- 元素很少或单个操作过轻,调度成本占主导。
- 数据源难以拆分,例如链式结构或串行 I/O。
- 大量装箱、对象分配和全局排序增加了计算与内存开销。
- 有序短路操作限制任务协作。
- 运行环境已经 CPU 饱和。
- 流水线包含数据库、网络或文件阻塞。
验证时应使用接近生产的数据量、核心数和并发背景。可用 Java 微基准工具 JMH 比较纯计算,再用端到端压测比较服务吞吐与尾延迟。只做一次 System.currentTimeMillis() 计时,会混入类加载、JIT 即时编译和垃圾回收的耗时。
9. 异常与取消
并行任务失败时,需要分别考虑流水线结果和其他任务已经做出的修改。某个任务抛出异常会使终止操作失败,但已经启动的其他任务仍可继续运行。
Stream 没有提供请求级超时,也没有为远程调用定义补偿协议。因此,需要超时、部分成功、逐项错误记录或主动取消时,应显式组织任务及其状态。若在 Lambda 内统一吞掉异常并返回 null,失败就会混入正常数据。后续归约再使用这个值,还会引发新的异常。
10. 并行标志与结果顺序
一条流水线选择一种执行模式,结果是否有序则由另一组规则决定。parallel() 和 sequential() 修改的是整条流水线的模式,执行时以最后一次设置为准:
List<Integer> values = IntStream.range(0, 1000)
.parallel()
.map(i -> i * 2)
.sequential()
.boxed()
.toList();
最后调用的是 sequential(),所以包括 map 在内的整条流水线都串行执行。这些方法不会把前后阶段分别交给不同线程池。
若改成并行执行,有序流的 toList() 仍会按遇到顺序排列最终列表。forEachOrdered 也只约束终端动作的顺序,其上游计算仍可并发。因此,计算同时进行与结果顺序固定可以同时成立。
unordered() 放弃顺序要求,但不会随机打乱元素。并行短路找到结果时,其他分区已有任务在执行,所以仍会发生额外计算。流水线中的函数应允许这种情况,业务逻辑应避免依赖精确调用次数。
11. 常见问题
下面补充线程数量、容器安全与嵌套并行的边界,帮助判断一次局部改动会影响哪些共享资源。
11.1 parallelStream 使用多少线程?
OpenJDK 21 中,公共池并行度影响工作线程数量,调用线程也可参与计算。实际活动线程数还受阻塞、嵌套任务和运行环境影响。因此,公共池并行度不能直接当作数据库或远程接口的业务并发上限。
11.2 使用线程安全集合后,业务操作就安全吗?
线程安全容器只保证自身单次操作符合约定。检查后写入、跨容器更新和业务顺序要求,仍需额外协调,才能保持业务规则成立。锁竞争也会增加成本,因此优先使用无副作用转换和分区隔离的收集过程。
11.3 parallel() 会创建新的线程池吗?
不会,parallel() 只把 Stream 的并行标志设为 true。等到终止操作执行时,框架才按并行模式安排任务。调用终止操作的线程仍要等待最终结果,所以并行流也没有把这次调用变成异步返回。
11.4 嵌套 parallelStream 会更快吗?
增加嵌套层数会增加拆分、排队和合并成本,内外层还会竞争相同的执行资源。它无法按层数增加可用 CPU。因此,应先选择一个适合拆分的维度,再用基准验证收益。
12 · 面试题卡片
12.1 ForkJoinPool 怎样调度任务,工作窃取解决了什么问题
出现公司:阿里巴巴、去哪儿
考察重点
- 说明大任务怎样拆分,以及工作线程怎样从双端队列取任务。
- 解释工作窃取如何缓解负载不均,以及数据源拆分质量的影响。
- 说明阻塞任务为什么会占用 Worker,并影响公共池中的其他任务。
相关内容:Spliterator 拆分、ForkJoinPool。
参考回答
Fork/Join 递归拆分任务,工作窃取让空闲线程分担其他线程尚未执行的任务,从而缓解负载不均。
每个 Worker 优先处理自己的双端队列,空闲时再从其他队列的另一端窃取任务。这样能减少部分线程空闲、另一些线程积压的情况。子任务完成后,框架再按终止操作的规则合并结果。
它适合可以继续拆分的 CPU 计算。若数据源无法拆分,空闲线程就拿不到更多工作。若任务长期等待网络或数据库,工作线程仍被占用,公共池的其他使用者也会排队。工作窃取只能重新分配任务,外部连接容量和可用 CPU 仍是限制。
12.2 在服务端使用 parallelStream 需要检查哪些条件
出现公司:美团、阿里巴巴
考察重点
- 区分持续使用 CPU 的计算与等待外部响应的阻塞 I/O。
- 解释公共池共享、副作用、顺序和线程上下文怎样影响正确性与性能。
- 结合基准结果和资源容量,判断采用并行流能否获得收益。
参考回答
服务端采用并行流,需要确认计算能够独立拆分、归约规则正确,并且计算收益足以覆盖额外成本。
具体应检查以下正确性与容量条件:
- 流水线没有共享副作用,结果不依赖请求线程的隐含上下文,顺序要求得到满足。
- 数据源容易拆分,数据量和单项计算量足以覆盖调度成本,合并运算满足结合律。
- 进程有可用 CPU,公共池中的其他模块也有足够执行机会。
- 数据库或网络调用有明确的并发上限、排队容量、拒绝策略和超时要求。
最后一类阻塞任务更适合显式执行器或任务模型,便于实施隔离。决定采用前,应在实际并发背景下比较串行与并行的吞吐及尾延迟,确认收益。
12.3 parallel 后调用 sequential,会只让后半段串行吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释最后一次模式设置为何作用于整条流水线,并区分元素计算顺序与最终结果顺序。
相关内容:并行标志与结果顺序。
参考回答
不会,最后一次 sequential() 会让整条流水线串行执行,包括它前面的 map。
这些方法设置的是流水线整体模式。结果顺序由数据源与操作约定决定,有序并行流也可以通过 toList() 返回按遇到顺序排列的列表。因此,执行模式与结果顺序需要分别判断。