Java常用类库-E03-原始类型集合的缺省值与装箱成本
商品编号索引不只是在替换 Map 类名
商品索引用整数编号查找结果,现有实现是 HashMap<Integer, Integer>。一次查询看起来只调用 get,但调用点需要把 int 表示为 Integer,容器也要组织对象键。fastutil 与 Eclipse Collections 提供原始类型键接口,可以减少这层表示成本;与此同时,缺失值、null 和迭代方式也会变化。
本篇固定 fastutil 8.5.16,源码 aa30fa7d93bf06fec45c4f6f572a1f6a38047d9f,以及 Eclipse Collections 11.1.0,源码 fb6abf7fe655ff04acf616158bce49725f2df5ad。后者选择 Java 8 兼容版本,不据此推断当前所有版本都支持 Java 8。完整测试见 Extension03Test.java,运行说明见 RUN.md。
实验分成两种类型组合。int→int 用于说明零与缺失的协议;int→Object 用于和现有 HashMap<Integer, Integer> 的布局、查找实验对齐。不能把前一组节省值装箱的收益,套在后一组仍保存 Integer 值的测量结果上。
返回零不能证明编号存在
普通 Map.get 返回 null,可能表示键不存在,也可能表示存在一个值为 null 的映射。测试分别查询已存 null 的键 1 和缺失键 2,get 都返回 null,containsKey 才区分两者。将容器换成原始类型映射后,这个问题不会消失,只是缺省表示发生变化。
fastutil 的 Int2IntOpenHashMap 默认缺省返回值是零。存入 1→0 后,get(1) 和 get(2) 都得到零。测试再把 defaultReturnValue 改为 -1,并存入 3→-1,缺失键与合法 -1 值又发生碰撞。换一个看似罕见的哨兵值,没有从类型上消除歧义。
Eclipse 的 IntIntHashMap 也用原始 int 表示结果。测试用 getIfAbsent(2,-1) 得到 -1,用 getIfAbsent(1,-1) 得到已存零。这个方法把缺省值放在当前调用处,比较容易看清调用意图,但若合法值也可以为 -1,仍然需要 containsKey 或更明确的业务返回类型。
| 结果类型 | 已存值 | 缺失返回 | 是否能只看 get 判存在 |
|---|---|---|---|
| Map<Integer,Integer> | null | null | 不能 |
| fastutil int→int 默认 | 0 | 0 | 不能 |
| fastutil 改为 -1 | -1 | -1 | 不能 |
| Eclipse getIfAbsent | 业务值可能等于指定缺省 | 指定值 | 取决于业务值域,不能自动保证 |
第一项可迁移模式是把存在性与值域分别建模。如果业务确实排除了某个值,可以在封装边界使用它作为哨兵,但必须持续验证该约束。对外 API 不必暴露容器内部的默认值协议,可以转换为明确的查询结果类型,让调用方不依赖某个集合配置。
固定源码说明存储与查找分支
fastutil 通过模板生成不同原始类型组合。固定 OpenHashMap 模板在查找未命中时返回 defRetValue;命中时从值存储区取值。这说明缺省值是接口与实现共同采用的返回协议,不是数据自动补全行为。
Eclipse 的原始类型映射模板同样体现原始类型数组和专门的方法签名。研究源码时应定位生成模板,而不是因为仓库没有逐个提交所有展开后的类文件,就认定发布包缺少实现。
对应用而言,模板生成带来的类型组合数量也是 API 成本。int→int、int→Object 和 Object→int 不能仅凭名称相似就互换。迁移前应确定哪一侧确实需要原始类型,并保留另一个维度原本的空值、相等和对象生命周期语义。
迭代结果可以相同,协议仍不同
测试对三种 int→int 或对象等价容器填入同一组键:1000 至 2023,值为键乘三,共 1024 项。遍历时把每项键和值相加,三者都得到校验和 6191104,同时检查各表大小为 1024。手算可以先对连续键求和,再乘四,得到同一结果。
校验和适合发现这组简单数据的明显偏差,却不能证明任意集合内容完全相等;不同错误可能抵消成相同和。实验代码因此保留统一的填充循环与明确键域,查找基准在计时前还逐键核对返回值。迁移验收若面对任意业务数据,应逐项比较或使用更完整的不变量,不能只保留一个总和。
JDK 使用 Map.Entry<Integer,Integer>;fastutil 使用 int2IntEntrySet 的专用迭代器,再调用 getIntKey 与 getIntValue;Eclipse 使用 forEachKeyValue 接收原始类型参数。后两种方式让方法签名保留原始类型,避免在遍历接口边界再次强制包装。
fastutil 的 fastIterator 还需要阅读对应入口的说明:某些快速迭代实现会复用 entry 对象。循环内读取键值并累加,与把每个 entry 引用保存到 List 供稍后使用,是不同操作。需要保存结果时应复制需要的键值,不能假定每次 next 都返回独立且长期稳定的快照。
本地实验只在循环内使用 entry,没有把快速迭代器的对象复用作为已测通用属性,也没有断言遍历顺序。HashMap 风格容器通常不适合承担业务稳定排序;如果导出需要按编号排列,应显式排序或选择相应有序结构,并把这项成本纳入端到端比较。
布局估计、分配率与保留堆是三种量
本篇使用 JOL 0.17 的 GraphLayout 对已构建对象图作布局估计。JOL 固定源码为 400c871bd4b7542fdbba96f4081b1ec3a01b5ebe。本机未取得 Instrumentation 与 Serviceability Agent,原始输出明确提示部分 VM 布局信息采用推断,因此结果标记为布局估计,不称为精确堆测量。
三种容器都保存同一组 int 键和 Integer 值;每个值先创建一次,再分别放进三张表。对每张表单独遍历可达对象图,得到 HashMap 73792 字节、fastutil Int2ObjectOpenHashMap 32880 字节、Eclipse IntObjectHashMap 32840 字节。JDK 8 与 JDK 21 在本机这组条件下报告相同数字,不能据此推导所有 VM 布局相同。
| 本机 1024 项对象图 | JOL 布局估计字节 | 测量含义 |
|---|---|---|
| HashMap<Integer,Integer> | 73792 | 从该根可达的容器及键值对象图 |
| Int2ObjectOpenHashMap |
32880 | 原始键结构与对象值图 |
| IntObjectHashMap |
32840 | 原始键结构与对象值图 |
从每个根分别统计时,共享的 Integer 值会在每次单独统计中出现,因此不能把三个数字简单相加作为三表同时存在时的去重总量。GraphLayout 也不是 GC 分析中的 retained size:某个值同时由外部根引用,删除这张表并不会让该值可回收。
容量增长、装载因子、对象对齐和压缩引用都会影响布局。这里三张表采用实验代码中明确可见的构造与填充路径,没有为了得到某个节省比例反复选择容量。若生产表长期处于不同装载率,应按实际容量状态重新估计或测量,不应把这个比例作为采购容量公式。
复用查找基准时保留原工作量
系列第 36 篇的 JMH 原始结果也随本篇提供。基准使用预先构建的 1024 个整数键,键从 1000 开始,值为共享 Integer 对象;每个线程持有自己的状态,按循环游标查找已存在键,返回查询结果。两次独立 fork,每次两轮预热、三轮测量,每轮一秒,堆设置为 256 MiB。
三种查找的平均时间分别为 HashMap 2.186±0.206 ns/op、fastutil 1.523±0.139 ns/op、Eclipse 1.293±0.186 ns/op。这里的 ± 为 JMH JSON 报告的 scoreError;不是所有请求延迟分位数,也不意味着任意两次测量之间都有固定差距。该实验选择 AverageTime 模式,不能把结果标题写成已经测了所有吞吐场景。
gc.alloc.rate.norm 对 HashMap 报告约 16 B/op,两种原始键容器均接近零。这个结果说明在本机本次编译与调用路径中,按 int 查对象 Map 的装箱形成了可观测分配;它不证明每一种 JIT、每一个键范围和所有 Map.get 写法都会分配。键从 1000 开始也刻意避开了常见的小整数缓存区间。
这些 B/op 数字描述单位操作的分配率,不是表在堆中占用多少空间。预构建状态把大量初始化分配放在计时之外,所以接近零的查询分配完全可能对应一张占用很多内存的表。把 JMH 分配率和 JOL 布局估计并排展示,是为了区分两个问题,而不是用一个数字验证另一个数字。
迁移边界决定收益能保留多少
空键也是迁移协议的一部分。对象 Map 可以在某些实现中接受 null 键,原始 int 参数则没有 null 这种取值。若既有数据用 null 代表“未分组商品”,迁移时必须先决定把这类记录放到独立位置、拒绝它,还是改用显式分组类型;随意选一个整数代替,可能与真实编号冲突。这与值侧默认返回值问题相似,但属于另一侧输入域。
专用迭代也会影响异常处理与组合方式。Eclipse 的回调接收键和值,fastutil 的迭代器暴露条目,通用 Stream 或 Map API 适配时可能重新建立包装对象。一个只比较容器内部查找的基准没有覆盖这些适配动作。若应用最终总要生成 DTO 列表,应另测包含输出构造的完整流程,并维持同样字段内容。
布局表中两种原始键容器只相差四十字节,不能据此判断一个库在所有数据规模下都更省空间。当前容量阈值、对象字段和数组分配恰好形成这个结果;项数跨过扩容边界时,差距可能改变。应优先理解减少了哪些包装键或条目对象,再以实际规模验证,而不是把很小的单样本差额解释成普遍优势。
如果上游已经持有 Integer 对象,而查询方法签名也接收 Integer,原有 Map 可能不需要在该调用点新装箱。反过来,若原始类型集合每次查询后又转换成通用 Map 或收集为包装键值对,节省可能被适配层消耗。应从真实调用边界确认装箱位置,而不是仅搜索代码中是否出现尖括号。
第二项可迁移模式是将优化证据绑定到具体工作量。容器构建、命中查找、未命中查找、遍历、删除与扩容具有不同成本;本组 JMH 只测预构建命中查询。它没有比较随机键分布、并发写入、碰撞攻击、扩容停顿或长期垃圾回收表现。
采用第三方集合也会改变公共类型传播。如果在服务公共接口返回专用映射,下游会依赖同一类库;如果只在内部使用,可以在业务边界转换为更稳定的结果类型,但需要计算复制成本。依赖选择应考虑这些传播成本,不能只按一条纳秒级查找结果决定整个系统的数据模型。
标准库选择与练习
数据量较小、查询频率低或业务本来需要对象键时,JDK Map 的通用协议与生态适配可能更有价值。编号稠密且范围受控时,数组或带显式存在标志的数组结构也可能满足需求,但它们的空间成本依赖最大编号而不是实际项数。应先确定稀疏性和缺失语义,再决定容器。
三个功能测试在双 JDK 上通过,分别覆盖默认值碰撞、同输入遍历校验和与 JOL 布局估计。完整可复跑项目、原始布局日志和已有 JMH 数据见 下载实验工程、JMH 原始 JSON。
手算题:缺省返回值为 -1,表中存在 3→-1,get(3) 能否证明命中?不能,仍需存在性判断。改动练习是把查询键改到小整数区间,并单独运行相同 JMH 参数,比较分配率;这属于待运行扩展,不在本篇结果中提前给出预期数值。
| 判断关键词 | 可迁移模式 | 具体选择 |
|---|---|---|
| defaultReturnValue | 存在性与值域分开 | 不凭零、null 或哨兵值判断命中 |
| primitive API | 装箱发生在调用边界 | 保留专用迭代与查找签名 |
| B/op、graph bytes | 不同资源指标分开 | 分配率、可达图估计、保留堆分别解释 |
| ns/op | 优化结论绑定工作量 | 不外推未命中、扩容或并发场景 |
系列起点:可复现基线。


