《Java 并发编程·性能诊断与事故》7.2 两种模型的取舍矩阵

把事件循环与虚拟线程放在团队熟悉度、调试难度、生态、阻塞调用、尾延迟、内存、可观测性七个维度上逐条比较,每维给出哪边更好及其原因;用同一批 600 个混合任务在本机四次量出 P99 差距(5.2 秒对 0.26 秒),并给出一张填好权重就能出结论的选型表与可运行的打分程序。

承接的问题

构造一场真实的选型会。团队要把 libraryd-conc 的借阅接口重写,两个方案:

  • 方案 A:沿用现有事件循环网关,业务处理改写成回调,数据库访问换成异步驱动。
  • 方案 B:升级到 JDK 25,接口用虚拟线程「每请求一线程」,数据库访问保持 JDBC。

支持 A 的人说「B 就是换了个名字的线程池,10 万线程照样打爆数据库」;支持 B 的人说「A 的回调地狱没人维护得动,上个季度的线上问题有一半出在背压状态机」。两边说的都是事实,但用不同维度的事实互相否定,会永远吵不出结果。

选型不是比谁的理论更先进,而是把维度摊开、给权重、算总分,并写清楚「什么条件下这个结论会翻转」。本节先逐维对照,再用本机数据校核尾延迟这一维,最后给一张能直接用的表。

把一次误选按事故格式复盘,能看清「选错」到底错在哪一步:

  • 症状:上线三个月后,团队每两周要为回调链补一次超时,P99 随下游抖动放大 5 倍。
  • 证据:延迟分布里,阻塞型任务的比例从 8% 涨到 31%;转储中循环线程多次出现业务帧。
  • 根因:选型时只比较了「单机连接数」一个维度,没有把「调用链里阻塞 API 的比例会随业务增长」写进假设。
  • 修复:不是推翻事件循环,而是把阻塞部分隔离到独立池,并补上背压状态机的监控。
  • 验证:重跑同样的混合任务,确认循环线程栈里不再出现业务帧;P99 回到与阻塞比例相称的水平。

这条复盘说明:选型错误的代价通常不在模型本身,而在没写下来的假设。矩阵的作用就是把假设逼到台面上。

七个维度,逐条说清哪边更好

团队熟悉度与调试难度

这两维虚拟线程明显更好,原因是认知模型不同。

事件循环要求写代码的人同时持有三个心智模型:回调之间的状态放在哪、异常怎么沿着回调链传、背压水位到了之后协议上做什么。一个刚接手的工程师看到 flatMap 套 flatMap 再套 onErrorResume,很难在十分钟内说出「这个请求现在卡在哪一步」。虚拟线程写出来就是同步代码:try 块、for 循环、catch 异常,栈是连续的。

调试难度差距更大。事件循环出问题时,线程栈里是框架的调度帧,业务帧被拆散在多个回调对象里;虚拟线程出问题时,jcmd Thread.dump_to_file 能看到从入口到阻塞点的完整调用链。本机实测,Thread.print 默认看不到未挂载的虚拟线程,但 Thread.dump_to_file -format=plain 可以——这个细节 7.3 会展开。

反过来说,事件循环的「难」是入门难、上限高:真正精通的人能用固定线程数扛住极高的连接数,而且一旦背压状态机写对了,它对下游的保护比任何线程池都精确。这一维的答案取决于团队里有没有这种人,不是取决于模型本身。

生态与阻塞调用

这两维也是虚拟线程更好,而且差距在短期不可逆。

Java 生态里绝大多数客户端是同步阻塞的:JDBC、大部分 SDK、文件 IO。事件循环要求整条链路异步,意味着你要么换驱动,要么把阻塞调用包进线程池再切回来——后者等于在事件循环旁边挂了一个线程池,混合栈的复杂度一样跑不掉,还多了一层线程切换。

虚拟线程不要求生态改变。JDBC 连接在虚拟线程里阻塞时,载体线程会被释放;代码一行不用改,连接池仍然是那个连接池。限制从「线程数」变成了「连接数」,这个转移在 5.2 已经用 Semaphore(8) 实测过:200 个虚拟线程加 8 个许可证,吞吐 467/s,与 8 个平台线程的 435–442/s 基本一致。

