Java stream 并发问题

在并行流中,可能各个线程处理的是同一个sink输出,导致并发问题。

forEach (无序并行):将同步责任交给用户

对于 stream.parallel().forEach(action)多个线程确实会并发地调用同一个 Sink 实例的 accept 方法。

我们来看 ForEachOp 的实现:

java 复制代码
// ... existing code ...
        /** Implementation class for reference streams */
        static final class OfRef<T> extends ForEachOp<T> {
            final Consumer<? super T> consumer;

            OfRef(Consumer<? super T> consumer, boolean ordered) {
                super(ordered);
                this.consumer = consumer;
            }

            @Override
            public void accept(T t) {
                consumer.accept(t);
            }
        }
// ... existing code ...

可以看到,accept(T t) 方法直接调用了用户传入的 consumer.accept(t)。它自身没有任何同步措施

ForEachTask处理的是同一个Sink

java 复制代码
static final class ForEachTask<S, T> extends CountedCompleter<Void> {
        private Spliterator<S> spliterator;
        private final Sink<S> sink;
        private final PipelineHelper<T> helper;
        private long targetSize;

        ForEachTask(PipelineHelper<T> helper,
                    Spliterator<S> spliterator,
                    Sink<S> sink) {
            super(null);
            this.sink = sink;
            this.helper = helper;
            this.spliterator = spliterator;
            this.targetSize = 0L;
        }

        ForEachTask(ForEachTask<S, T> parent, Spliterator<S> spliterator) {
            super(parent);
            this.spliterator = spliterator;
            this.sink = parent.sink;
            this.targetSize = parent.targetSize;
            this.helper = parent.helper;
        }

        // Similar to AbstractTask but doesn't need to track child tasks
        public void compute() {
            Spliterator<S> rightSplit = spliterator, leftSplit;
            long sizeEstimate = rightSplit.estimateSize(), sizeThreshold;
            if ((sizeThreshold = targetSize) == 0L)
                targetSize = sizeThreshold = AbstractTask.suggestTargetSize(sizeEstimate);
            boolean isShortCircuit = StreamOpFlag.SHORT_CIRCUIT.isKnown(helper.getStreamAndOpFlags());
            boolean forkRight = false;
            Sink<S> taskSink = sink;
            ForEachTask<S, T> task = this;
            while (!isShortCircuit || !taskSink.cancellationRequested()) {
                if (sizeEstimate <= sizeThreshold ||
                    (leftSplit = rightSplit.trySplit()) == null) {
                    task.helper.copyInto(taskSink, rightSplit);
                    break;
                }
                ForEachTask<S, T> leftTask = new ForEachTask<>(task, leftSplit);
                task.addToPendingCount(1);
                ForEachTask<S, T> taskToFork;
                if (forkRight) {
                    forkRight = false;
                    rightSplit = leftSplit;
                    taskToFork = task;
                    task = leftTask;
                }
                else {
                    forkRight = true;
                    taskToFork = leftTask;
                }
                taskToFork.fork();
                sizeEstimate = rightSplit.estimateSize();
            }
            task.spliterator = null;
            task.propagateCompletion();
        }
    }

这就是 Stream 框架的"契约"forEach 是一个非常底层的操作,它追求极致的性能,因此把线程安全的责任完全交给了开发者。如果你在 forEachConsumer 中操作一个共享的可变对象(比如往一个普通的 ArrayList 中添加元素),你必须自己处理同步 ,否则就会出现竞态条件、数据丢失或抛出 ConcurrentModificationException 等问题。

正确(但通常不推荐)的做法是:

java 复制代码
List<String> sharedList = Collections.synchronizedList(new ArrayList<>());
stream.parallel().forEach(sharedList::add);

collect:框架负责线程安全(推荐方式)

这才是并行收集数据的正确且高效 的方式。collect 操作远比 forEach 聪明,它专门设计用来解决并发问题。

collect 操作需要三个函数:

  1. Supplier (供应器)() -> new ArrayList<>()
  2. Accumulator (累加器)(list, item) -> list.add(item)
  3. Combiner (组合器)(list1, list2) -> { list1.addAll(list2); return list1; }

在并行执行时,collect 的工作流程如下:

  1. 分裂ForkJoinPool 将任务分裂给多个线程。
  2. 供应每个线程 都会调用 Supplier 来创建自己私有的、局部的 结果容器。例如,线程A得到 listA,线程B得到 listB。它们操作的不是同一个 ArrayList
  3. 累加 :每个线程使用 Accumulator 将自己负责的元素累加到各自的局部容器 中。线程A往 listA 里加,线程B往 listB 里加。因为操作的是线程私有对象,所以完全没有并发问题,速度极快。
  4. 组合 :当所有线程都完成了自己的部分后,框架会使用 Combiner 将所有线程的局部结果合并成一个最终结果。例如,执行 listA.addAll(listB)。这个合并过程可能是串行的,也可能是分层并行的。

通过这种"分头累加,最后合并"的策略,collect 完美地避开了在核心并行阶段操作共享可变状态的问题,从而既保证了线程安全,又实现了高并发。

forEachOrdered (有序并行):通过缓冲和串行消费来保证安全

forEachOrdered 为了保证顺序,会先让各个并行任务处理数据并缓冲 在各自的 Node 对象里。

最终,当轮到某个任务消费它的结果时,它是在一个确定的"happens-before"关系链中被触发的。这意味着对用户 action 的调用,实际上是串行化 的,一个任务消费完了才会轮到下一个。因此,它也从根本上避免了对同一个 Sink 的并发写入问题。

总结

  • forEach :最快、最底层,但不安全 。它把同步的烂摊子留给了你。如果你想并行收集到集合里,几乎永远都不应该用它。
  • collect并行收集的正确姿势。通过"本地容器+最终合并"的策略,由框架优雅地解决了并发问题,既安全又高效。
  • forEachOrdered :为了保证顺序,其最终消费阶段是串行化的,因此也是线程安全的。
相关推荐
练习时长两年半的程序员小胡1 小时前
JVM 性能调优实战:让系统性能 “飞” 起来的核心策略
java·jvm·性能调优·jvm调优
爱代码的小黄人1 小时前
利用劳斯判据分析右半平面极点数量的方法研究
算法·机器学习·平面
崎岖Qiu1 小时前
【JVM篇11】:分代回收与GC回收范围的分类详解
java·jvm·后端·面试
深海潜水员2 小时前
【Python】 切割图集的小脚本
开发语言·python
27669582923 小时前
东方航空 m端 wasm req res分析
java·python·node·wasm·东方航空·东航·东方航空m端
许苑向上3 小时前
Spring Boot 自动装配底层源码实现详解
java·spring boot·后端
Yolo566Q3 小时前
R语言与作物模型(以DSSAT模型为例)融合应用高级实战技术
开发语言·经验分享·r语言
喵叔哟3 小时前
31.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--单体转微服务--财务服务--收支分类
java·微服务·.net
Felven3 小时前
C. Challenging Cliffs
c语言·开发语言
Dreamsi_zh4 小时前
Python爬虫02_Requests实战网页采集器
开发语言·爬虫·python