一、前提说明
本文讨论的扩容特指 HashMap 完成初始化之后,因新增元素导致 size 超过阈值而触发的扩容过程,不包含第一次创建内部数组的情况。JDK 7 与 JDK 8 在这一时机的处理逻辑存在根本性差异,背后的设计思想也大不相同。
二、JDK 7:先扩容,再插入
2.1 执行流程
JDK 7 在插入新节点前,会进行一个双重判断:
- 当前元素数量是否已达到扩容阈值(
size >= threshold); - 待插入的新节点所在的桶是否已有元素(即是否发生哈希冲突)。
只有两个条件同时满足时,才会先执行扩容,再将新节点插入扩容后的空数组。
如果目标桶为空,即使 size 已经达到阈值,也不会立即扩容,而是直接把新节点放进空桶。
// JDK 7 插入逻辑简化示意
if (size >= threshold && table[bucketIndex] != null) {
resize(); // 扩容
bucketIndex = indexFor(hash, table.length); // 重新计算下标
}
addEntry(...); // 插入新节点
2.2 优点
- 避免新节点重复迁移
新节点不会先放入旧数组、再马上参与数据迁移,减少了不必要的搬运开销。 - 空桶场景下的内存节省
当新节点落入空桶时暂不扩容,可以在哈希冲突较少时延迟数组分配,一定程度上节约内存。
2.3 缺点
- 扩容规则不够统一
是否触发扩容不仅取决于size和threshold,还与目标桶是否为空相关。两个容量相同、元素数量相同的 HashMap,可能仅仅因为新节点落入的桶不同,一个触发扩容而另一个不扩容。这导致扩容行为不易预测,负载因子的语义也被弱化。 - 插入流程复杂化
提前扩容后,由于数组长度改变,需要重新计算新节点的存储位置,增加了插入路径的复杂度。
三、JDK 8:先插入,确认新增后再扩容
3.1 执行流程
JDK 8 将扩容判断后置:
-
先完成桶内查找与插入(可能是链表追加或红黑树插入);
-
若本次插入确实是一个新键 (非覆盖旧值),则
size加 1; -
若插入后的
size > threshold,触发扩容。// JDK 8 插入逻辑简化示意
Node<K,V> e = ...; // 在桶中完成查找/插入
if (e == null) { // 新增键
++size;
if (size > threshold)
resize();
}
afterNodeInsertion(evict); // 回调
3.2 优点
1. 扩容条件更清晰、统一
是否扩容完全由 size > threshold 决定,不再依赖新节点是否落入空桶。负载因子成为真正意义上的"容量饱和度"指标,语义明确,行为可预测。
2. 仅新增键时触发扩容
如果调用 put 的 key 已存在,仅仅覆盖旧值,size 保持不变,自然也不会引发扩容,避免了无意义的数组重建。
3. 与红黑树机制无缝配合
JDK 8 引入了"链表转红黑树"的优化。采用"先完成桶内部操作,再统一处理树化、size 更新和扩容判断"的流程,代码结构更加一致,逻辑内聚,便于维护。
4. 扩容迁移代价降低,使后置扩容可行
JDK 8 的迁移算法做了关键优化:因为数组容量按 2 倍扩展,节点在新数组中的下标只有两种可能------保持原下标 或 原下标 + 旧容量 。
只需判断节点哈希值中与 oldCap 对应的那一位,即可将原链表拆分为"高位链"和"低位链",无需重新计算完整哈希下标。
因此,即使新节点刚刚插入旧数组就立即参与一次迁移,额外成本也远低于 JDK 7,这使得"先插入后扩容"的设计在性能上完全可接受。
// JDK 8 扩容拆分示意
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
for (Node<K,V> e = oldTab[j]; e != null; e = e.next) {
if ((e.hash & oldCap) == 0) {
// 保留在原下标
} else {
// 移动到 原下标 + oldCap
}
}
3.3 缺点
- 临界场景下的重复迁移
新节点可能刚刚放入旧数组,就因为扩容被再次迁移,发生一次"无用搬运"。 - 不再因空桶而延迟扩容
JDK 8 丢弃了"目标桶为空则不扩容"的优化,只要 size 超过阈值就会立即扩容,可能比 JDK 7 更早分配更大的数组,在内存敏感的场景下略显激进。
四、设计取舍总结
JDK 7 与 JDK 8 在扩容时机上的差异,本质上是一组设计取舍:
- JDK 7 偏向局部优化
尽量避免新节点的重复迁移,同时在冲突较少时通过延迟扩容来节省内存。代价是扩容条件复杂化、行为不一致,代码逻辑耦合度高。 - JDK 8 追求全局统一与可维护性
用一次可能的额外迁移,换取了:- 扩容规则的纯粹与统一
- 负载因子语义的严格保证
- 与红黑树机制的和谐共生
- 更清晰的代码结构与更可预测的性能表现
因此,不能简单地说 JDK 8 的"先插入再扩容"就一定更快。更准确的理解是:正是因为 JDK 8 优化了迁移算法(2 倍扩容下标二选一),并引入了红黑树等新结构,"先插入、确认 size 增加后再统一扩容"才成为更合适的设计选择。 这也是 JDK 在不断演进中,根据内部机制的变化,对同一问题给出的不同最优解。
五、对比一览
| 维度 | JDK 7 | JDK 8 |
|---|---|---|
| 扩容时机 | 插入前,size≥阈值且桶非空 | 插入后,新增键且 size>阈值 |
| 扩容规则 | 依赖哈希冲突情况,不够统一 | 纯粹依赖 size 与阈值,统一清晰 |
| 新节点迁移 | 避免重复迁移 | 可能刚插入就迁移一次 |
| 空桶行为 | 可延迟扩容,节省内存 | 不再延迟,size 超阈值必扩容 |
| 迁移算法 | 重新计算所有节点下标 | 只需拆分高位/低位链表 |
| 代码复杂度 | 插入路径分支多 | 逻辑统一,树化与扩容分离 |
| 配合机制 | 纯链表 | 链表 + 红黑树 |
理解这些差异,有助于我们在不同的应用场景中更合理地评估 HashMap 的性能表现,同时也体现了 JDK 设计团队在性能、语义与可维护性之间的精妙平衡。