Skip to content

平均 86 纳秒的 put,为什么有一次要 21 毫秒 ​

往默认容量的 HashMap 里放 200 万个键,平均每次 put 86 纳秒,最慢的一次却要 21 毫秒,差了五个数量级。这一次正好是第 1,572,864 次 put,表在这里扩容。换成 new HashMap<>(2_000_000) 也没用,扩容还是会发生,只有 HashMap.newHashMap(2_000_000) 才能消掉它。

ArrayList.add 和 HashMap.put 常被说成 O(1)。准确地说,它们的 O(1) 是均摊的:绝大多数操作很便宜,偶尔有一次要把整个底层数组复制一遍,这一次的代价摊到所有操作上,平均下来仍是常数。复杂度分析关心的是总量和平均,请求延迟看的却是每一次。那次 21 毫秒落在哪个请求上,那个请求就慢 21 毫秒。

本文用 JDK 21.0.5 测两件事:扩容的代价集中在哪几次操作上、预分配容量能不能消掉它;以及「元素少时顺序扫描比哈希查找快」这条经验在什么条件下成立。结果见文末配套实验。

一、均摊是把账摊平,每一笔并没有变小 ​

OpenJDK 21 的 ArrayList 在数组满了时分配一个 1.5 倍大的新数组,把旧元素复制过去(空列表第一次先分配 10 个)。HashMap 在元素数超过「容量 × 负载因子 0.75」时把表扩大一倍,所有节点重新分桶。按这两条规则算:

加入元素扩容次数累计复制或搬移
ArrayList,默认容量1,000 万362,769 万个引用,2.77 倍 N
HashMap,默认容量200 万18315 万个节点,1.57 倍 N

累计代价是 N 的常数倍,这就是「均摊 O(1)」。ArrayList 的文档也只承诺这一点,增长策略的细节没有规定,1.5 倍是当前实现。但这笔账是按几何级数分布的:每次扩容复制的是当前全部元素,最后一次最大,前面所有次数加起来也只和它同一个量级。

实测逐次记录每个 add 和 put 的耗时。为了只看复制本身,这两组用不回收的 Epsilon GC 并预先触碰整个堆,排除 GC 和缺页。每种写法跑 3 轮,取第 3 轮:

均值p99最大最慢一次在第几次超过 100 µs 的操作
ArrayList,默认容量26.0 ns42 ns2.41 ms9,230,100(扩容点)11 次
ArrayList,new ArrayList<>(n)24.3 ns42 ns0.05 ms不固定0 次
HashMap,默认容量86.0 ns250 ns21.27 ms1,572,864(rehash 点)8 次
HashMap,HashMap.newHashMap(n)78.0 ns209 ns0.44 ms0(见第三节)1 次

默认容量时,最慢的 10 次操作里,ArrayList 有 8 次落在扩容点上,HashMap 有 9 次落在 rehash 点上,剩下的是计时区间里的其他干扰。p99 完全看不出这些尖峰:200 万次操作里只有 8 次超过 100 微秒,占百万分之四。

05 ms10 ms15 ms20 ms0.066,1440.1312,2880.2824,5760.5249,1521.1498,3042.31196,6085.35393,21610.76786,43221.271,572,864均值 86 ns/次,贴着底线第几次 put 触发 rehash(下标从 0 起)
图 1 · 横轴是 rehash 发生在第几次 put(每一格表大小翻倍),柱高是这一次 put 的耗时。每次 rehash 都比上一次慢约一倍,最后一次 21.27 ms;全部 200 万次的均值只有 86 ns,在这个刻度上贴着底线。用 HashMap.newHashMap(n) 预分配后,最慢一次 0.44 ms

两种结构的尖峰量级差了近十倍。把最后一次的耗时除以当时的元素数,ArrayList 约每个引用 0.26 纳秒,HashMap 约每个节点 13.5 纳秒。前者是一整块连续内存的复制;后者要逐个访问节点、按哈希值的一个比特位拆到两个桶里,是跟着指针跳的随机访存。所以同样的元素数,HashMap 的扩容贵得多。这两个单价只是这台机器上的推算,用来比较量级,不要当成常数。

二、尖峰在什么场景里会变成问题 ​

