《Java 并发编程·线程与内存模型》12.3 分治拆分、阈值与异常

阈值同时决定任务数和叶子成本:切太小,任务对象与调度开销吃掉收益;切太大,叶子里的插入排序退化成平方复杂度。本机 100 万元素归并排序实测,阈值 1 是 140ms、16 是 21ms、65536 是 1629ms,顺序版 105ms。文末用实测输出说明异常如何跨线程包装,以及 cancel 为什么停不下正在跑的叶子。

承接的问题

12.1 和 12.2 都用到了分治,但都绕开了一个数字:切到多小为止。RecursiveTask 把这个决定交给写代码的人,parallelStream 替你做了决定但不告诉你。这个数字同时影响三件事:

  • 任务数量:叶子数约等于 n / threshold,每个任务都是一次对象分配加两次队列操作;
  • 单任务成本:叶子里的算法在这个规模下是否还高效;
  • 并行收益:每个 worker 分到的活够不够多,窃取是否还有意义。

这一节用归并排序把阈值扫一遍,量出「太小」和「太大」各自的代价,然后补齐分治里最容易忽略的两块语义:异常怎么传播、取消到底取消了什么。

阈值决定两件事

先看任务数。一个长度为 n 的数组,每次对半切,切到长度不超过 threshold 为止:

阈值叶子数 n/threshold递归深度 log2(叶子数)任务对象总数(约)
11 000 000202 000 000
1665 53616131 072
2564 096128 192
4 0962568512
65 53616432

每个任务都要分配对象、fork 进队列、join 时同步。按本机实测折算,一个任务的固定开销在几十纳秒量级——单看不多,乘以两百万就是上百毫秒。

再看叶子成本。叶子不是「什么都不做」,它要做一段顺序计算。如果叶子算法是 O(k²) 的插入排序,阈值从 16 涨到 65536,单叶子的比较次数涨了 1600 万倍:

插入排序的比较次数 ≈ k² / 4
k = 16      → 64 次
k = 4096    → 4.2 × 10^6 次,乘 256 个叶子 ≈ 1.1 × 10^9
k = 65536   → 1.07 × 10^9 次,乘 16 个叶子 ≈ 1.7 × 10^10

所以阈值不是「越大越省调度」,它同时被叶子算法的复杂度卡住。最优阈值 = 任务开销与叶子计算成本曲线的交点,而这两条曲线的形状取决于你叶子里的代码,没有通用答案。

带阈值的归并排序长这样:

import java.util.concurrent.RecursiveAction;

public final class SortTask extends RecursiveAction {
    private final int[] a;
    private final int[] tmp;
    private final int lo;
    private final int hi;
    private final int threshold;

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

    @Override
    protected void compute() {
        if (hi - lo <= threshold) {
            insertionSort(a, lo, hi);
            return;
        }
        int mid = (lo + hi) >>> 1;
        invokeAll(new SortTask(a, tmp, lo, mid, threshold),
                  new SortTask(a, tmp, mid, hi, threshold));
        merge(a, tmp, lo, mid, hi);
    }

    private static void insertionSort(int[] a, int lo, int hi) {
        for (int i = lo + 1; i < hi; i++) {
            int v = a[i];
            int j = i - 1;
            while (j >= lo && a[j] > v) {
                a[j + 1] = a[j];
                j--;
            }
            a[j + 1] = v;
        }
    }

    private static void merge(int[] a, int[] tmp, int lo, int mid, int hi) {
        System.arraycopy(a, lo, tmp, lo, hi - lo);
        int i = lo;
        int j = mid;
        int k = lo;
        while (i < mid && j < hi) {
            a[k++] = (tmp[i] <= tmp[j]) ? tmp[i++] : tmp[j++];
        }
        while (i < mid) {
            a[k++] = tmp[i++];
        }
        while (j < hi) {
            a[k++] = tmp[j++];
        }
    }
}

两处细节决定了它是否正确:

  • a 和 tmp 都是共享数组,但每个任务只写自己的 [lo, hi)。左半和右半的区间不相交,所以不需要锁;合并阶段父任务独占 [lo, hi),此时两个子任务都已 join 完成。
  • invokeAll 会先 fork 一个再算另一个,不是两个都 fork 后干等。join 时的可见性由 ForkJoinTask 内部的同步保证,父任务看到的是子任务写入后的数组。

