Java常用类库36:用JMH比较等价工作与分配成本
把 Lists.transform 的创建时间与遍历后得到完整列表的时间放在一起,通常会得到惰性转换非常快的结果。两段程序交付的东西却不同:前者返回尚未执行转换的视图,后者已经持有全部输出元素。商品报表如果必须序列化全部结果,前者的计算还会发生在后续遍历中。测量范围不同,数字再稳定也无法回答报表应采用哪种实现。
集合和缓存的比较需要先写出可观察结果,再确定计时边界。查找实验要返回同一个值;转换实验要形成元素和顺序相同的完整列表;缓存命中与未命中应分开。惰性求值的边界 与 缓存迁移契约 已说明,性能只能比较满足要求的候选方案,不能抵消语义变化。
本文固定 JMH 1.37,在 macOS aarch64、Corretto 21.0.11 执行九组真实基准。独立 benchmarks 模块以 release17 编译,依赖 Guava 33.5.0-jre、Caffeine 3.2.4、fastutil 8.5.16、Eclipse Collections 11.1.0。数字只描述本次小工作集、单线程、简单整数值负载,不构成生产环境的类库排名。
计时之前确定交付物
转换的输入有1024个整数,值从1000开始。直接循环创建预分配的 ArrayList,另一方案创建 Guava 转换视图后再用 new ArrayList<>(view) 物化,两者都返回包含1024个三倍值的独立列表。初始化阶段比较整个列表的相等性,不能只比较长度。基准返回结果,使生成的 JMH 调用代码消费返回值,避免结果未使用导致计算被删除。
这里没有对两个实现强行指定同一条分配路径。循环可以预先知道输出大小,集合构造器可能经过 toArray 等中间路径。它们满足同一份列表契约,但执行步骤不同;这正是需要测量的实现差异。结论应指向这两种完整写法,不能扩展为“惰性转换总是慢”。当业务只消费前几个结果,完整物化的契约已经改变,需要另一份实验。
映射比较采用整数键、对象值。HashMap<Integer,Integer>、Int2ObjectOpenHashMap<Integer>、IntObjectHashMap<Integer> 预先保存相同1024个映射,并共享同一批 Integer 值对象。原始类型键容器省去的是键接口的装箱要求,值仍是对象。它们没有变成整数键到整数值的专用表,也不能把别的 int→int 实验的默认返回值和内存数字混入这组结果。
每次查询循环选择已有键,初始键避开常见的小整数缓存区。返回对象值保持一致;对象 HashMap 的调用处需要把动态 int 键装箱,原始键版本直接接受 int。这种接口成本属于当前业务写法的计时范围。如果真实调用方已经长期持有 Integer 键,复用对象的实验可能得到不同分配结果。先明确调用端输入形态,再决定是否把装箱成本算进候选方案。
JMH 管理的执行边界
基准状态采用 Scope.Thread,每个测量线程持有自己的表、缓存和游标。初始化在 Level.Trial 建表并验证结果,建表时间不进入单次查找。状态字段保留动态输入,查询键随游标变化;把固定常量直接写入计算表达式可能被优化成预先可知的结果。固定版本 ConstantFold 样例 展示了这种风险。
完整转换本来需要遍历输入,循环属于工作量。查找基准则每次调用只执行一次查找,没有为了降低计时开销在方法内部再包十万次循环。手写批量循环会改变优化和依赖关系,使“总时间除以次数”的结果偏离独立调用。对于非常短的操作,JMH 的调用机制仍不自动证明机器码与预想一致;更严格的热点分析可以补充汇编和其他 profiler,本次没有执行这些检查。
每组基准运行两个独立 JVM fork,每个 fork 先预热两次、每次一秒,再测量三次、每次一秒。命令明确单线程,堆初始与最大值均为256MiB。预热用于让运行状态接近持续执行,fork 用于隔离不同运行产生的编译配置影响。Forking 官方样例 解释了单一 JVM 中类型画像相互影响的问题。
两次预热并不是所有程序都已稳定的证明。这个运行规格适合生成可核查的小实验;遇到明显的迭代漂移、复杂初始化或长周期缓存维护,应调整预热和测量时长,并保留调整前的数据。独立 fork 也没有隔离 CPU 温度、系统后台任务和内存竞争。运行机器、JVM版本、启动参数与原始各迭代结果必须一起保存。
缓存命中与未命中的两份负载
命中缓存预放1024个条目,最大容量2048,测量循环查询已有键,不混入冷启动的首次加载。Guava 与 Caffeine 都返回键的三倍值。缓存比普通表额外维护过期、容量或访问相关状态;普通 HashMap 并没有提供相同的缓存契约。表查找与缓存命中的数字可以解释额外机制的成本,不能据此建议直接用无界表替换需要容量管理的缓存。
未命中缓存容量设为256,游标生成持续的新键,加载器只做整数乘法。测量包含加载、写入与容量管理成本,不包含HTTP或磁盘访问。Caffeine 这一组显式采用直接执行器,维护任务会在提交线程执行;命中组保留默认执行器。线程配置属于实验定义,不是调优建议。生产缓存的异步维护、后端等待与并发合并必须采用另一个符合真实线程模型的负载。
初始化还检查未命中加载结果,再清空缓存和重置游标。新键生成使用有限整数区间,最终会重复;本次短时测量没有达到约十亿次的回绕范围。持续压测或扩大吞吐规模时应另行确认键空间和操作计数,不能把有限生成器描述成无限唯一键。
这组负载刻意固定容量、简单加载器和访问顺序,便于检查每个操作的含义。它没有覆盖热门键倾斜、扫描污染、刷新、过期、共享线程竞争或后端抖动。特别是1024个条目的小工作集容易维持较好的局部性,较大表与不规则键分布可能改变内存访问成本。缓存策略的生产收益需要结合真实分布和命中率验证。
本次结果与可支持的结论
下表来自保留的 jmh-result.json。主指标为平均每次操作耗时,误差列为 JMH 默认99.9%均值置信区间的半宽;分配指标来自 -prof gc 的 gc.alloc.rate.norm。每组原始数据均含两个 fork、各三次测量,没有把失败 fork 的缺失数据计入结果。
| 操作 | 平均 ns/op | 误差 ±ns/op | 分配 B/op |
|---|---|---|---|
| 对象键 HashMap 查询 | 2.186 | 0.206 | 16.000 |
| fastutil 原始键查询 | 1.523 | 0.139 | 约0 |
| Eclipse 原始键查询 | 1.293 | 0.186 | 约0 |
| Guava 缓存命中 | 27.630 | 3.969 | 40.000 |
| Caffeine 缓存命中 | 13.504 | 1.549 | 16.000 |
| Guava 缓存未命中 | 152.910 | 36.696 | 203.713 |
| Caffeine 缓存未命中 | 112.129 | 16.556 | 315.866 |
| 循环转换并物化 | 2678.133 | 215.023 | 20520.018 |
| 转换视图后物化 | 4234.677 | 212.513 | 24664.029 |
原始键查询的分配结果支持当前动态 int 调用路径减少键装箱的判断。两个“约0”分别是约0.000010和0.000009B/op的估计值,表内的舍入不能变成整个程序绝无分配的断言。建表与输入对象分配发生在初始化阶段,不属于这些查询操作的 B/op。
Eclipse 与 fastutil 两个原始键查找的区间也有重叠,不能从当前均值给两个项目排出稳定先后。微小差距还包含游标递增、取模、返回值消费与测量循环成本;没有专门的基线和机器码观察,就不应把全部纳秒归因于哈希表实现。方法体短并不等于已经隔离出容器内部的唯一成本。
这份比较关注持续运行的单线程局部调用。需要验证共享缓存并发时,不能简单把线程数改成八而保留 Scope.Thread:那会测量八份独立缓存。共享状态、竞争键分布与加载器阻塞方式应一并改变,再用吞吐或采样时间回答新的问题。平均时间模式也没有提供排队时间、突发容量和请求尾延迟,这些指标应由匹配服务调用边界的实验补充。
输入整数是连续生成的,查询顺序循环重复,没有随机采样。连续键的哈希分布和局部性属于结果条件;生产商户编号的稀疏性与热门商品分布未被模拟。更换访问序列时应在初始化阶段生成同一份序列供各实现使用,把随机数生成移出被测查找,避免测到生成器成本之后归因于容器。
完整物化的两种转换都执行了1024个元素的计算。当前循环写法耗时和分配更少,支持这份完整列表交付选择循环实现;没有证据支持删除惰性视图这种 API。它仍可能适合短路消费或避免立刻复制的场景,这些场景应重新确定结果与预算。
缓存未命中有一个需要保留的细节:Caffeine 的均值更低,但分配更多,而且两者均值置信区间重叠。这个有限样本不能形成稳定优势或显著性检验的结论。命中与未命中的值也不能直接相加成为业务请求延迟;至少需要实际命中率、后端加载成本及等待合并关系。99.9%均值置信区间更不是“99.9%的请求在这个时间内完成”,本次没有测量尾部请求延迟。
Result 固定实现 与 GCProfiler 分配归一化 可以核对指标计算范围。B/op表示测量区间内的分配估计,不是缓存最后保留的堆大小。对象布局、共享引用和可达对象总量属于另一类问题;原始类型集合 对这些口径分别讨论。
复跑入口与完整基准
在 examples/java-libraries 目录执行,使用JDK21或已另外验证兼容的运行时:
1 | |
首次运行需要下载固定依赖。结果写入 target,避免覆盖正文对应的原始证据。JMH 的 fork 协调需要本地套接字,本次第一次在受限沙箱运行时出现 Operation not permitted,没有产出有效基准;随后允许本地协调的运行成功。两份日志都保留,构建成功不能替代 fork 执行成功。
以下完整类与实际执行版本一致,参数和负载调整应产生新的结果文件:
1 | |
| 可迁移做法 | 约束与用途 |
|---|---|
| 先比较完整结果,再测实现成本 | 把元素、顺序、所有权和错误规则纳入等价检查,防止省略实际工作 |
| 把耗时、分配和运行条件一起交付 | 保留fork与迭代数据,只在同一负载和机器范围内解释结果 |
缓存或集合的替换决策还需要功能回归。低几个纳秒无法修复错误的键相等规则,较低的分配也不能接受未受控的容量增长。下一份综合工程会把输入预算、查询和归档放进同一条实际路径,使每个类库的收益对应具体业务操作。