但要如实说:虚拟线程对生态的兼容不是无条件的。依赖 ThreadLocal 做缓存的库、在静态初始化里开线程的库、用 synchronized 保护长临界区的库(JDK 24 起不再 pin,但临界区本身还在),都需要逐个确认。事件循环对这些问题的暴露更早——它在设计上就不允许你依赖它们。

尾延迟、内存与可观测性

尾延迟:虚拟线程更好,但要看任务构成;内存:事件循环更好;可观测性:虚拟线程更好。

尾延迟的直觉是「事件循环没有线程切换,应该更稳」,这个直觉只在所有任务都短且均匀时成立。一旦有长任务混进来,单队列串行会把长任务的耗时加到后面所有任务头上,P99 直接爆炸。虚拟线程每请求一个执行单元,长任务只拖自己。下面用同一批任务实测这个差距。

内存这一维事件循环赢:线程数固定,栈开销固定,连接状态存在堆上的对象里,几万连接的内存是可推算的。虚拟线程的栈按需增长,单个空栈很便宜,但 10 万个并发请求的栈、ThreadLocal 副本、请求对象加在一起不是小数;而且它的成本随并发线性增长,不像事件循环那样有明确上限。

可观测性这一维虚拟线程赢在「栈连续」,事件循环赢在「线程数少、抖动来源少」——但后者只在你能看懂调度帧时成立。综合看,虚拟线程的证据链更容易交给一个没参与开发的人复核。

内存这一维怎么算

内存的比较必须带假设,否则会变成「虚拟线程很轻」和「10 万线程会 OOM」两句口号的对撞。给一组可复算的假设:

  • 事件循环:固定 10 个线程,每线程栈按 1 MB 保留(-Xss 默认量级),连接状态对象按每连接 2 KB 估算。
  • 虚拟线程:每个并发请求一个虚拟线程,栈初始保留量远小于平台线程,但请求对象、ThreadLocal 副本、解码缓冲仍按每请求 2 KB 估算,另加载体线程 10 个。

在 10 万并发下,事件循环的连接状态约 200 MB,虚拟线程的请求状态同样约 200 MB,差别主要在线程对象本身,而不是「有没有栈」。真正拉开差距的是并发上限是否可控:事件循环的连接数由你决定,虚拟线程的并发数由流量决定,一旦入口不限流,内存随流量线性涨。所以这一维的结论要写成「事件循环更容易给出内存上界」,而不是「虚拟线程更费内存」。以上数字是按上述假设推导的示例值,本机未做 10 万连接的内存实测。

用同一批任务量出尾延迟差距

构造一组混合任务:600 个任务,其中 10%(61 个)是 40 ms 的阻塞任务(LockSupport.parkNanos),90% 是纯计算任务(200 万次异或)。用两种调度方式跑同一批任务:

  • 串行模型:一个平台线程从队列里逐个取任务执行。这模拟的是「事件循环里处理函数是阻塞的」——注意它不是 Reactor 的实现,只是把「一次只有一个执行单元、任务体阻塞」这个特征抽出来。
  • 每任务模型:每个任务一个虚拟线程,同时提交。

延迟从提交时刻算到完成时刻,含排队。四次运行的结果:

模型P50P99最大总墙钟
单线程串行(模拟循环内阻塞)2812–2894 ms5183–5212 ms5203–5231 ms5.21–5.25 s
每任务一个虚拟线程134.1–145.8 ms249.3–263.3 ms272.1–282.5 ms0.28–0.29 s

差距接近 20 倍。原因不复杂:串行模型里 61 个 40 ms 的阻塞任务累计 2.44 s,再加上 539 个计算任务,总墙钟 5.2 s,每个任务的平均排队时间就是它前面所有任务耗时之和。虚拟线程模型里阻塞任务不占载体,61 个阻塞任务几乎完全重叠,墙钟由计算任务决定。

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.LockSupport;

/** 同一批混合任务,两种调度模型下的完成延迟。单次运行,仅作量级参考。 */
public final class ModelLatency {
    static final int TASKS = 600;
    static final int FAST_COST_OPS = 2_000_000;
    static volatile long sink;

