四种写法先算出同一个金额

订单批次的金额可以用数组上的 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
2
3
4
5
6
7
8
final class BatchWorkload(size: Int):
require(size > 0 && size <= 100000)
private val array = Array.tabulate(size) { index =>
new OrderLine(BigDecimal((index % 99) + 1) / 100,
(index % 5) + 1)
}
private val list = array.toList
private val vector = array.toVector

数组转成其他集合的时间和内存不在本次操作的计时范围内。实验回答的是“已经拥有某种表示时,求一次总额需要什么成本”,无法回答从输入文件开始到得到金额的整个路径。如果业务每次请求都临时转 List,而基准只复用预建 List,测量省掉的转换成本就会影响选型。

三种表示引用同一批不可变字段的 OrderLine。这里不把对象复制深度或解析成本算作遍历差异。由于 while 使用数组,而集合路径使用 List/Vector,最终差异包含表示与遍历方式两部分。只凭这组结果不能把全部时间差归因于“函数式闭包”。要隔离闭包成本,需要保持数据表示、数值运算和遍历算法相同,再改变闭包的生成与调用方式。

严格映射和惰性映射做了什么

while 路径逐项读取数组,把小计加入一个局部可变累加器。变量的可变性只影响当前调用,基准状态中的输入没有被修改,因此连续测量可以复用同一份数据。

1
2
3
4
5
6
7
def loopTotal(): BigDecimal =
var result = BigDecimal("0.00")
var index = 0
while index < array.length do
result += subtotal(array(index))
index += 1
result

List 与 Vector 的 map 先生成严格结果集合,再 foldLeft。view 路径在消费时映射,省去那份严格中间结果集合,但仍需完成每一行的金额乘法和整批加法。四条路径按相同输入顺序累加。

1
2
3
4
5
6
7
8
def listTotal(): BigDecimal =
list.map(subtotal).foldLeft(BigDecimal("0.00"))(_ + _)

def vectorTotal(): BigDecimal =
vector.map(subtotal).foldLeft(BigDecimal("0.00"))(_ + _)

def viewTotal(): BigDecimal =
vector.view.map(subtotal).foldLeft(BigDecimal("0.00"))(_ + _)

这份负载会消费全部元素,因而 view 没有通过短路少算订单行。它可能改变中间集合分配、迭代对象和调用方式;“惰性”本身不能推出更快。若改成找到第一个满足条件的订单,消费数量又成为一个变化因素,必须重新定义同工作量的条件。

BigDecimal 运算还可能构造新对象。即使消除了中间 List,乘法与累加仍有数值对象相关的成本。整数、原始类型数组或不同精度金额表示会形成另一份负载;没有运行对应实验,不能套用当前分配数字。

JMH 方法必须保留计算结果

JMH 适配层使用 Java 注解,调用实际 Scala 工作负载。@State(Scope.Thread) 给测量线程独立状态,@Param 指定两个规模,@Setup 构造输入并核验四种结果。测量方法返回金额对象,不把金额算完后丢弃。

1
2
3
4
@Benchmark
public Object loop() {
return workload.loopTotal();
}

返回值交给 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
2
3
-t 1 -f 2 -wi 2 -i 3 -w 1s -r 1s
-jvmArgs "-Xms256m -Xmx256m"
-rf json -foe true

每个方法和规模有两个 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
2
3
4
InvokeDynamic: apply:(Lscalaexamples/BatchWorkload;)Lscala/Function1;
Method scala/collection/immutable/List.map
InterfaceMethod scala/collection/IndexedSeqView.map
Method scala/math/BigDecimal.$times

这些调用点解释了闭包在生成 class 中如何连接到工作负载。它们不是“一次调用必然新建一个闭包”的证明:运行时可能复用非捕获函数,JIT 还可能内联或消除对象。反过来,源代码没有显式 new 也不能证明无分配。subtotal 的字节码还会把整数数量转换为 BigDecimal,再执行乘法,说明数组循环没有绕开与集合路径共享的金额运算。

字节码是执行优化之前的重要产物,不能直接给出某条机器指令的耗时。若看到 invokevirtual 就把它计为固定虚调用开销,会忽略 JIT 的内联;若看到源代码 lambda 就按一个新对象估算堆,也会忽略运行时优化。本章同时保留源码、class 反汇编和实际基准,让各层证据回答各自的问题,具体内联与逃逸结果仍未测量。

正确性断言证明指定规模的四条路径给出相同金额;JMH 生成入口和八组样本证明实际计时;独立 GC 结果证明本次负载观察到的分配。源码解释为什么严格映射存在中间集合,但当前实验没有采集 JIT 汇编、内联决策或逃逸分析日志,所以不会把源码中的每个对象直接对应到运行时堆对象。

微基准把文件、网络、租户配置与错误处理都排除在测量范围外。它适合研究已经识别出的计算热点。若总请求主要等待远程报价,即使总额计算减少几微秒,端到端延迟也可能几乎不变;应先用真实请求数据定位占比,再把必要部分拆成基准。

练习

手算两 fork、每 fork 三轮测量会留下多少个正式样本,并判断“4096 行 while 均值较小”是否足以证明它总比 Vector 快。随后增加同一 Vector 上的 while/iterator 路径,保持金额结果一致,单独比较遍历方式;不要把输入转换成本混入其中一个方法。

参考资料

顺序导航:系列入口:00 · 上一篇:37 · 下一篇:39。