Scala 38:集合与抽象的性能证据
四种写法先算出同一个金额
订单批次的金额可以用数组上的 while 循环计算,也可以把 List、Vector 的每个订单行映射成小计,再折叠求和。Vector 的 view 则把映射推迟到消费阶段。源代码的长度不同,执行过程还受集合结构、闭包、十进制计算、JIT 与垃圾回收共同影响。要比较成本,首先要保证每一种实现完成同样的工作。
本实验把数量和单价存入 OrderLine,四种路径都使用 BigDecimal 乘法和加法,并返回整批小计。输入在基准启动前构造,测量期间复用;循环中没有文件读取、随机生成或控制台输出。正确性程序先在 1、7、512、4096 行下对照结果,同时拒绝零行负载。这些断言通过之后才运行 JMH。
状态为 LAB_VERIFIED。冻结组合是 Scala 3.3.7、Scala CLI 1.9.1、JMH 1.37、Amazon Corretto JDK 21.0.11。已经运行两 fork、每 fork 两轮预热及三轮正式测量,得到八组带原始样本的结果;GC profiler 另行运行四组诊断。代码在 examples/scala-lab/snippets/38/ 与 modules/38/,原始证据在 modules/38/evidence/20261002-ch38/。RUN.md 提供重跑命令。
输入表示也是比较条件
负载构造器按索引生成单价和数量,随后建立数组、List 和 Vector。生成规则确定,便于核验金额;数据仍作为运行时状态传入基准,不把一个可提前算好的金额常量直接写进测量方法。
1 | |
数组转成其他集合的时间和内存不在本次操作的计时范围内。实验回答的是“已经拥有某种表示时,求一次总额需要什么成本”,无法回答从输入文件开始到得到金额的整个路径。如果业务每次请求都临时转 List,而基准只复用预建 List,测量省掉的转换成本就会影响选型。
三种表示引用同一批不可变字段的 OrderLine。这里不把对象复制深度或解析成本算作遍历差异。由于 while 使用数组,而集合路径使用 List/Vector,最终差异包含表示与遍历方式两部分。只凭这组结果不能把全部时间差归因于“函数式闭包”。要隔离闭包成本,需要保持数据表示、数值运算和遍历算法相同,再改变闭包的生成与调用方式。
严格映射和惰性映射做了什么
while 路径逐项读取数组,把小计加入一个局部可变累加器。变量的可变性只影响当前调用,基准状态中的输入没有被修改,因此连续测量可以复用同一份数据。
1 | |
List 与 Vector 的 map 先生成严格结果集合,再 foldLeft。view 路径在消费时映射,省去那份严格中间结果集合,但仍需完成每一行的金额乘法和整批加法。四条路径按相同输入顺序累加。
1 | |
这份负载会消费全部元素,因而 view 没有通过短路少算订单行。它可能改变中间集合分配、迭代对象和调用方式;“惰性”本身不能推出更快。若改成找到第一个满足条件的订单,消费数量又成为一个变化因素,必须重新定义同工作量的条件。
BigDecimal 运算还可能构造新对象。即使消除了中间 List,乘法与累加仍有数值对象相关的成本。整数、原始类型数组或不同精度金额表示会形成另一份负载;没有运行对应实验,不能套用当前分配数字。
JMH 方法必须保留计算结果
JMH 适配层使用 Java 注解,调用实际 Scala 工作负载。@State(Scope.Thread) 给测量线程独立状态,@Param 指定两个规模,@Setup 构造输入并核验四种结果。测量方法返回金额对象,不把金额算完后丢弃。
1 | |
返回值交给 JMH 消费,使这份结果在测量结构中保留用途。若把方法改成 void 并完全忽略计算结果,JIT 可能消除不影响可观察行为的工作;纳秒级的漂亮数字可能只是没有执行目标运算。JMH 的官方 DeadCode 样例专门对照了这种形状。固定版本样例
常量折叠是另一个问题。让方法返回一个基于字面常量的表达式,并不一定保住目标计算;编译器仍可能提前得到结果。当前参数在运行时注入状态,金额从对象字段读取,每次调用都会处理数组或集合。官方 ConstantFold 样例展示了状态输入与可预测常量之间的区别,但这项措施也不能保证每一种 JVM 优化都被禁止。固定版本样例
JMH 适配层本身需要真正生成基准入口。runner 调用 javac 和 JMH annotation processor,随后用 org.openjdk.jmh.Main -l 核验四个方法出现。没有生成 BenchmarkList 的普通 Java 类,即使有注解,也不能直接成为可运行的 JMH 基准。
预热、fork 与样本分别解决什么
用两次 System.nanoTime 包住一次方法调用,能得到一个时间差,却不能自动排除首次加载、JIT 状态和测量代码自身的影响。若负载很短,计时开销也会占据明显比例;若人为在外层再套一百万次循环,优化器又可能把跨迭代不变的工作移动出去。这些问题不能只靠增加循环次数消除。JMH 仍需正确设计输入与消费,但它提供统一生成的测量结构和明确的预热、fork、时间配置,使不同方法的比较条件可以检查。
JVM 刚启动时的执行状态与运行一段时间之后可能不同。类加载、解释执行、JIT 编译和运行时画像都会影响耗时,所以实验先预热,再采集正式样本。两轮预热各一秒是本次实验配置,不是已经证明所有方法达到稳定峰值。
fork 启动独立 JVM,减少不同基准在同一个虚拟机中共享运行画像的影响。它没有隔离整个操作系统:其他进程仍然可能竞争 CPU、内存带宽和功耗预算。官方 Forking 样例说明了同 JVM 内画像混合如何影响比较。固定版本样例
1 | |
每个方法和规模有两个 fork,每个 fork 三个正式样本,共六个数值。runner 检查 JSON 中两层 rawData 的形状,避免只留下一个最终平均值,却缺少产生这个均值的观察。一次操作对应整个批次,因此 us/op 的 op 是一次总额计算,不能把数字直接当作单行订单的耗时。
基准只有一个工作线程,固定堆为 256 MiB。它没有测吞吐并发扩展性,也没有模拟线程池排队或请求尾延迟。测量模式是 AverageTime,平均耗时与生产接口的 p99 延迟属于不同统计量。
本机计时结果怎样读
下面的数值来自真实 timing-results.json,单位均为微秒每批。误差列保留 JMH 的 scoreError,没有根据期望排序修改数值。
| 实现 | 512 行均值 | JMH 误差 | 4096 行均值 | JMH 误差 |
|---|---|---|---|---|
| Array while | 9.731 | 0.991 | 84.942 | 26.401 |
| List map + fold | 12.495 | 1.844 | 98.264 | 16.321 |
| Vector map + fold | 12.240 | 1.060 | 95.705 | 4.523 |
| Vector view + fold | 11.557 | 1.913 | 94.569 | 6.599 |
4096 行 while 的六个正式样本约为 79.670、77.869、76.747、90.505、101.238、83.620。两个 fork 的观测差异明显,只读 84.942 的平均值会隐藏这件事。Vector 的均值略高,但在这份有限样本下,不能把这组数字升级成固定速度比例或普遍排序。
机器为 macOS arm64,14 个逻辑 CPU,同时有其他编译任务。GC 阶段开始时记录的系统负载均值约为 49.89、42.29、37.29,明显不是空闲独占主机。历史 machine.json 保存该次记录;后续 runner 按阶段生成独立 machine 文件,避免多个阶段覆盖同一份环境记录。这里没有把负载记录当作每一轮计时中的连续监控。
这份结果证明基准确实执行并保存了规定形状的观测,支持在相同方法中讨论噪声和工作量。若需要据此决定生产实现,下一步应在可控主机上增加预热与独立重复运行,检查不同规模和真实订单分布,再结合业务性能预算。当前证据不足以宣布“Scala 集合慢多少倍”。
分配诊断必须与计时分开
GC profiler 另开一次运行:一个 fork、一轮 500 毫秒预热、一轮 500 毫秒测量,只测 512 行。该次诊断的目标是观察分配形状,短测量也不支持稳定的 GC 时间比较。表中的 B/op 来自 gc.alloc.rate.norm。
| 实现 | 512 行分配,B/op |
|---|---|
| Array while | 143256.1 |
| List map + fold | 155560.2 |
| Vector map + fold | 145680.2 |
| Vector view + fold | 143344.2 |
view 的分配接近 while,严格 List 的分配更多,符合需要建立额外结果集合的方向。但这些数字是完整负载的观测,不能精确分解成某个闭包、某个节点或某次 BigDecimal 的分配大小。JIT 可能消除部分对象,其他对象又受实现细节影响;要定位具体分配,需追加相应的 profiler 或编译产物分析。
B/op 也不是进程最终保留的堆大小。运算中创建后很快死亡的对象仍会计入分配;GC 次数和时间取决于堆、运行时长与收集器。四组分别出现 39–43 次 GC,并不能推出 List 更频繁触发某种生产故障。
诊断运行中的时间数据保存在原始 JSON,但正文的耗时表只使用无 profiler 的计时结果。这样不会把 profiler 开销与普通计时拼接成一张看似一致的排名表。
结果、产物与推断的边界
javap -private -c 的实测输出在 evidence/20261002-ch38-artifacts/bytecode.log。while 路径出现 arraylength、aaload 与回跳;List 路径调用 List.map 和 foldLeft;view 路径调用 IndexedSeqView.map,没有先执行严格 Vector 的 map。映射用的 Function1 在 invokedynamic 调用点捕获当前 BatchWorkload,因为它需要调用实例方法 subtotal;求和 Function2 的调用点不携带这个实例参数。
1 | |
这些调用点解释了闭包在生成 class 中如何连接到工作负载。它们不是“一次调用必然新建一个闭包”的证明:运行时可能复用非捕获函数,JIT 还可能内联或消除对象。反过来,源代码没有显式 new 也不能证明无分配。subtotal 的字节码还会把整数数量转换为 BigDecimal,再执行乘法,说明数组循环没有绕开与集合路径共享的金额运算。
字节码是执行优化之前的重要产物,不能直接给出某条机器指令的耗时。若看到 invokevirtual 就把它计为固定虚调用开销,会忽略 JIT 的内联;若看到源代码 lambda 就按一个新对象估算堆,也会忽略运行时优化。本章同时保留源码、class 反汇编和实际基准,让各层证据回答各自的问题,具体内联与逃逸结果仍未测量。
正确性断言证明指定规模的四条路径给出相同金额;JMH 生成入口和八组样本证明实际计时;独立 GC 结果证明本次负载观察到的分配。源码解释为什么严格映射存在中间集合,但当前实验没有采集 JIT 汇编、内联决策或逃逸分析日志,所以不会把源码中的每个对象直接对应到运行时堆对象。
微基准把文件、网络、租户配置与错误处理都排除在测量范围外。它适合研究已经识别出的计算热点。若总请求主要等待远程报价,即使总额计算减少几微秒,端到端延迟也可能几乎不变;应先用真实请求数据定位占比,再把必要部分拆成基准。
练习
手算两 fork、每 fork 三轮测量会留下多少个正式样本,并判断“4096 行 while 均值较小”是否足以证明它总比 Vector 快。随后增加同一 Vector 上的 while/iterator 路径,保持金额结果一致,单独比较遍历方式;不要把输入转换成本混入其中一个方法。