本机实测:阈值扫掠

环境:Apple M1 Pro,10 核,32 GB,Temurin 25.0.5+8。测量时 load averages 约 280(机器上还有别的实验),所以只看同一轮内的相对关系。数组长度 100 万,随机 int,每个阈值预热一轮后取四轮,并校验排序结果。

availableProcessors = 10
n = 1000000, loadAverage = 280.58984375

threshold=1      leaves=1000000  maxDepth=20 runs=[142, 134, 127, 140] median=140ms
threshold=16     leaves=65536    maxDepth=16 runs=[36, 21, 21, 21] median=21ms
threshold=256    leaves=4096     maxDepth=12 runs=[21, 22, 22, 23] median=22ms
threshold=4096   leaves=256      maxDepth= 8 runs=[105, 101, 97, 101] median=101ms
threshold=65536  leaves=16       maxDepth= 4 runs=[1636, 1525, 1610, 1629] median=1629ms
sequential merge sort          runs=[104, 105, 108, 104] median=105ms
Arrays.sort                    runs=[59, 59, 59, 59] median=59ms
Arrays.parallelSort            runs=[13, 10, 10, 9] median=10ms

读法:

  • 曲线是 U 形,但两侧的成因完全不同。 左侧(阈值 1)是任务开销:约 200 万个任务,比阈值 16 多了约 120ms,折算每个任务几十纳秒。右侧(阈值 4096、65536)是叶子算法退化:任务数明明更少,耗时却从 21ms 涨到 101ms 和 1629ms。
  • 最优区间是 16–256,都是 21–22ms,相对顺序版 105ms 约 5 倍加速。10 核机器上 5 倍不算高,原因是归并的合并阶段是串行内存操作,而且高负载下 worker 拿不到满额 CPU。
  • 阈值 16 和 256 几乎没差别,说明在这个区间里任务开销和叶子成本都不占主导。工程上选 128 或 256 这种「一个 cache line 友好的块」比精确调参更实际。
  • 手写版打不过 Arrays.parallelSort(21ms vs 10ms)。JDK 的小数组用双枢轴快排、合并阶段也做了优化,这个例子是教学实现,不是性能基准。

阈值的选择可以按这个顺序做:

  1. 先让叶子里的算法在「几百到几千个元素」规模上是线性的(快排、插入排序在小数组上表现好,但别用到几万)。
  2. 用 n / threshold 估任务数,确保「任务数 » 并行度」,否则 worker 分不到活。100 万元素、10 核,叶子数在 1000 以上比较稳妥。
  3. 实测扫三档(比如 64、256、1024),选拐点,不要凭直觉写死。

异常传播:join 是一个重抛点

分治里的异常有两个容易踩的坑:兄弟任务不会自动取消,以及异常跨线程后会被重新包装。

FailingSum left = new FailingSum(lo, mid, ...);
FailingSum right = new FailingSum(mid, hi, ...);
left.fork();
Long r;
try {
    r = right.compute();
} catch (RuntimeException e) {
    left.cancel(false);        // 不写这一行,左半会继续跑完
    throw e;
}
return r + left.join();

本机实测(1024 个元素、阈值 16、共 64 个叶子,故意让第 500 号元素所在分片抛异常,三次运行):

未取消兄弟: java.lang.IllegalStateException: java.lang.IllegalStateException: java.lang.IllegalStateException: bad shard [496,512),异常后叶子共执行 64/64 个
显式取消兄弟: java.lang.IllegalStateException,异常后叶子共执行 57/64 个
已取消任务的 get(): java.util.concurrent.CancellationException
cancel(true) 返回 true,子任务完成后 finished=1(取消不会中断正在执行的叶子)

三次运行里,不取消兄弟时都是 64/64,显式取消后是 57/57/61。差异不是随机的:fork 出去的任务只要已经开始执行,cancel 就只能改状态,不能把它从栈上拽下来。

顺带对比一下:如果用 invokeAll(left, right),框架在检测到异常时会主动 cancel(false) 另一个分片,不需要你写 catch。上面这段手写 fork/join 没有这层兜底——这正是「用库的封装」和「自己控制」的差别。

