前言:从原料到模具的精密加工
在上一篇章中,我们深入剖析了 HashMap 源码中的"魔法数字",理解了 2 的幂次方设计、0.75 的加载因子以及 8 与 6 的树化防抖动机制。这些常量构成了 HashMap 运转的静态基石。
然而,HashMap 是一个动态生长的有机体。当我们调用 put() 方法时,一个普通的 Key 是如何在底层经过重重计算,最终精准地落入某个"桶"中的?当哈希冲突不可避免时,HashMap 又是如何在"扩容"与"树化"之间做出最优决策的?
本篇章将带你进入 HashMap 的动态世界,彻底讲透哈希扰动函数、下标计算的完整链路,以及那个在面试中最容易踩坑的"树化前置扩容"反直觉场景。
第四章:扰动函数------让高位信息参与战斗的艺术
4.1 为什么不能直接使用 hashCode()?
在计算元素存放位置之前,HashMap 并没有直接使用 Key 的 hashCode(),而是进行了一次看似神秘的"扰动":
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
【深度追问:为什么要多此一举?】
hashCode() 返回的是一个 32 位的整数。但在实际运行中,HashMap 的数组长度通常很小(比如默认的 16,甚至扩容后也只有 64)。我们在计算下标时,使用的是 hash & (n - 1),这意味着只有 hashCode 的最低几位参与了运算,高位信息被直接丢弃了。
如果两个 Key 的 hashCode 只有高位不同,低位完全相同,那么在数组较小的时候,它们就会发生严重的哈希冲突。
4.2 异或右移 16 位的精妙之处
【通俗讲解:高低位的"基因重组"】
(h = key.hashCode()) ^ (h >>> 16) 的操作,是将 32 位的 hashCode 无符号右移 16 位 (把高 16 位移到低 16 位的位置),然后与原始的 hashCode 进行按位异或(^)。
这就相当于:把高位的特征,"混合"到了低位中 。
通过这种"基因重组",即使数组很短,只取低位计算下标,也间接地保留了高位的信息。这极大地降低了因为低位相似而导致的哈希冲突概率。
4.3 为什么偏偏是右移 16 位?
【定义】
因为 Java 的 int 类型正好是 32 位。右移 16 位,恰好是将一个 32 位的整数对半劈开,高 16 位与低 16 位进行异或。这是在不增加额外计算开销的前提下,让尽可能多的高位信息参与低位运算的最佳选择。
第五章:下标计算------从 Key 到桶位置的完整推演
经过扰动函数处理后,我们得到了一个高质量的 hash 值。接下来就是定位桶的位置。
5.1 核心公式:hash & (n - 1)
正如我们在第一篇中所述,这个公式利用了 2 的幂次方的特性,将取模运算优化为了位运算。
【实证推演:同一个 Key,不同数组长度,桶位置完全不同】
假设 key.hashCode() 扰动后的 hash 值为 99162322,二进制为:
...0101 1110 1001 0010 0110 1001 0011 0010
- 当 n = 16 :n-1 =
0000 1111。取最低 4 位0010,桶下标为 2。 - 当 n = 32 :n-1 =
0001 1111。取最低 5 位10010,桶下标为 18。 - 当 n = 64 :n-1 =
0011 1111。取最低 6 位010010,桶下标为 18。 - 当 n = 128 :n-1 =
0111 1111。取最低 7 位0110010,桶下标为 50。
【结论】
这清晰地展示了"数组长度在哪一步介入":hashCode() 只是提供了"原始素材",数组长度 n 决定了如何从这块素材中"截取"有效的下标信息。这也是为什么 HashMap 在扩容后必须重新计算所有元素的位置(rehash)------因为 n 变了,掩码变了,下标自然就变了。
第六章:双重触发机制------扩容与树化的独立逻辑
这是 HashMap 中最容易混淆的部分。很多开发者认为"桶满了就扩容"或"链表长了就变树",这都是片面的。实际上,HashMap 内部有两套完全独立、各司其职的触发机制。
6.1 机制一:数组扩容(Resize)------看"总量"
- 触发条件 :
size > threshold(即size > capacity * loadFactor) - 核心逻辑:扩容是为了解决"整体太拥挤"的问题。哪怕某个桶挤爆了,只要整体元素个数还没超过阈值,数组就不需要变大。
6.2 机制二:链表转红黑树(Treeify)------看"局部"
- 触发条件 :必须同时满足 两个条件:
- 单个桶的链表长度
>= TREEIFY_THRESHOLD (8) - 数组长度
>= MIN_TREEIFY_CAPACITY (64)
- 单个桶的链表长度
- 核心逻辑:树化是为了解决"局部哈希冲突太严重"的问题。但如果数组本身还很小,官方认为"冲突严重可能是因为数组太小导致的",所以优先选择扩容而不是树化。
6.3 经典反直觉场景:桶内 > 8 但数组 < 64 怎么办?
这是我们对话中重点讨论的场景,也是面试中最容易踩坑的点:
问题:当数组中一个桶的元素大于 8,这时候又进来一个同桶元素,其他桶都是空的,会扩容吗?会树化吗?
答案:不会常规扩容,也不会立即树化,而是触发"树化前置扩容"。
【推演表】
假设 new HashMap<>(16),然后疯狂 put 哈希值相同的 key:
| 放入第几个 | size | 数组长度 | 桶内结构 | 发生了什么? |
|---|---|---|---|---|
| 1~8 | 8 | 16 | 链表(8) | 正常放入,不扩容,不树化 |
| 9 | 9 | →64 | 链表(9) | size=9 < 12 不触发常规扩容 ;但链表≥8 且 cap<64 → 触发resize()将数组扩到64 |
| 10+ | 10+ | 64 | 取决于rehash | 扩容后元素被分散,大概率不再集中在一个桶 |
【源码验证】
java
if (binCount >= TREEIFY_THRESHOLD - 1) { // 链表长度达到8(binCount从0计数)
if (tab.length < MIN_TREEIFY_CAPACITY) { // 数组长度 < 64
resize(); // 【关键】只扩容到64,不树化!
} else {
treeifyBin(tab, hash); // 数组>=64 才真正转红黑树
}
}
6.4 设计哲学
人话版:
- 要不要扩大房间(数组扩容) ?看房间里总共住了多少人。
- 要不要把某个隔间改成高级套房(树化) ?看这个隔间里挤了多少人,并且房间总面积得够大才行。如果房间本身就小,优先扩大房间,而不是急着改套房。
能用扩容解决的冲突,就绝不轻易用红黑树。 因为数组太小才是冲突的"真凶",扩容后 rehash 很可能直接化解冲突,从根本上避免树化的开销。红黑树是"最后手段",只有空间给够了还挤,才不得不动用这把"重武器"。
结语与后续预告
在第二部分的讲解中,我们彻底打通了 HashMap 的动态运转机制。从扰动函数的"基因重组",到下标计算的"模具截取",再到扩容与树化的"双重触发机制",我们看到了 HashMap 在性能与空间上的极致权衡。
但这依然不是终点。在后续的**《HashMap 核心原理全解(讲解三)》**中,我们将继续深入,为您详细拆解以下核心知识点:
- JDK 8 扩容机制的终极优化 :为什么扩容时不再重新计算 Hash,而是通过
hash & oldCap来决定元素的新位置? - 红黑树的退化机制:当树节点数减少到 6 时,HashMap 是如何优雅地将其退回链表的?
- 并发安全与死循环问题:为什么 JDK 7 的尾插法在多线程扩容时会形成环形链表导致死循环?JDK 8 又是如何修复这个致命 Bug 的?
- HashMap 与 ConcurrentHashMap 的定位策略对比:后者如何在保证线程安全的同时复用这套 2 的幂定位机制?