    record Task(int id, boolean slow) { }

    static void run(Task t) {
        if (t.slow()) {
            LockSupport.parkNanos(40_000_000L);          // 40ms 阻塞型任务
        } else {
            long x = 0x9E3779B97F4A7C15L;
            for (int i = 0; i < FAST_COST_OPS; i++) { x ^= x << 13; x ^= x >>> 7; x ^= x << 17; }
            sink = x;
        }
    }

    static double[] percentiles(long[] nanos) {
        long[] a = nanos.clone();
        Arrays.sort(a);
        return new double[] {
            a[(int) (a.length * 0.50)] / 1e6,
            a[(int) (a.length * 0.99)] / 1e6,
            a[a.length - 1] / 1e6
        };
    }

    static long[] serial(List<Task> tasks) throws Exception {
        long[] lat = new long[tasks.size()];
        long[] start = new long[tasks.size()];
        ExecutorService single = Executors.newSingleThreadExecutor();
        for (int i = 0; i < tasks.size(); i++) {
            int idx = i;
            start[idx] = System.nanoTime();
            single.execute(() -> { run(tasks.get(idx)); lat[idx] = System.nanoTime() - start[idx]; });
        }
        single.shutdown();
        single.awaitTermination(60, TimeUnit.SECONDS);
        return lat;
    }

    static long[] perTask(List<Task> tasks) throws Exception {
        long[] lat = new long[tasks.size()];
        long[] start = new long[tasks.size()];
        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < tasks.size(); i++) {
            int idx = i;
            start[idx] = System.nanoTime();
            threads.add(Thread.ofVirtual().start(() -> {
                run(tasks.get(idx)); lat[idx] = System.nanoTime() - start[idx];
            }));
        }
        for (Thread t : threads) t.join();
        return lat;
    }

    static List<Task> makeTasks() {
        List<Task> list = new ArrayList<>();
        Random r = new Random(42);
        for (int i = 0; i < TASKS; i++) list.add(new Task(i, r.nextInt(10) == 0));
        return list;
    }

    public static void main(String[] args) throws Exception {
        for (int w = 0; w < 2; w++) { run(new Task(-1, false)); run(new Task(-1, true)); }
        List<Task> tasks = makeTasks();
        long t0 = System.nanoTime();
        double[] s = percentiles(serial(tasks));
        double serialWall = (System.nanoTime() - t0) / 1e9;
        t0 = System.nanoTime();
        double[] v = percentiles(perTask(tasks));
        double vtWall = (System.nanoTime() - t0) / 1e9;
        System.out.printf("tasks=%d slow=%d%n", TASKS, tasks.stream().filter(Task::slow).count());
        System.out.printf("serial  P50=%.1fms P99=%.1fms max=%.1fms wall=%.2fs%n", s[0], s[1], s[2], serialWall);
        System.out.printf("vthread P50=%.1fms P99=%.1fms max=%.1fms wall=%.2fs%n", v[0], v[1], v[2], vtWall);
    }
}

必须说清三条边界。第一,这不是基准测试:四次运行、非 JMH、没有隔离机器负载(本机 load average 常年 70–170),数字只用来比较量级。第二,串行模型的 5.2 s 里,539 个计算任务按单个约 5 ms 估算占了约 2.7 s(未单独计时,属按任务数与单次耗时推导),如果任务全是阻塞,差距会更大;如果任务全是计算,两者几乎一样。第三,虚拟线程模型的 P50 是 135 ms 而不是 5 ms,因为 600 个虚拟线程只有 10 个载体,计算任务仍然要排队——它没有消除排队,只是让阻塞不再占着载体排队。

一张可直接用于选型的决策表

把七个维度写成权重 + 打分的结构,比开会争论有效。下面这个程序不是「算出一个客观答案」,而是把主观判断显式化:权重和分数都是示例,换团队就必须重填。

import java.util.*;

/** 取舍矩阵:权重由团队自己填,1–5 分,分数越高越适合。 */
public final class ModelDecision {