预分配省下的总时间并不多:ArrayList 从 259.6 ms 降到 243.1 ms,HashMap 从 172.0 ms 降到 156.0 ms,都在一成左右。它真正改变的是最大值:ArrayList 从 2.41 ms 降到 0.05 ms,HashMap 从 21.27 ms 降到 0.44 ms。预分配主要是为了尾延迟,吞吐的收益是次要的。

尖峰要不要紧,看它落在谁身上:

  • 一次请求里从空开始建一个大集合,比如导出报表时把 200 万行聚合进一个 HashMap:这个请求本来就要跑一两百毫秒,扩容已经算在总耗时里,尖峰只是其中一段,不会额外拖慢别的请求。
  • 长期存活、在请求路径上持续增长的集合,比如进程内的本地索引、按 ID 缓存的会话表:哪个请求的写入碰上扩容,哪个请求就多等这一次复制的时间。集合越大,这一次越久,而且集合大到一定程度后,这种事一段时间才发生一次,很难从平均值或 p99 里看出来。
  • 扩容发生在锁里:如果集合用 synchronized 或 Collections.synchronizedMap 保护,扩容期间所有等这把锁的请求都跟着等。这是由机制推出的,本文没有单独测。ConcurrentHashMap 的文档同样提醒,扩容可能比较慢,能预估大小时应在构造时给出。
  • 实验排除了 GC:真实服务里,扩容还会分配一个新的大数组、留下一个旧的大数组,给 GC 增加压力。这部分的影响见 G1 与 ZGC。

所以尾延迟的来源不只是 GC 和网络。一个平均 O(1) 的操作,在它扩容的那一次里是 O(N) 的。

三、预分配:知道大小就告诉集合,但参数要看清 ​

java
List<Order> list = new ArrayList<>(expected);                 // 参数是容量
Map<Long, Order> byId = HashMap.newHashMap(expected);         // JDK 19+,参数是预期元素数
Map<Long, Order> wrong = new HashMap<>(expected);             // 参数是表容量,放满之前仍会扩容
Map<Long, Order> shared = new ConcurrentHashMap<>(expected);  // 参数是预期元素数

同样是一个 int 参数,含义不一样:

写法参数的含义放入 200 万个键时
new HashMap<>(2_000_000)表的初始容量,向上取 2 的幂表 2,097,152、阈值 1,572,864;放入第 1,572,865 个键时 rehash,实测 21.14 ms
HashMap.newHashMap(2_000_000)预期元素数,按负载因子算好容量不扩容,最慢一次 0.44 ms
new ConcurrentHashMap<>(2_000_000)预期元素数文档保证容纳这么多元素不需要扩容

new HashMap<>(n) 这一行最容易出错:它看上去已经预分配了,但只要 n 超过取整后容量的四分之三,最后还是要 rehash 一次,而且正好是最大的那一次。实测它的最大值和默认容量时一样,都在 21 毫秒左右。JDK 19 之后 HashMap(int) 的文档已经加了说明,要按预期元素数建表,应该用 newHashMap。JDK 19 之前的项目可以自己按 (int) Math.ceil(n / 0.75) 算容量,或者用 Guava 的 Maps.newHashMapWithExpectedSize。

预分配的 HashMap 最慢的一次出现在第 0 次 put:HashMap 的表是第一次放入时才分配的,构造时只记下容量。预分配没有让这 16 MB 的数组凭空消失,只是把分配挪到了第一个元素上,而且只有这一次。ArrayList(int) 则在构造时就分配数组。

不知道确切大小时,给一个偏大的估计也比不给好:多出来的只是一个空数组的内存,而少估了,最后一次扩容仍然会发生。已经创建好的 ArrayList 可以在批量加入前调用 ensureCapacity。

四、元素少时顺序扫描更快吗 ​

另一条常听到的经验是:键只有几个的时候,遍历数组比 HashMap 快,不用算哈希。实测 N 从 1 到 256,比较在 String[] 里逐个 equals 和 HashMap.get。查询字符串和表里的键是不同的实例,哈希值已经预先算好(String 会缓存哈希值,被多次使用的键大多是这种情况),每个 N 取 7 次测量的中位数:

N均匀查询:扫描均匀查询:HashMap90% 命中热键:扫描90% 命中热键:HashMap
12.83 ns5.83 ns2.87 ns6.04 ns
210.95 ns6.14 ns3.40 ns5.82 ns
415.98 ns6.14 ns4.55 ns5.86 ns
822.08 ns6.00 ns5.35 ns5.75 ns
1631.14 ns10.40 ns6.36 ns6.20 ns
6476.55 ns8.48 ns11.23 ns6.06 ns

