36 的字节码观察确认了挂起函数的 JVM 签名与状态字段,但单凭 `javap` 里多了几个字段,算不出应用的分配量,更算不出 HTTP 请求耗时。08 的集合实验展示 `take(1)` 在首项匹配时 Sequence 可以少做很多转换;那种输入若直接放进性能对比,两条路径其实完成了不同的工作。这里先把工作量对齐,再问几个更窄的问题:同一组任务标签通过 List 与 Sequence 转换、同一组 ID 包装成 value class、同一条合成状态路径通过 `runBlocking`,在当前 JVM 上分别记录什么。

工具是 Kotlin 编译器/插件 2.2.21、JDK 21、Gradle 8.14 以及 OpenJDK JMH 1.37(tag 2effa2c8310e1d3ad03c8ee02024edca9252b46a)。JMH 官方同一 tag 的 JMHSample_02_BenchmarkModes、JMHSample_08_DeadCode 和 JMHSample_27_Params 分别解释模式/单位、死代码消除与固定输入;源码身份及本机解析依赖见 writing-plans/kotlin/research/37.md、verification/37/(2026-10-10 查阅)。测试只在本机 Linux x86_64 运行,不将一次进程的结果推广到所有 JVM 或移动设备。

工作量不一致,比较就会失焦

两组输入都取 1,024 个本地合成标签:前 1,023 个为 Later,末项为 Queued。同一个 labels 在 @Setup(Level.Trial) 构造,不计入每次测量。eagerTaskLabels 先对所有输入 map、filter,再 take(1);lazyTaskLabels 通过 Sequence 延迟执行,末项才匹配,也必须遍历全部输入;测量前显式 toList(),避免把“只构造 Sequence、根本没有读取”当成完成工作。两条路径都传入无副作用回调,返回相同的 listOf("queued");TaskCostProbeTest 验证输入大小和结果。

这依然不是一个“只换集合类型,其余源码逐字相同”的微型实验:两个生产函数复用的是 08 篇的实现,创建/遍历策略确实不同,可能触发 JIT 的不同内联或分配;得出的只可能是这两条实际路径在该输入上的观察。换成首项匹配,Sequence 少做的工作本身才是主要变量,不能从那个结果断言抽象语法有天然的运行速度优势。若业务实际执行 JDBC 或 HTTP,它们的网络与锁等待也不在这个纯计算样本里。

第二组使用同样的 1,024 个字符串,分别生成 List<String> 与 List<TaskId>。TaskId 是 value class,但 Kotlin 的泛型 List<TaskId> 需要保留作为元素的对象表示;返回完整列表交由 JMH 消费,避免整个转换被编译器消掉。此处比较的是整个列表转换与 value class 装箱之和,不是仅仅一个元素的机器指令成本。TaskCostProbeTest 把包装的 ID 映射回字符串,确认业务值相同。

第三组比较直接按 Start/Success 生成 TaskState.Succeeded,和 runBlocking { executeSyntheticTask(TaskId(...)) {} } 的结果,断言两者相等。后者每次包含创建调用方协程作用域、挂起函数框架与 value class 边界,空步骤并没有实际等待;因此这不是“等待一秒任务”的性能,也不能把两个数字之差直接归因于 Kotlin 的 suspend 指令。它只衡量当前这两个 API 调用路径。

测量方法本身要暴露

六个 @Benchmark 方法都返回结果,而不是把它们丢掉;JMH 将返回值交给消费机制,降低被 JIT 完全消除的风险。模式为 AverageTime,单位 us/op,单线程;每个方法两次 fork,每次三轮 250ms 预热和四轮 250ms 测量。-prof gc 额外采集 gc.alloc.rate.norm(B/op);JSON 留有各次测量与误差,jmh.txt 留下命令、fork 与原始输出。gc 计数是此环境的分配证据,不是对象生命周期图,也不等于生产环境堆峰值。重跑时别把单位 us/op 与 MB/sec 混成同一种指标。

测量组 解释时要固定的前提 尚未验证的命题
急切/延迟标签 末项匹配,完整消费,两次 fork 任意位置提前匹配仍花同样时间
原字符串/包装 ID 1,024 项,返回完整列表 所有 value class 使用处都装箱
直接/runBlocking 同一任务状态,空步骤 真实挂起/线程调度、HTTP/数据库吞吐

本次完整复跑的六条 JMH 输出如下。时间栏是均值和 JMH 报出的误差(us/op),分配栏是 gc.alloc.rate.norm(B/op),二者不能互换;原始 fork/iteration 数据在 verification/37/jmh-results.json。

路径 平均时间 ± 误差(us/op) 归一分配(B/op)
eager 60.482 ± 13.516 118,929.7
lazy 50.523 ± 5.474 114,825.4
rawIds 6.225 ± 1.448 4,136.2
boxedIds 10.945 ± 1.708 20,520.3
direct 0.0171 ± 0.0004 80.0
suspended 0.2456 ± 0.0265 292.0

急切与延迟路径的时长误差区间重叠,不能按均值为它们排列可靠的速度名次。包装列表比原字符串列表多分配,归因仅限这次泛型容器路径;direct 和 suspended 的差别包含 runBlocking、函数进入、作用域和状态计算,不是单独一条 suspend 指令的费用。短测量受 JIT、GC、CPU 频率与同机其他会话影响,增加 fork 与输入矩阵才可能支持更强的比较。

为核对 suspended 的调用栈,又单独对它运行了一次带 JFR settings=profile 的 JMH JVM fork。五秒原始录制在 verification/37/suspended.jfr,jfr-summary.txt 显示 237 个 jdk.ExecutionSample 和 1170 个 jdk.ObjectAllocationSample,筛出的栈帧包含 runBlocking、JMH worker(jfr-frames.txt)。这不是上表的两 fork GC profiler 数据:录制开销、fork 和测量配置不同,不能把 JFR 单次平均时间拼进上表。它提供的是定位实际调用路径的采样依据,而不是所有 CPU 时间都花在协程里的结论。

练习与答案

练习一:把 Sequence 输入改成第一项就匹配,还能用当前 lazy/eager 数字比较同一工作量吗?答案:不能。那会让延迟路径少处理大量元素,变量从“表示方式”变成“实际工作量”。

练习二:boxedIds 分配更多,是否证明 value class 在所有地方都会装箱?答案:不能。本次路径是 List<TaskId> 泛型容器转换;局部内联、专门化调用或不同集合形状要另测。

练习三:JFR 采到 runBlocking 栈帧,能否把 JMH 表中的时间全部归因给协程?答案:不能。JFR 录制配置、fork 和样本含义与 JMH GC profiler 不同,只能辅助定位调用路径。

bash examples/kotlin-language/verification/37.sh 要求先有缓存或可获取 JMH 1.37,随后离线跑累计 JUnit/独立 Netty 回环、六个基准及 CLI 正反;jmh-results.json 与测试 XML 可逐条复核。归档解包过程另有原始退出码记录,不拿构建产物出现当运行成功。外部数据库/HTTP 性能、服务吞吐和提交到回滚的 IO 延迟均 NOT_RUN。35 的 JDBC是独立事务实验证据;下一篇的可运行服务关闭和资源观察也需另做。

累计工程归档带有脚本、Wrapper 和 00–37 的研究/原始日志;摘要校验与复跑说明见同名素材目录。