    public record Dimension(String name, double weight, int eventLoop, int virtualThread, String note) {
        public double delta() { return weight * (virtualThread - eventLoop); }
    }

    public static void main(String[] args) {
        List<Dimension> dims = List.of(
            new Dimension("团队熟悉度",   0.20, 2, 4, "会写同步代码的人远多于会调背压的人"),
            new Dimension("调试难度",     0.20, 2, 4, "虚拟线程栈是连续的业务调用链"),
            new Dimension("生态兼容",     0.15, 3, 5, "JDBC、阻塞 SDK 在虚拟线程下直接可用"),
            new Dimension("阻塞调用容忍", 0.15, 1, 5, "事件循环里一次阻塞停掉整条连接"),
            new Dimension("尾延迟",       0.10, 2, 4, "单队列串行会放大 P99"),
            new Dimension("内存/线程数",  0.10, 5, 4, "事件循环线程数固定;虚拟线程对象便宜但非零"),
            new Dimension("可观测性",     0.10, 2, 4, "dump_to_file 与 JFR 能看到虚拟线程的栈")
        );
        double loop = 0, vt = 0;
        System.out.printf("%-12s %6s %6s %6s  %s%n", "维度", "权重", "事件循环", "虚拟线程", "说明");
        for (Dimension d : dims) {
            loop += d.weight() * d.eventLoop();
            vt += d.weight() * d.virtualThread();
            System.out.printf("%-12s %6.2f %8d %10d  %s%n",
                    d.name(), d.weight(), d.eventLoop(), d.virtualThread(), d.note());
        }
        System.out.printf("%n加权总分(未归一):事件循环 %.2f / 虚拟线程 %.2f%n", loop, vt);
        double gap = Math.abs(vt - loop) / Math.max(loop, vt);
        System.out.println(gap < 0.10
                ? "结论:差距 <10%,不要因性能选型;按团队与生态选。"
                : "结论:差距 " + Math.round(gap * 100) + "%,偏向 "
                  + (vt > loop ? "虚拟线程" : "事件循环") + ";仍需用本团队权重复核。");
    }
}

本机输出:

维度               权重   事件循环   虚拟线程  说明
团队熟悉度          0.20        2          4  会写同步代码的人远多于会调背压的人
调试难度           0.20        2          4  虚拟线程栈是连续的业务调用链
生态兼容           0.15        3          5  JDBC、阻塞 SDK 在虚拟线程下直接可用
阻塞调用容忍         0.15        1          5  事件循环里一次阻塞停掉整条连接
尾延迟            0.10        2          4  单队列串行会放大 P99
内存/线程数         0.10        5          4  事件循环线程数固定;虚拟线程对象便宜但非零
可观测性            0.10        2          4  dump_to_file 与 JFR 能看到虚拟线程的栈

加权总分(未归一):事件循环 2.30 / 虚拟线程 4.30
结论:差距 47%,偏向 虚拟线程;仍需用本团队权重复核。

这张表的价值不在 4.30 比 2.30,而在每一个分数后面都写了理由。评审时有人不同意「调试难度 2 分」,可以直接改数字,看结论会不会翻转;这比争论「Reactor 是不是更先进」有效得多。

权重怎么定

三条经验,比「大家拍脑袋取平均」可靠:

  • 先标不可妥协项。合规、团队规模、既有技术栈这类硬约束不进打分,直接作为前置条件排除方案;剩下的才进矩阵。
  • 权重按「出问题的概率 × 恢复成本」估。一个团队从没维护过背压状态机,「阻塞调用容忍」的权重就该调高,因为它出问题的概率高、恢复成本也高。
  • 至少两个人独立打分再对表。分差超过 2 分的维度,说明双方对事实的理解不一致,先把事实对齐,再谈权重。

权重之和建议保持 1,方便解释「这一维占了决策的多少」。程序里没有做归一化,分数只用于两列横向比较,不是绝对评级。

三个常见误判

误判一:事件循环吞吐更高。 吞吐由 CPU、下游和协议决定,调度模型只影响「等待时浪费了多少资源」。5.2 的实测里,200 个虚拟线程的吞吐是 2241/s,饱和平台线程池是 2212–2262/s,两者没有量级差;事件循环也不会凭空多出核来。它赢的是「每连接的内存与线程开销」,不是「每秒处理更多请求」。

