《Java 并发编程·JUC 源码与容器》12.3 并行流的拆分策略

并行流的性能由拆分决定:Spliterator 的 trySplit/estimateSize/characteristics 三个契约,AbstractTask 与 CountedCompleter 的循环式拆分、LEAF_TARGET 与目标叶大小,commonPool 固定并行度的代价;附本机拆分次数、栈溢出与阻塞任务实测,以及自定义 Spliterator 的两版求和。

承接的问题

12.1 和 12.2 讲的是任务已经进了队列之后怎么调度。但并行流的第一性问题不在调度,而在任务是怎么被拆出来的:拆成几份、每份多大、拆出来的子任务能不能均匀落到 worker 上。

parallelStream 看起来是一行代码,背后是 Spliterator.trySplit() 反复对半切,切到"目标叶大小"就停。这个目标由 AbstractTask.LEAF_TARGET = parallelism << 2 决定:并行度 9 的 commonPool,目标叶子数是 36,也就是大约每个核 4 个叶子。源码注释给的理由是"为了负载均衡而过度拆分"。

本节回答四个问题:Spliterator 的三个方法各自承诺了什么;AbstractTask 为什么用循环而不是递归拆分;拆分粒度失控会发生什么;以及为什么 parallelStream 里做 I/O 会把整个 JVM 的并行流一起拖住。

Spliterator 的三个契约

并行流只依赖 Spliterator 的三个方法:

public interface Spliterator<T> {
    Spliterator<T> trySplit();      // 切一半出去;切不动返回 null
    long estimateSize();            // 剩余元素数的估计
    int characteristics();          // 有序性、大小是否精确、可否修改
}

trySplit() 的契约最容易写错:它返回的新 Spliterator 覆盖前半段,this 变成后半段,两段不重叠、合起来等于原来。返回值是 null 表示"切不动了",调用方应当把它当叶子处理。注意它没有"切多少"的参数——粒度完全由实现决定。

estimateSize() 不是"精确大小",是"估计"。只有声明了 SIZED 特征,它才必须精确;声明了 SUBSIZED,则每个 split 出来的子段也必须精确。AbstractTask 用 estimateSize() 算目标叶大小,如果估计偏差很大,拆出来的叶子会一大一小。

characteristics() 是一组位掩码,常见的几个:

特征含义对并行流的影响
SIZEDestimateSize() 精确决定能否用目标大小拆分
SUBSIZED每个子段也精确影响 AbstractTask 的目标计算
ORDERED有相遇顺序决定 forEachOrdered/findFirst 的语义
SORTED按比较器有序影响 sorted() 是否要重排
NONNULL不含 null影响某些短路操作的优化
IMMUTABLE / CONCURRENT源不可变 / 可并发修改决定是否允许并发遍历

一个最小可用的自定义 Spliterator.OfInt,每次对半切:

import java.util.Spliterator;
import java.util.function.IntConsumer;

public final class HalfSplit implements Spliterator.OfInt {
    private final int[] a;
    private int lo, hi;

    public HalfSplit(int[] a, int lo, int hi) {
        this.a = a; this.lo = lo; this.hi = hi;
    }

    @Override
    public Spliterator.OfInt trySplit() {
        int mid = (lo + hi) >>> 1;
        if (mid <= lo) return null;      // 切不动了
        int oldLo = lo;
        lo = mid;                        // this 变成后半段
        return new HalfSplit(a, oldLo, mid);
    }

    @Override public long estimateSize() { return hi - lo; }

    @Override public int characteristics() {
        return SIZED | SUBSIZED | IMMUTABLE | NONNULL | ORDERED;
    }

    @Override public boolean tryAdvance(IntConsumer action) {
        if (lo >= hi) return false;
        action.accept(a[lo++]);
        return true;
    }

    @Override public void forEachRemaining(IntConsumer action) {
        for (int i = lo; i < hi; i++) action.accept(a[i]);
        lo = hi;
    }
}

