跳到主要内容
约 10 分钟3 张卡

并行流

并行流把数据拆成多个分区,由多个任务分别计算,再合并结果。它适合容易拆分、计算量足够且没有共享副作用的 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 本身没有指定执行器的参数。

一次计算按拆分、执行、窃取和合并展开:

  1. 大任务递归拆成较小任务。
  2. 每个 Worker,也就是工作线程,优先处理自己双端队列中的任务。
  3. 空闲 Worker 从其他工作线程的队列中窃取尚未执行的任务。
  4. 子任务结果按终止操作的规则合并。

双端队列支持从两端取任务。在默认调度模式下,工作线程优先取自己最近加入的任务,窃取者从另一端取得较早的任务。这样能减少同一端的竞争,也能让空闲线程分担积压的工作。

工作窃取重新分配的是尚未执行的任务。数据无法继续拆分,或某个任务长期等待网络响应时,它仍会限制整体完成速度。下面的金额汇总更适合按独立分区计算:

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() 会创建新的线程池吗?​

11.4 嵌套 parallelStream 会更快吗?​

12 · 面试题卡片​

12.1 ForkJoinPool 怎样调度任务,工作窃取解决了什么问题​

出现公司:阿里巴巴、去哪儿

考察重点

  • 说明大任务怎样拆分,以及工作线程怎样从双端队列取任务。
  • 解释工作窃取如何缓解负载不均,以及数据源拆分质量的影响。
  • 说明阻塞任务为什么会占用 Worker,并影响公共池中的其他任务。

相关内容:Spliterator 拆分、ForkJoinPool。

参考回答

Fork/Join 递归拆分任务,工作窃取让空闲线程分担其他线程尚未执行的任务,从而缓解负载不均。

每个 Worker 优先处理自己的双端队列,空闲时再从其他队列的另一端窃取任务。这样能减少部分线程空闲、另一些线程积压的情况。子任务完成后,框架再按终止操作的规则合并结果。

它适合可以继续拆分的 CPU 计算。若数据源无法拆分,空闲线程就拿不到更多工作。若任务长期等待网络或数据库,工作线程仍被占用,公共池的其他使用者也会排队。工作窃取只能重新分配任务,外部连接容量和可用 CPU 仍是限制。

12.2 在服务端使用 parallelStream 需要检查哪些条件​

出现公司:美团、阿里巴巴

考察重点

  • 区分持续使用 CPU 的计算与等待外部响应的阻塞 I/O。
  • 解释公共池共享、副作用、顺序和线程上下文怎样影响正确性与性能。
  • 结合基准结果和资源容量,判断采用并行流能否获得收益。

相关内容:外部可变状态、阻塞 I/O、适用条件。

参考回答

服务端采用并行流,需要确认计算能够独立拆分、归约规则正确,并且计算收益足以覆盖额外成本。

具体应检查以下正确性与容量条件:

  • 流水线没有共享副作用,结果不依赖请求线程的隐含上下文,顺序要求得到满足。
  • 数据源容易拆分,数据量和单项计算量足以覆盖调度成本,合并运算满足结合律。
  • 进程有可用 CPU,公共池中的其他模块也有足够执行机会。
  • 数据库或网络调用有明确的并发上限、排队容量、拒绝策略和超时要求。

最后一类阻塞任务更适合显式执行器或任务模型,便于实施隔离。决定采用前,应在实际并发背景下比较串行与并行的吞吐及尾延迟,确认收益。

12.3 parallel 后调用 sequential,会只让后半段串行吗?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释最后一次模式设置为何作用于整条流水线,并区分元素计算顺序与最终结果顺序。

相关内容:并行标志与结果顺序。

参考回答

不会,最后一次 sequential() 会让整条流水线串行执行,包括它前面的 map。

这些方法设置的是流水线整体模式。结果顺序由数据源与操作约定决定,有序并行流也可以通过 toList() 返回按遇到顺序排列的列表。因此,执行模式与结果顺序需要分别判断。

13. 参考资料​