误判二:虚拟线程等于无限并发。 载体线程默认并行度等于 availableProcessors,本机是 10;连接池、下游并发配额、文件描述符都是硬上限。把 100 万个虚拟线程怼到一个 8 连接的数据库上,只是把排队地点从池队列换到了 getConnection()。

误判三:事件循环不能调阻塞 API,所以它不实用。 准确的说法是「不能在循环线程上调阻塞 API」。把阻塞调用丢到一个有界线程池,回调里再切回循环,是标准做法;代价是混合栈的上下文传播与调试复杂度,这正是 7.3 的主题。

在 libraryd-conc 里落地

选型结论要能复审,所以把它写成一条记录,包含四段:假设、权重、证据、翻转条件。

选型记录:libraryd-conc 借阅接口(2026-10)
假设:峰值 300 QPS,单请求 80% 时间在等 JDBC 与下游,团队 6 人无响应式经验
权重:团队 0.20 / 调试 0.20 / 生态 0.15 / 阻塞 0.15 / 尾延迟 0.10 / 内存 0.10 / 可观测 0.10
证据:ModelDecision 输出 2.30 vs 4.30;ModelLatency P99 5.2s vs 0.26s
结论:虚拟线程 + JDBC,连接池上限 32,下游超时统一 800ms
翻转条件:若单机连接数需求超过 5 万且每连接流量极低,重新评估事件循环
复审时间:2027-04 或连接池持续打满时

「翻转条件」这一行是最容易被省略、也最有用的一行。没有它,选型记录会变成信仰;有了它,半年后的人知道什么信号出现时该重新开会。

评审会上的用法也固定下来,避免又变成立场之争:

  1. 会前各自独立填分,不讨论;
  2. 会上只对齐分差 ≥2 的维度,先对事实,再改分;
  3. 权重改动必须说明理由,并记录在选型记录里;
  4. 结论差距小于 10% 时,明确写「按团队与生态选」,不要包装成性能结论。

这四步不保证选出最优模型,但能保证结论可追溯:半年后有人问「为什么选 B」,记录里既有当时的权重,也有谁在哪个维度上不同意。

常见错误与边界

错误后果正确做法
只比较「理论吞吐」忽略调试与生态成本七个维度都打分,权重显式
权重由架构师一个人定团队不认,落地走形每个维度写理由,评审时允许改分
把单次压测当证据结论随机器负载漂移多次运行、给区间、标注非基准
忽略任务构成全短任务时两者差别很小,白改先统计任务时长分布与阻塞比例
认为虚拟线程不需要限流打爆下游连接池用 Semaphore 或连接池上限夹住
选型记录不写翻转条件结论无法复审写清假设与触发重评的信号

什么时候不要做这张表:只有一个方案能落地时(比如公司强制使用某个网关),打分只是形式;团队规模小于三人、系统生命周期短于半年时,直接选最熟悉的模型,维护成本比理论最优更重要;以及性能不是瓶颈时——先把慢查询和 N+1 查询修掉,收益通常大于换调度模型。

小结

七个维度里,虚拟线程在团队熟悉度、调试难度、生态兼容、阻塞调用容忍、尾延迟、可观测性六维更好,事件循环在内存与线程数一维更好。尾延迟的优势不是绝对的:本机同一批 600 个混合任务,单线程串行 P99 为 5.18–5.21 s,每任务一个虚拟线程为 249–263 ms,差距来自「阻塞是否占住唯一执行单元」;如果任务全是计算,这个差距会消失。

决策表的输出(2.30 对 4.30)只是一次示例打分,真正的交付物是每个分数背后的理由和那一行「翻转条件」。下一节处理两个模型同时存在时的现实问题:事件循环里调阻塞 API、虚拟线程里进 synchronized、trace 上下文在哪一层断掉、以及怎么在转储和 JFR 里把两边的线程对上。

阅读导航:上一节 7.1 Reactor 的线程模型回顾 | 下一节 7.3 混合栈的边界与调试

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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