Java 8 HashMap 扩容解析

24 阅读7分钟

1. 底层数据结构:数组 + 链表 + 红黑树

HashMap 的内部核心是一个 Node<K,V>[] 数组,通常称为哈希桶数组。每个数组元素(桶)可能包含以下三种状态:

  • :尚未存放任何键值对。
  • 链表:当多个键值对哈希冲突时,以链表形式串联在同一个桶中。链表节点 Node 包含 hashkeyvalue 和指向下一个节点的 next 指针。
  • 红黑树:当链表长度超过阈值(默认 8)且数组容量达到 64 时,链表转换为红黑树,以优化查找性能。树节点 TreeNode 是 Node 的子类。

这种设计结合了数组的随机访问优势和链表的动态扩展能力,同时利用红黑树保证了极端情况下的查询效率。


2. 哈希冲突的解决

2.1 链地址法

当两个不同的 key 通过哈希函数计算后落在同一个数组索引时,就发生了哈希冲突。HashMap 采用链地址法:将新节点追加到该桶链表的尾部(Java 8 后改为尾插法,避免死循环)。查找时,先定位到数组索引,再遍历链表通过 equals() 比较 key 找到目标值。

2.2 扰动函数:减少冲突

为了降低哈希冲突的概率,HashMap 在计算哈希值时对 key.hashCode() 做了二次处理:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这段代码将哈希值的高 16 位与低 16 位进行异或运算(称为扰动函数),使高 16 位信息也能参与后续的索引计算,从而让哈希分布更均匀。

2.3 寻址算法:替代取模

确定键值对在数组中的索引时,HashMap 没有使用取模运算 %,而是采用更高效的位与运算:

index = (n - 1) & hash   // n 为数组长度

该等式成立的前提是数组长度 n 必须是 2 的幂。因为 n - 1 的二进制全是低位 1,与哈希值进行位与等价于取模,但性能更高。

2.4 红黑树优化

当链表过长时,查找性能会从 O(1) 恶化到 O(n)。为此,Java 8 引入了红黑树:

  • 树化条件:链表长度 ≥ 8 数组容量 ≥ 64。
  • 退化条件:红黑树节点数 ≤ 6 时,退化为链表。阈值错开(8 和 6)是为了避免频繁转换。

树化阈值选择 8 是基于泊松分布的统计结果:在理想随机哈希码下,链表长度达到 8 的概率极低(小于千万分之一),因此树化是一种应对极端情况的“保底”策略。


3. 扩容机制

3.1 触发条件

  • 初始化:首次 put 元素时,若数组未初始化,会触发一次扩容(分配默认容量 16)。
  • 容量不足:当元素个数 size 超过 阈值 threshold = 容量 * 负载因子 时触发扩容。默认负载因子为 0.75,是空间与时间的折中。

3.2 扩容过程

  1. 创建新数组:新容量为旧容量的 2 倍(保持 2 的幂)。
  2. 数据迁移:将旧数组中的所有键值对重新分配到新数组。这个过程没有简单地重新计算每个元素的哈希值,而是采用了高效的链表拆分技术。

3.3 链表拆分优化

在遍历旧数组的每个桶时,对于链表结构的节点,HashMap 会将其拆分为两条新链表,分别放置在新数组的不同位置。核心判断代码是:

if ((e.hash & oldCap) == 0) {
    // 保持原索引
} else {
    // 新索引 = 原索引 + oldCap
}
  • 若 (e.hash & oldCap) == 0,说明哈希值在 oldCap 对应的位上为 0,新索引不变。
  • 否则,哈希值在该位上为 1,新索引 = 原索引 + 旧容量。

这一优化避免了重新计算所有节点的哈希值,仅通过一次位运算即可确定新位置,并且保持了链表节点的相对顺序(尾插法),解决了 JDK 1.7 中头插法可能导致的死循环问题。

下图展示了链表拆分的迁移过程(假设旧容量为 16,新容量为 32):

HashMap节点拆分.png

这里再详细说一下:

  • 假设扩容从16->32 就是从0000 1000->0001 0000
  • 计算index的时候,就是e.hash & (16-1) -> e.hash & (32-1)
  • 假如原本的hash后8位为0001 0001
    • 计算index,e.hash & (16-1) = e.hash & 0000 1111 -> 其实就是与最后4位
    • 计算index,e.hash & (32-1) = e.hash & 0001 1111 -> 其实就是与最后5位
    • 由于计算中,你的e.hash是要给固定值,所以计算的时候,你能看到 16和32的差值是16,刚刚好是0001 0000,也就是扩容的倍数。
  • 扩容的时候怎么做的的呢?
    • 通过e.hash & 16 == 0,判断当前的node是去新的链表还是旧的链表
    • 就是说与运算的时候刚刚好是 e.hash & 00001 0000 等于0的,留在旧桶的index,大于0的,则index+16
    • 回到扩容的计算,32-1 = 00001 1111, e.hash & 00001 1111,其中0000 1111与运算的结果,和扩容前是一样的,0001 0000 与出来的结果,是0或者16(落在新的链表还是旧的链表),而扩容,就是原本的长度2,也就是2^4x2 = 2^5
    • 0或者16,是不是很熟悉?(通过e.hash & 16 != 0,判断当前的node是去新的链表还是旧的链表)这也是为什么hashmap扩容的时候,一定要是2的N次幂。

3.4 为什么高效?

  • 避免重新取模:利用位运算直接推导出新旧索引的关系,无需重新计算哈希值。
  • 保持顺序:尾插法确保链表节点顺序不变,避免并发问题。
  • 红黑树拆分:对树节点也采用类似逻辑,通过高低位拆分。

4. 扩容后 get 方法如何正确寻址?

一个常见的疑问是:扩容后数组长度变长,掩码从 oldCap-1 变为 newCap-1(多了一位),get 方法计算索引时会不会找不到元素?

答案是否定的,因为 get 方法与扩容时的判断逻辑完全一致,都基于相同的位运算规则。我们通过一个具体例子来说明:

假设扩容前容量为 16(掩码 1111),某哈希值 hash = ... 1 0101(低 5 位为 10101,第 5 位为 1)。

  • 原索引 = hash & 1111 = 0101 = 5。
  • 扩容时,由于 (hash & oldCap) = (hash & 10000) = 10000 != 0,因此该元素被移动到新数组的 5 + 16 = 21 位置。

扩容后容量为 32,掩码为 11111。当调用 get(key) 时,重新计算索引:

  • 新索引 = hash & 11111 = 10101 = 21。

恰好是元素实际存放的位置。对于第 5 位为 0 的哈希值,新索引仍为原索引。因此,get 方法使用当前数组长度计算出的索引,与扩容时元素移动的目标位置天然一致,无需额外标记。


5. 总结

Java 8 对 HashMap 的重构,使其在保持原有高效性的同时,显著提升了最坏情况下的性能:

  • 数据结构:数组 + 链表 + 红黑树,结合了随机访问和动态扩展的优势。
  • 哈希冲突解决:链地址法 + 扰动函数 + 红黑树优化,兼顾常规与极端场景。
  • 扩容机制:容量翻倍 + 链表拆分,通过位运算避免重新哈希,保持顺序,提升迁移效率。
  • 定位准确性get 方法与扩容逻辑统一,确保寻址无误。

理解这些底层原理,不仅能帮助我们在开发中更合理地使用 HashMap,还能在遇到性能问题时快速定位和优化。希望本文能为你揭开 HashMap 的神秘面纱,让你在日常编码中更加游刃有余。