查询在所有键上均匀分布时,这条经验只在 N=1 时成立,N=2 时扫描就慢了近一倍。90% 的查询命中第一个键时,扫描一直领先到 N=8,到 N=16 才被追上。换成 int 键和 int[] 扫描,形状相同:N=1 时扫描 2.06 ns、HashMap 3.78 ns,N=2 起 HashMap 更快。

08162432N=1N=2N=4N=8N=16N=32N=64N=32、64:46、77 ns,超出刻度均匀:N=2 起 HashMap 更快热键:N=16 交叉顺序扫描 · 均匀顺序扫描 · 90% 热键HashMap(实线均匀,虚线热键)
图 2 · 字符串键,单位 ns/次,纵轴截在 32 ns。均匀查询时 N=2 起 HashMap 就更快:命中位置随机,扫描循环何时退出无法预测。90% 查询命中第一个键时,扫描领先到 N=8,交叉点推迟到 N=16。HashMap 两条线几乎重合,与分布无关

N 从 1 变成 2,扫描只多了半次比较,耗时却从 2.8 纳秒跳到 11 纳秒。多出来的主要不是比较本身,而是分支预测失败:命中位置是随机的,CPU 猜不到循环会在第几次退出,猜错一次就要丢掉已经推测执行的指令。热键分布下,循环几乎总在第一次就退出,分支很好猜,扫描的优势才显现出来。这个解释是从两种分布的差别推断的,实验没有读 CPU 的分支计数器。

所以交叉点不是一个固定的 N,它取决于查询分布、键的比较成本和 CPU。不要因为「只有十几个元素」就把 HashMap 手工改成数组扫描;真要改,用线上的真实查询分布测一次。多数时候差别只有几纳秒,只有在调用量极大的热点路径上才值得考虑。

五、测这类问题时要注意的几点 ​

这组实验没有用 JMH,是为了拿到每一次操作的耗时,而不只是平均值。自己写计时代码时,以下几点决定了结果能不能信:

  • 计时分辨率:这台机器上 System.nanoTime() 的步长约 41 纳秒,单次操作的 p50 只能显示 0 或 41 ns。所以均值用总耗时除以次数得出,单次计时只用来找毫秒级的尖峰。
  • 先预热:每种写法跑 3 轮只取最后一轮,查找实验整组先跑一遍再测,避开解释执行和 JIT 编译的阶段。JIT 对测量的影响见 JIT 与逃逸分析,微基准的通用做法仍是 JMH。
  • 把要看的机制隔离出来:测扩容时用 Epsilon GC 和预触内存,否则 GC 停顿和缺页会混进结果。反过来,评估线上影响时要用真实的 GC 和负载,隔离环境里的数字不能直接搬过去。
  • 看分布,不只看平均:均值和 p99 都看不出 200 万次里只出现几次的尖峰。要看最大值、看最慢的几次发生在哪里,线上则看高分位和最大值的监控,分位数的用法见 从「能跑」到「可观测」。
  • 只信相对关系:尖峰的绝对值在多次运行间波动明显,ArrayList 最后一次扩容见过 2.4 到 10 毫秒。可以稳定复现的是:最慢的几次落在扩容点,预分配后最大值降一到两个数量级。

六、怎么用 ​

  1. 能预估大小的集合都给出大小:new ArrayList<>(n)、HashMap.newHashMap(n)、HashSet.newHashSet(n)、new ConcurrentHashMap<>(n);不要用 new HashMap<>(n) 表达「预期 n 个元素」。
  2. 重点检查长期存活、在请求路径上增长的集合:本地索引、缓存、注册表。它们的扩容会落在某个用户请求上,集合越大,那一次越慢。
  3. 不要只凭元素少就把哈希查找改成扫描:交叉点取决于查询分布,均匀分布时 N=2 就已经不划算。
  4. 排查偶发的毫秒级慢请求时,除了 GC 和网络,也看看有没有大集合在这时扩容;应用内埋点记录最大值,而不只是均值。
  5. 复杂度仍然是第一位的:N=256 时扫描比 HashMap 慢 20 倍,O(N) 与 O(1) 的差距在 N 变大后远超这些常数。常见的算法套路见 工程里用得上的十个算法套路。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权