为什么消息会嵌套两层? 这不是你的代码包出来的。ForkJoinTask.getException() 的逻辑是:如果记录的异常不是当前线程抛出的,就反射找一个 (Throwable) 构造器,新建一个同类型异常、把原异常设为 cause,目的是给出「在当前线程重抛」的准确堆栈。跨两次线程边界就套两层,最底下的 cause 才是原始异常。所以:

catch (RuntimeException e) {
    Throwable root = e;
    while (root.getCause() != null) {
        root = root.getCause();
    }
    // root 才是真正抛出的那个
}

异常类型会保留,消息会被改写。日志里看到三层 IllegalStateException: 前缀时,不要去搜「谁会抛这个嵌套异常」,它是 JDK 造的。

取消:标记,不是中断

ForkJoinTask.cancel(boolean) 的基类实现只看状态位,mayInterruptIfRunning 参数根本没用上:

public boolean cancel(boolean mayInterruptIfRunning) {
    int s = trySetCancelled();
    return (s >= 0 || (s & (ABNORMAL | THROWN)) == ABNORMAL);
}

但这里有一个反直觉的差别,本机实测:

submit(Runnable): cancel=true, outcome=interrupted, interruptedFlag=true
RecursiveAction:   cancel=true, outcome=finished-normally

同样是 cancel(true):通过 pool.submit(Runnable) 提交的任务被中断了,而自己继承 RecursiveAction 的任务跑完了。原因是前者被包装成 JDK 内部的 InterruptibleTask,它重写了 cancel 并调用 interruptIfRunning;后者走的是基类实现,只置状态位。

所以对自定义的 RecursiveTask / RecursiveAction:

  • cancel 之后 join() 抛 CancellationException,isCancelled() 为真;
  • 已经进入 compute() 的叶子不会停,它会把这段算完;
  • 想让叶子尽早退出,必须在叶子的循环里自己检查 isCancelled() 或中断标志。

这也是分治取消的实践建议:取消要自上而下传播,执行要自下而上检查。父任务 cancel 后,子任务如果还没开始,fork 时会被跳过;已经开始的那部分只能靠代码自觉。

在 libraryd-conc 里落地

借阅系统要生成「即将到期」列表:把当天的借阅快照按 dueAt 排序,取最早的一批发提醒。快照可能有几十万条,排序是纯 CPU 活,适合分治。

package com.plume.conc.core;

import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveAction;

public final class LoanDueSorter {
    private static final int THRESHOLD = 256;
    private static final Comparator<Loan> BY_DUE =
            Comparator.comparing(Loan::dueAt).thenComparing(Loan::loanId);

    private final ForkJoinPool pool = new ForkJoinPool(4);

    private static final class SortTask extends RecursiveAction {
        private final Loan[] items;
        private final Loan[] tmp;
        private final int lo;
        private final int hi;

        SortTask(Loan[] items, Loan[] tmp, int lo, int hi) {
            this.items = items;
            this.tmp = tmp;
            this.lo = lo;
            this.hi = hi;
        }

        @Override
        protected void compute() {
            if (hi - lo <= THRESHOLD) {
                for (int i = lo + 1; i < hi; i++) {
                    Loan v = items[i];
                    int j = i - 1;
                    while (j >= lo && BY_DUE.compare(items[j], v) > 0) {
                        items[j + 1] = items[j];
                        j--;
                    }
                    items[j + 1] = v;
                }
                return;
            }
            int mid = (lo + hi) >>> 1;
            invokeAll(new SortTask(items, tmp, lo, mid),
                      new SortTask(items, tmp, mid, hi));
            merge(lo, mid, hi);
        }

        private void merge(int lo, int mid, int hi) {
            System.arraycopy(items, lo, tmp, lo, hi - lo);
            int i = lo;
            int j = mid;
            int k = lo;
            while (i < mid && j < hi) {
                items[k++] = BY_DUE.compare(tmp[i], tmp[j]) <= 0 ? tmp[i++] : tmp[j++];
            }
            while (i < mid) {
                items[k++] = tmp[i++];
            }
            while (j < hi) {
                items[k++] = tmp[j++];
            }
        }
    }

