HashMap 扩容原理分析
HashMap 扩容原理深度分析
一、引言
HashMap 作为 Java 集合框架中最常用的实现类之一,其高效的查找和插入性能使其在各类 Java 应用中被广泛使用。HashMap 的高性能很大程度上归功于其动态扩容机制,该机制能够在数据量增长时自动调整内部结构,维持哈希表的查询效率。本文将从底层原理出发,深入分析 HashMap 的扩容机制,包括触发条件、执行流程、版本差异及性能影响。
二、HashMap 基础结构
2.1 核心数据结构
HashMap 在 JDK8 及以上版本采用 "数组 + 链表 + 红黑树" 的复合结构:
- 数组(哈希桶):作为主体存储结构,每个元素是一个 Node 节点引用
- 链表:用于处理哈希冲突,存储哈希值相同的元素
- 红黑树:当链表长度超过阈值时转换而成,优化查询性能
2.2 关键参数
HashMap 的扩容行为由以下关键参数控制:
参数 |
含义 |
默认值 |
作用 |
capacity |
哈希表容量,即数组长度 |
16 |
决定哈希表的存储规模 |
loadFactor |
负载因子 |
0.75 |
控制哈希表的填充程度 |
threshold |
扩容阈值 |
12 |
触发扩容的临界值,计算公式:capacity × loadFactor |
size |
实际存储的键值对数量 |
0 |
记录当前元素总数 |
这些参数共同决定了 HashMap 的扩容时机和行为特征。
三、扩容触发机制
HashMap 的扩容并非随时进行,而是在特定条件下触发,主要有以下两种情况:
3.1 常规扩容触发
当 HashMap 中新增元素后,若元素总数 (size) 超过当前阈值 (threshold),则触发扩容。这是最常见的扩容触发场景,其核心判断逻辑如下:
// JDK8中putVal()方法的扩容判断 if (++size > threshold) resize(); |
3.2 树化前扩容
当链表长度达到树化阈值 (8),但数组容量小于最小树化容量 (64) 时,HashMap 会先进行扩容而非直接树化。这一设计的原因是:在小容量数组下,哈希冲突更可能是由于数组规模不足导致的,通过扩容可以更有效地解决冲突,而不是引入红黑树的复杂性。
树化前扩容的核心逻辑在 treeifyBin () 方法中:
final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; // 数组容量不足64时,优先扩容 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); // 否则进行树化处理 // ... } |
四、扩容完整流程解析
HashMap 的扩容操作主要在 resize () 方法中实现,完整流程可分为四个阶段:计算新容量和阈值、创建新数组、迁移元素和更新引用。
4.1 计算新容量和新阈值
这一阶段根据原数组的状态计算新的容量和阈值,处理逻辑如下:
int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; // 处理已有容量的情况 if (oldCap > 0) { // 若已达最大容量则不再扩容 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // 新容量为原容量的2倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // 新阈值也为原阈值的2倍 newThr = oldThr << 1; } // 处理初始化时指定了阈值的情况 else if (oldThr > 0) newCap = oldThr; // 处理默认初始化情况 else { newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 补全新阈值计算(针对特殊场景) if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; |
从代码可以看出,HashMap 的容量始终保持为 2 的幂次方,这是一个关键设计,为后续的位运算优化奠定了基础。
4.2 创建新数组
计算出新容量后,HashMap 会创建一个新的数组,作为扩容后的存储载体:
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; |
4.3 元素迁移(核心步骤)
元素迁移是扩容过程中最复杂也最关键的步骤,需要将原数组中的所有元素重新分布到新数组中。JDK8 对这一过程进行了重要优化,大幅提升了迁移效率。
if (oldTab != null) { // 遍历原数组 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; // 释放原数组引用,帮助GC
// 情况1:单个节点(无链表) if (e.next == null) newTab[e.hash & (newCap - 1)] = e;
// 情况2:红黑树节点 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 情况3:链表节点(JDK8优化点) else { // 低位链表:新索引 = 原索引 Node<K,V> loHead = null, loTail = null; // 高位链表:新索引 = 原索引 + 原容量 Node<K,V> hiHead = null, hiTail = null; Node<K,V> next;
// 遍历链表,按高位信息分组 do { next = e.next; // 关键优化:通过哈希值与原容量的位运算判断新位置 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null);
// 将低位链表放入新数组原索引位置 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } // 将高位链表放入新数组原索引+原容量位置 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } |
4.4 迁移优化深度解析
JDK8 中元素迁移的核心优化是基于位运算的分组迁移策略,其原理如下:
- 索引计算原理:
由于 HashMap 的容量始终是 2 的幂次方,新容量是原容量的 2 倍,即 newCap = oldCap << 1。
原索引计算:(oldCap - 1) & hash
新索引计算:(newCap - 1) & hash = (oldCap << 1 - 1) & hash
- 高位判断机制:
新索引与原索引相比,多了一个最高位的判断。JDK8 通过(hash & oldCap)来判断这一位是 0 还是 1:
-
- 若结果为 0,新索引与原索引相同
- 若结果不为 0,新索引为原索引加上 oldCap
- 优势分析:
- 无需重新计算哈希值,减少了计算开销
- 通过一次位运算即可确定新位置,效率高
- 保持了链表的原有顺序,避免了 JDK7 中头插法可能导致的循环链表问题
五、JDK7 与 JDK8 扩容机制对比
JDK7 和 JDK8 中的 HashMap 扩容机制存在显著差异,主要体现在以下几个方面:
特性 |
JDK7 |
JDK8 |
数据结构 |
数组 + 链表 |
数组 + 链表 + 红黑树 |
扩容触发时机 |
先判断是否需要扩容,再插入元素 |
先插入元素,再判断是否需要扩容 |
链表插入方式 |
头插法(迁移后链表顺序反转) |
尾插法(保持原链表顺序) |
元素迁移方式 |
重新计算哈希值确定新位置 |
基于位运算分组迁移,无需重新计算哈希 |
红黑树支持 |
不支持 |
支持红黑树拆分迁移 |
并发问题 |
可能产生循环链表 |
避免循环链表,但仍非线程安全 |
初始化逻辑 |
单独的 init 方法 |
整合在 resize 方法中 |
这些差异使得 JDK8 的 HashMap 在扩容效率和安全性上都有了显著提升。
六、扩容对性能的影响
扩容是一个开销较大的操作,对 HashMap 的性能有显著影响,主要体现在以下几个方面:
6.1 时间成本
扩容过程需要遍历所有元素并进行迁移,时间复杂度为 O (n)。频繁扩容会导致 HashMap 的性能下降,特别是在数据量较大的情况下。
6.2 空间成本
扩容后数组容量翻倍,内存消耗也相应增加。负载因子设置得越小,HashMap 扩容越频繁,空间利用率也就越低。
6.3 性能优化建议
- 合理设置初始容量:
根据预期存储的元素数量,设置合适的初始容量可以有效减少扩容次数。
计算公式:initialCapacity = (int)(expectedSize / 0.75) + 1
- 选择合适的负载因子:
对于读操作频繁的场景,可以适当降低负载因子,减少哈希冲突;
对于内存受限的场景,可以适当提高负载因子,提高空间利用率。
- 使用稳定的哈希值:
避免使用哈希值不稳定的对象作为 key,防止扩容后哈希值变化导致的问题。
- 批量插入优化:
对于批量插入操作,可以先调用 resize () 方法预扩容,避免插入过程中多次扩容。
七、总结
HashMap 的扩容机制是其维持高效性能的关键设计,通过动态调整容量来平衡哈希冲突和查询效率。本文深入分析了 HashMap 扩容的触发条件、完整流程和核心优化,对比了 JDK7 和 JDK8 中扩容机制的差异,并探讨了扩容对性能的影响及优化建议。
理解 HashMap 的扩容原理,有助于开发者在实际应用中更好地使用这一数据结构,避免因不当使用导致的性能问题,从而编写出更高效、更稳定的 Java 程序。
本文来自博客园,作者:诸葛匹夫,转载请注明原文链接:https://www.cnblogs.com/shenxingzhuge/p/19069743
卡里离冰冷的40个亿还差39多个亿