这段代码有一个隐含约束:trySplit() 修改了 this.lo,所以它只能在单线程下被调用,或者由调用方保证同一个实例不被并发访问。并行流框架满足这个约束——每个 AbstractTask 独占一个 Spliterator。但如果你把这个对象传给别的线程池自己用,就会出错。

forEachRemaining 一定要实现,而且要和 tryAdvance 语义一致。并行流的叶子节点会调 forEachRemaining,如果它漏掉元素或者重复遍历,求和结果就错了,而且不会有任何异常。

AbstractTask:用循环拆,不用递归

parallelStream 的拆分逻辑在 java.util.stream.AbstractTask 里,它继承 CountedCompleter:

// JDK 25 源码摘录,已删减
@Override
public void compute() {
    Spliterator<P_IN> rs = spliterator, ls; // right, left spliterators
    long sizeEstimate = rs.estimateSize();
    long sizeThreshold = getTargetSize(sizeEstimate);
    boolean forkRight = false;
    K task = (K) this;
    while (sizeEstimate > sizeThreshold && (ls = rs.trySplit()) != null) {
        K leftChild, rightChild, taskToFork;
        task.leftChild  = leftChild = task.makeChild(ls);
        task.rightChild = rightChild = task.makeChild(rs);
        task.setPendingCount(1);
        if (forkRight) {
            forkRight = false;
            rs = ls;
            task = leftChild;
            taskToFork = rightChild;
        } else {
            forkRight = true;
            task = rightChild;
            taskToFork = leftChild;
        }
        taskToFork.fork();
        sizeEstimate = rs.estimateSize();
    }
    task.setLocalResult(task.doLeaf());
    task.tryComplete();
}

三个设计点:

用 while 而不是递归。 拆一次就 fork 一个子任务、自己继续处理另一半,循环继续。递归深度是 O(1),不会因为拆分次数多而爆栈。这一点在 12.3 后面会看到一个反例:手写的 RecursiveTask 如果每层都递归调用 compute(),拆得细就会 StackOverflowError。

forkRight 交替。 如果 Spliterator 总是左边大右边小(或者反过来),固定的"fork 左、算右"会让某个线程一直拿大的那半。交替 fork 方向把偏差摊平。

setPendingCount(1) + tryComplete()。 这是 CountedCompleter 的完成计数:父任务挂起时记 1,子任务完成时 tryComplete() 递减,减到 0 才触发父任务的 onCompletion。和 RecursiveTask.join() 的区别是不需要显式 join,也不需要在父任务里阻塞等待。

目标叶大小由两个方法决定:

// JDK 25 源码摘录,已删减
private static final int LEAF_TARGET = ForkJoinPool.getCommonPoolParallelism() << 2;

public static int getLeafTarget() {
    Thread t = Thread.currentThread();
    if (t instanceof ForkJoinWorkerThread) {
        return ((ForkJoinWorkerThread) t).getPool().getParallelism() << 2;
    }
    else {
        return LEAF_TARGET;
    }
}

public static long suggestTargetSize(long sizeEstimate) {
    long est = sizeEstimate / getLeafTarget();
    return est > 0L ? est : 1L;
}

<< 2 就是乘 4:每个核 4 个叶子,注释说是为了"在叶子不均匀或某些核被占用时,让别的线程有活可帮"。注意 getLeafTarget() 会先看当前线程是不是 ForkJoinWorkerThread——如果是,用那个池的并行度;否则退回 commonPool 的并行度。所以同一个 Spliterator 在专用池里跑和在 commonPool 里跑,目标叶大小可能不同。

本机拆了多少次

用 IntStream.range(0, n).spliterator() 包一层计数 Spliterator,再交给并行流,就能数出 JDK 自己拆了几次:

commonPool.parallelism=9 leafTarget=36
n=2^12 splits=63 leaves=64 sum=8386560
n=2^16 splits=63 leaves=64 sum=2147450880
n=2^20 splits=63 leaves=64 sum=549755289600
n=2^24 splits=39 leaves=40 sum=140737479966720

