HashMap 扩容机制与 Rehash 细节分析
HashMap 是 Java 中常用的键值对存储结构,其底层基于数组加链表(JDK 1.8 后引入红黑树)的实现。为了应对元素数量增加,HashMap 提供了动态扩容机制。本文将深入分析其扩容过程及 rehash 的细节,并对比 JDK 1.7 和 JDK 1.8 的不同实现。
一、扩容机制概述
HashMap 的容量(capacity)是指底层数组的大小,默认初始值为 16。当元素数量超过某个阈值(threshold = capacity × loadFactor,默认负载因子为 0.75)时,HashMap 会触发扩容,将数组容量翻倍(例如从 16 变为 32),并重新分配所有已有元素到新数组中,这个过程称为 rehash。
扩容的核心步骤:
- 创建新数组:新数组大小为旧数组的两倍。
- rehash:将旧数组中的每个元素重新计算哈希值,映射到新数组的位置。
- 转移数据:将旧数组中的链表(或红黑树)迁移到新数组。
二、JDK 1.7 的扩容与 rehash
在 JDK 1.7 中,HashMap 的实现相对简单,底层仅使用数组加链表。
扩容过程
当 put 操作导致元素数量超过阈值时,调用 resize()
方法:
- 新数组大小为旧数组的 2 倍。
- 调用
transfer()
方法,将旧数组的元素转移到新数组。
rehash 细节
JDK 1.7 的 rehash 使用的是头插法,具体步骤如下:
- 对每个键的 hash 值与新数组长度减 1(newCapacity - 1)进行位与运算,计算新位置。
- 公式:
index = hash & (newCapacity - 1)
。
- 公式:
- 将旧链表的节点逐个取出,按计算出的新位置插入新数组对应桶中。
- 使用头插法插入,即新节点成为链表的头部,原链表接在其后。
问题:链表逆序与死循环
- 链表逆序:由于头插法,转移后链表顺序会与原来相反。例如,原链表为 A -> B -> C,转移后变为 C -> B -> A。
- 多线程隐患:在并发环境下,头插法可能导致链表形成环。例如,线程 1 扩容到一半时,线程 2 也开始扩容,可能出现节点互相指向,形成死循环。这是 JDK 1.7 HashMap 非线程安全的一个著名问题。
三、JDK 1.8 的扩容与 rehash
JDK 1.8 对 HashMap 进行了优化,引入红黑树(当链表长度超过 8 时转换),并改进了扩容和 rehash 的实现。
扩容过程
扩容依然通过 resize()
方法完成:
- 新数组大小为旧数组的 2 倍。
- 根据元素类型(链表或红黑树)分别处理转移逻辑。
rehash 细节
JDK 1.8 的 rehash 摒弃了头插法,改为尾插法,并利用了容量翻倍的特性优化计算:
- 位置计算优化 :
- 容量翻倍后,新数组的索引计算基于一个巧妙的规律:对于旧数组中的每个键,其在新数组中的位置要么保持不变,要么加上旧数组容量(oldCap)。
- 判断方法:检查 hash 值与 oldCap 的位与结果(
hash & oldCap
)。- 如果结果为 0,新位置与旧位置相同。
- 如果结果为 oldCap,新位置 = 旧位置 + oldCap。
- 链表拆分 :
- 将旧链表拆分为两条链表:低位链表(loHead)和高位链表(hiHead)。
- 低位链表留在原位置,高位链表移到新位置。
- 使用尾插法保持链表顺序不变。例如,原链表 A -> B -> C,转移后仍是 A -> B -> C。
- 红黑树处理 :
- 如果桶中是红黑树,先将其拆分为链表,再按链表逻辑拆分,最后在新位置判断是否转换回红黑树。
代码示例(简化版)
java
// JDK 1.8 resize 方法中的核心逻辑
Node<K,V>[] oldTab = table;
int oldCap = oldTab.length;
int newCap = oldCap << 1; // 容量翻倍
Node<K,V>[] newTab = new Node[newCap];
for (int j = 0; j < oldCap; j++) {
Node<K,V> e = oldTab[j];
if (e != null) {
oldTab[j] = null;
if (e.next == null) { // 单个节点
newTab[e.hash & (newCap - 1)] = e;
} else { // 链表或红黑树
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
do {
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 = e.next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
优势
- 顺序保持:尾插法避免了链表逆序。
- 线程安全改进:虽然 HashMap 仍非线程安全,但消除了死循环风险。
- 性能优化:利用位运算和容量翻倍特性,减少了重复计算。
四、JDK 1.7 与 JDK 1.8 的对比
特性 | JDK 1.7 | JDK 1.8 |
---|---|---|
数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
插入方式 | 头插法 | 尾插法 |
rehash 计算 | 每次重新计算 | 利用 oldCap 优化 |
链表顺序 | 逆序 | 保持原序 |
并发风险 | 可能死循环 | 无死循环风险 |
五、总结
HashMap 的扩容机制和 rehash 细节在 JDK 1.7 和 JDK 1.8 中有显著差异。JDK 1.7 的头插法简单但存在逆序和并发死循环问题,而 JDK 1.8 通过尾插法和位运算优化,不仅提高了性能,还增强了稳定性。理解这些细节有助于开发者在实际应用中更好地使用 HashMap,并避免潜在的坑点。
如果你对 HashMap 的其他特性(如红黑树转换、哈希冲突)感兴趣,欢迎进一步探讨!