把 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
2
3
4
./mvnw -B -ntp -s .mvn/settings.xml -f benchmarks/pom.xml clean package
java -jar benchmarks/target/benchmarks.jar blog.libraries.LibraryBenchmarks \
-f 2 -wi 2 -i 3 -w 1s -r 1s -t 1 -prof gc \
-rf json -rff benchmarks/target/jmh-result.json

首次运行需要下载固定依赖。结果写入 target,避免覆盖正文对应的原始证据。JMH 的 fork 协调需要本地套接字,本次第一次在受限沙箱运行时出现 Operation not permitted,没有产出有效基准;随后允许本地协调的运行成功。两份日志都保留,构建成功不能替代 fork 执行成功。

以下完整类与实际执行版本一致,参数和负载调整应产生新的结果文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package blog.libraries;

import com.google.common.collect.Lists;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.Caffeine;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import org.eclipse.collections.impl.map.mutable.primitive.IntObjectHashMap;
import org.openjdk.jmh.annotations.*;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 3, time = 1)
@Fork(value = 2, jvmArgsAppend = {"-Xms256m", "-Xmx256m"})
@State(Scope.Thread)
public class LibraryBenchmarks {
@Param({"1024"})
public int size;
private List<Integer> input;
private Map<Integer, Integer> boxed;
private Int2ObjectOpenHashMap<Integer> fast;
private IntObjectHashMap<Integer> eclipse;
private com.google.common.cache.LoadingCache<Integer, Integer> guavaHit;
private com.github.benmanes.caffeine.cache.LoadingCache<Integer, Integer> caffeineHit;
private com.google.common.cache.LoadingCache<Integer, Integer> guavaMiss;
private com.github.benmanes.caffeine.cache.LoadingCache<Integer, Integer> caffeineMiss;
private int cursor;

@Setup(Level.Trial)
public void setupAndCheckEquivalentResults() throws Exception {
input = new ArrayList<>(size);
boxed = new HashMap<>(size * 2);
fast = new Int2ObjectOpenHashMap<>(size);
eclipse = new IntObjectHashMap<>(size);
guavaHit = newGuava(size * 2);
caffeineHit = Caffeine.newBuilder().maximumSize(size * 2).build(key -> key * 3);
guavaMiss = newGuava(256);
caffeineMiss = Caffeine.newBuilder().maximumSize(256).executor(Runnable::run).build(key -> key * 3);
for (int i = 0; i < size; i++) {
int key = 1000 + i;
input.add(key);
Integer value = key * 3;
boxed.put(key, value);
fast.put(key, value);
eclipse.put(key, value);
guavaHit.put(key, value);
caffeineHit.put(key, value);
}
guavaHit.cleanUp();
caffeineHit.cleanUp();
if (!eagerTransform().equals(lazyTransformThenMaterialize())) throw new AssertionError("transform mismatch");
for (int key = 1000; key < 1000 + size; key++) {
Integer expected = boxed.get(key);
if (!expected.equals(fast.get(key)) || !expected.equals(eclipse.get(key))
|| !expected.equals(guavaHit.get(key)) || !expected.equals(caffeineHit.get(key))) {
throw new AssertionError("lookup mismatch");
}
}
if (guavaMiss.get(99) != 297 || caffeineMiss.get(99) != 297) throw new AssertionError("load mismatch");
guavaMiss.invalidateAll();
caffeineMiss.invalidateAll();
cursor = 0;
}

private static com.google.common.cache.LoadingCache<Integer, Integer> newGuava(int max) {
return CacheBuilder.newBuilder().maximumSize(max).build(new CacheLoader<Integer, Integer>() {
@Override public Integer load(Integer key) { return key * 3; }
});
}

@Benchmark
public List<Integer> eagerTransform() {
List<Integer> output = new ArrayList<>(input.size());
for (Integer value : input) output.add(value * 3);
return output;
}

@Benchmark
public List<Integer> lazyTransformThenMaterialize() {
return new ArrayList<>(Lists.transform(input, value -> value * 3));
}

private int hitKey() { return 1000 + ((cursor++ & Integer.MAX_VALUE) % size); }
private int missKey() { return 100000 + (cursor++ & 0x3fffffff); }

@Benchmark public Integer boxedLookup() { return boxed.get(hitKey()); }
@Benchmark public Integer fastutilLookup() { return fast.get(hitKey()); }
@Benchmark public Integer eclipseLookup() { return eclipse.get(hitKey()); }
@Benchmark public Integer guavaHit() { return guavaHit.getUnchecked(hitKey()); }
@Benchmark public Integer caffeineHit() { return caffeineHit.get(hitKey()); }
@Benchmark public Integer guavaMiss() { return guavaMiss.getUnchecked(missKey()); }
@Benchmark public Integer caffeineMiss() { return caffeineMiss.get(missKey()); }
}
完整基准源码、独立模块配置、运行说明、原始各fork结果 和 完整运行日志 一起交付。Java8实验未运行:模块需要更高编译目标,不能把主系列的Java8契约回归当作此基准在Java8的性能结果。
可迁移做法 约束与用途
先比较完整结果,再测实现成本 把元素、顺序、所有权和错误规则纳入等价检查,防止省略实际工作
把耗时、分配和运行条件一起交付 保留fork与迭代数据,只在同一负载和机器范围内解释结果

缓存或集合的替换决策还需要功能回归。低几个纳秒无法修复错误的键相等规则,较低的分配也不能接受未受控的容量增长。下一份综合工程会把输入预算、查询和归档放进同一条实际路径,使每个类库的收益对应具体业务操作。

参考资料