(本机 Temurin 25.0.5+8,10 核,load average 82.6;单次运行、随调度波动。)

n 从 4096 涨到 1048576,拆分次数稳定在 63、叶子稳定在 64。算一下:目标叶大小是 n / 36,对半切只能切出 2 的幂,1048576 切 6 层是 64 片、每片 16384,比 29127 小;切 5 层是 32 片、每片 32768,比目标大。所以停在 64。2^12 时目标 113,切 6 层每片 64,也停在 64。

n=2^24 时反而只拆了 39 次。这一条我没有稳定的解释,只能说:拆分次数不是"n 越大越多",它由目标叶大小和 trySplit 的切法共同决定,具体到这个数据点还受并行流内部短路和负载影响。把它当"63 左右"的量级读,不要当公式。

拆分粒度失控会怎样

把 trySplit 改成"每次只切 8 个元素出去",同样 100 万个元素,两种写法结果完全不同:

A HalfSplit+RecursiveTask sum=549755289600 splits=15 leaves=16 ms=24
B TinySplit+RecursiveTask StackOverflowError splits=10434 leaves=7625
C HalfSplit+parallelStream sum=549755289600 splits=63 ms=16
D IntStream.range.parallel sum=549755289600 ms=19
E TinySplit+parallelStream sum=549755289600 splits=127432 ms=40

(本机 Temurin 25.0.5+8;单次运行,ms 随调度波动,只看结构不看绝对值。)

A 和 C 是同一个 HalfSplit,两种驱动方式:手写 RecursiveTask 拆了 15 次(16 个叶子),并行流拆了 63 次(64 个叶子)——因为并行流的目标叶大小是框架算的,手写版的目标是 n / 16。同一个 Spliterator,驱动方不同,拆分次数差 4 倍。

B 是反面教材:手写 RecursiveTask 每层递归调用 compute(),TinySplit 每次只切 8 个,递归深度约 13 万层,在 10434 次拆分后 StackOverflowError。注意它不是在 trySplit 里崩的,是 compute 的调用栈崩的。

E 是同一个 TinySplit 交给并行流:拆了 127432 次,没有崩,结果正确。原因是 AbstractTask.compute() 用 while 循环,拆分次数再多也只占一层栈。同一个糟糕的 Spliterator,在并行流里只是慢,在手写递归里会崩——这就是 AbstractTask 坚持循环拆分的原因。

E 的 127432 次拆分也说明了粒度失控的代价:每个叶子只有 8 个元素,框架要为每个叶子分配一个 CountedCompleter、做一次 fork/join 簿记。本机 40ms 对 16ms,差的两倍多基本都花在簿记上。

commonPool 的默认并行度是硬上限

ForkJoinPool.commonPool() 的并行度默认是"核数 - 1",本机 10 核是 9。这个值可以用 -Djava.util.concurrent.ForkJoinPool.common.parallelism=N 改,但改的是全 JVM 共享的那一个池。

用 18 个阻塞任务(Thread.sleep(400) 模拟 I/O)测一下:

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ForkJoinPool;
import java.util.stream.IntStream;

public final class CommonPoolLimit {
    static final Set<String> THREADS = ConcurrentHashMap.newKeySet();
    static final int TASKS = 18;
    static final int SLEEP_MS = 400;