    public List<Loan> sortByDueAt(List<Loan> snapshot) {
        Loan[] items = snapshot.toArray(Loan[]::new);
        if (items.length > 1) {
            pool.invoke(new SortTask(items, new Loan[items.length], 0, items.length));
        }
        return List.of(items);
    }

    public void close() {
        pool.shutdown();
    }
}

四点说明:

  • 排序的是快照数组,不是 LoanRepository。分治任务里不能查库;Loan 是 record,取出 dueAt 和 loanId 后不可变,切片之间没有共享写。
  • 比较器必须无副作用且可重复调用。BY_DUE 是静态常量,并加了 loanId 作为决胜键:归并排序本身是稳定的,相同 dueAt 会保留输入顺序;再加一层 loanId 是为了让结果与输入顺序无关,便于对账和重跑。
  • 阈值 256 是在 100 万规模上量出来的,几十万条数据下叶子数仍有上千,能把 4 个 worker 喂饱;如果快照只有几千条,直接用 List.sort 更划算。
  • List.of(items) 返回不可变副本,防止调用方在排序后改动数组;如果调用方要按「最早到期」截断,再 subList 即可。

什么时候不要用

  • 数据量不够大。叶子数少于并行度的几倍时,拆分和合并的成本高于收益。几千条记录的排序用 List.sort 就够了。
  • 叶子里的算法不是线性的。插入排序、冒泡排序在阈值上千后会退化成平方复杂度,实测 65536 的阈值让总耗时涨到 21ms 的 77 倍。
  • 需要稳定的全局顺序且比较器有大量并列。并行合并的顺序由树形决定,并列元素的相对顺序与顺序流未必一致。
  • 任务里要做 IO 或加锁。分治的收益来自「每片独立算」,一旦共享资源,worker 会互相等。
  • 需要精确的取消或超时。cancel 停不下正在执行的叶子,也没有超时机制;这种需求用 Future/虚拟线程更合适。

常见错误与边界

错误后果正确做法
阈值设成 1任务数等于元素数,分配与调度吞掉收益叶子保留几百到几千个元素
阈值设得过大且叶子用插入排序叶子退化为 O(k²),实测 1629ms叶子算法与阈值一起选
共享 tmp 数组但切片重叠数据竞争,结果错乱保证每个任务只写自己的区间
子任务失败后不管兄弟已 fork 的任务继续跑,浪费资源捕获后 cancel 兄弟,见上文
用 e.getMessage() 判断异常消息可能是嵌套后的 cause.toString()沿 getCause() 找到根因
以为 cancel(true) 会中断自定义任务照常跑完叶子内检查 isCancelled()
在 compute 里 System.out输出交错、拖慢整体汇总后统一打印
递归没有深度上限退化输入导致栈溢出加深度上限,超过就顺序处理

小结

  • 阈值同时决定任务数和叶子成本。本机 100 万元素、10 核:阈值 1 是 140ms(约 200 万个任务),16 是 21ms,256 是 22ms,4096 是 101ms,65536 是 1629ms,顺序版 105ms。左端是调度开销,右端是插入排序的平方复杂度。
  • 最优阈值没有通用值,用「任务数 » 并行度」和「叶子算法在 k 规模上仍线性」两条约束先框定,再实测三档选拐点。
  • 异常会从 join/invoke 重抛,但跨线程时 ForkJoinTask 会新建同类型异常并把原异常设为 cause,所以消息会嵌套,根因在 getCause() 链底部。
  • 兄弟任务不会自动取消:不取消时 64/64 个叶子全部执行,显式 cancel 后减到 57–61 个。cancel(true) 对自定义 RecursiveTask 不中断,对 submit(Runnable) 包装的任务才中断。
  • Arrays.parallelSort 比手写归并快一倍,工程上优先用 JDK 现成的实现;手写分治的价值在于理解阈值与合并逻辑。

第 12 章到此结束。下一章换一个维度:不再关心「怎么把活切开」,而是「怎么把多个异步结果拼起来」——CompletableFuture 的创建、组合与异常。

阅读导航:上一节 12.2 并行流的边界与 commonPool 陷阱 | 下一节 13.1 CompletableFuture 的创建与组合

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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