    static void blockingTask() {
        THREADS.add(Thread.currentThread().getName());
        try {
            Thread.sleep(SLEEP_MS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public static void main(String[] args) {
        int par = ForkJoinPool.getCommonPoolParallelism();
        System.out.println("availableProcessors=" + Runtime.getRuntime().availableProcessors()
                + " commonPool.parallelism=" + par);

        THREADS.clear();
        long t0 = System.nanoTime();
        IntStream.range(0, TASKS).parallel().forEach(i -> blockingTask());
        System.out.printf("commonPool 耗时=%dms 线程数=%d%n",
                (System.nanoTime() - t0) / 1_000_000, THREADS.size());

        THREADS.clear();
        ForkJoinPool big = new ForkJoinPool(TASKS);
        t0 = System.nanoTime();
        big.submit(() -> IntStream.range(0, TASKS).parallel().forEach(i -> blockingTask())).join();
        System.out.printf("专用池(%d) 耗时=%dms 线程数=%d%n", TASKS,
                (System.nanoTime() - t0) / 1_000_000, THREADS.size());
        big.shutdown();
    }
}

本机输出:

availableProcessors=10 commonPool.parallelism=9
commonPool  18 个阻塞任务 耗时=816ms 线程数=10
专用池(18) 18 个阻塞任务 耗时=411ms 线程数=18

(本机 Temurin 25.0.5+8;单次运行,耗时随调度波动,不作基准。)

18 个 400ms 的任务在 commonPool 里跑了两轮(816ms),因为只有 9 个 worker;换成 18 线程的专用池,一轮跑完(411ms)。线程数 10 而不是 9,是因为调用 parallelStream 的主线程自己也参与执行——这也是 12.1 提到的"外部提交会被帮助执行"。

风险不在这 816ms,而在共享:commonPool 是全 JVM 一个。你的报表任务占满 9 个 worker 的 400ms 里,同一个 JVM 里任何别的 parallelStream、任何 CompletableFuture 的默认异步执行,都在这条队列后面排着。如果一个请求路径里的 parallelStream 做的是数据库查询,那么它排队的对象是整台机器上所有用默认池的代码。

在 libraryd-conc 里落地

库存盘点天然是"数组 + 纯函数",正适合并行流。但要用对三件事:源必须是 SIZED、叶子必须是纯内存计算、池子必须是专用的。

package com.plume.conc.core;

import java.util.Spliterator;
import java.util.concurrent.ForkJoinPool;
import java.util.function.IntConsumer;

/** 盘点批次的区间视图:不复制底层数组,只移动 lo/hi。 */
public final class BatchSpliterator implements Spliterator.OfInt {
    private final int[] slotIds;
    private int lo, hi;

    public BatchSpliterator(int[] slotIds, int lo, int hi) {
        this.slotIds = slotIds; this.lo = lo; this.hi = hi;
    }

    @Override
    public Spliterator.OfInt trySplit() {
        int mid = (lo + hi) >>> 1;
        if (mid - lo < 1024) return null;   // 叶子下限:小于 1024 不再拆
        int oldLo = lo;
        lo = mid;
        return new BatchSpliterator(slotIds, oldLo, mid);
    }

    @Override public long estimateSize() { return hi - lo; }

    @Override public int characteristics() {
        return SIZED | SUBSIZED | IMMUTABLE | NONNULL | ORDERED;
    }

    @Override public boolean tryAdvance(IntConsumer action) {
        if (lo >= hi) return false;
        action.accept(slotIds[lo++]);
        return true;
    }

    @Override public void forEachRemaining(IntConsumer action) {
        for (int i = lo; i < hi; i++) action.accept(slotIds[i]);
        lo = hi;
    }

    /** 盘点:每个槽位独立计算,结果按分类累加,全程不碰 I/O。 */
    public static long countReserved(int[] slotIds, ForkJoinPool pool) {
        return pool.submit(() ->
                java.util.stream.StreamSupport.intStream(
                                new BatchSpliterator(slotIds, 0, slotIds.length), true)
                        .mapToLong(id -> id % 7 == 0 ? 1 : 0)
                        .sum()).join();
    }
}

三个决策:

第一,trySplit 加了下限 mid - lo < 1024。不加下限时,框架会一路切到单元素,拆分次数和叶子对象数按元素数线性增长——上面的 E 组实验就是这个下场。加了下限,叶子数被压在"元素数 / 1024"的量级。

第二,characteristics 声明了 SIZED | SUBSIZED,因为 estimateSize() 是精确的 hi - lo;如果不声明 SIZED,AbstractTask 的目标叶计算会退回默认值,拆分行为变得不可预测。

第三,countReserved 用专用池 pool.submit(...),而不是直接 parallel()。这样盘点不会占用 commonPool,也不会被别人的 parallelStream 拖住。调用方负责把 slotIds 读进内存——读库是 I/O,必须在提交前完成。

什么时候不要用并行流

源是链表或迭代器。 LinkedList、BufferedReader.lines()、Files.lines() 的 trySplit 要么切不动,要么切出来的段大小不可知;estimateSize 不准,目标叶大小就失真。数据量小时,拆分开销比计算还大。

叶子要做 I/O。 上面 816ms 的实验已经说明:commonPool 的 9 个 worker 被阻塞任务占满时,全 JVM 的并行流一起排队。数据库查询、HTTP 调用、文件读写都应该放到虚拟线程或专门的 I/O 池,算完再回到并行流做纯计算。

需要顺序或状态。 forEach 不保证顺序,findFirst 在并行下必须尊重相遇顺序,短路收益有限;有共享可变状态时,parallelStream 里的 += 一定会丢更新。要么用 collect 的合并器,要么老老实实串行。

任务本身只有几微秒。 目标叶大小是 n / (4 × 并行度),如果每个叶子的计算只有几微秒,CountedCompleter 的分配和 fork/join 的簿记会占大头。本机 A 组(16 个叶子,24ms)和 C 组(64 个叶子,16ms)的差距就来自拆分策略,不是计算本身。

常见错误与边界

trySplit() 返回 this。 契约要求返回一个覆盖前半段的新对象,this 变成后半段。返回 this 会让框架重复处理同一段数据,结果是重复计数,且不会抛异常。

estimateSize() 撒谎。 声明了 SIZED 却返回估计值,AbstractTask 会按错误的目标拆分,叶子大小严重不均,某个 worker 拖到最后。

在 forEachRemaining 里漏掉元素。 tryAdvance 和 forEachRemaining 必须覆盖同一段数据。框架在叶子上只调一次 forEachRemaining,漏了就少算,不会报错。

用单次冷启动的耗时判断并行流快不快。 同一个 100 万元素求和,D 组单次运行 19ms;预热 5 轮后取最好成绩,本机是串行 0.691ms、并行 0.175ms。19ms 里绝大部分是 JIT 编译和框架初始化,不是拆分成本。数据量小于一万时拆分才真正占主导,这时并行确实可能比 for 慢。

在 commonPool 里嵌套 parallelStream。 外层占满 worker 后,内层流没有线程可用,会退化成串行甚至死等。这是 ForkJoinPool 的经典陷阱,和 12.2 的"外部帮助"不是一回事。

小结

并行流的性能由拆分决定,拆分的三个契约是 trySplit 切一半、estimateSize 给估计、characteristics 声明语义。AbstractTask 用 while 循环而不是递归来拆,fork 方向交替以摊平偏斜,目标叶大小是 并行度 × 4,本机 commonPool 并行度 9、目标 36,实际拆出 64 个叶子。粒度失控时,同一个 Spliterator 在并行流里拆 12 万次还能跑完,在手写递归里一万次就 StackOverflowError——这是 CountedCompleter 循环拆分的价值。最后,commonPool 是全 JVM 共享的 9 个线程,18 个阻塞任务要跑两轮(816ms),而 18 线程的专用池只要一轮(411ms);把 I/O 放进并行流,代价由整台机器上所有用默认池的代码一起付。

三节合起来是一条完整的链:WorkQueue 提供两端队列(12.1),poll/phase 处理两端相撞(12.2),Spliterator 与 AbstractTask 决定往队列里放多少任务(12.3)。下一章换到另一个方向:任务之间不再靠拆分并行,而是靠依赖关系串起来——CompletableFuture 的 Completion 栈。

阅读导航:上一节 12.2 双向队列与 steal 竞争 | 下一节 13.1 CompletableFuture 的依赖栈

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收