1. 底层数据结构:数组 + 链表 + 红黑树
HashMap 的内部核心是一个 Node<K,V>[] 数组,通常称为哈希桶数组。每个数组元素(桶)可能包含以下三种状态:
- 空:尚未存放任何键值对。
- 链表:当多个键值对哈希冲突时,以链表形式串联在同一个桶中。链表节点
Node包含hash、key、value和指向下一个节点的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 扩容过程
- 创建新数组:新容量为旧容量的 2 倍(保持 2 的幂)。
- 数据迁移:将旧数组中的所有键值对重新分配到新数组。这个过程没有简单地重新计算每个元素的哈希值,而是采用了高效的链表拆分技术。
3.3 链表拆分优化
在遍历旧数组的每个桶时,对于链表结构的节点,HashMap 会将其拆分为两条新链表,分别放置在新数组的不同位置。核心判断代码是:
if ((e.hash & oldCap) == 0) {
// 保持原索引
} else {
// 新索引 = 原索引 + oldCap
}
- 若
(e.hash & oldCap) == 0,说明哈希值在oldCap对应的位上为 0,新索引不变。 - 否则,哈希值在该位上为 1,新索引 = 原索引 + 旧容量。
这一优化避免了重新计算所有节点的哈希值,仅通过一次位运算即可确定新位置,并且保持了链表节点的相对顺序(尾插法),解决了 JDK 1.7 中头插法可能导致的死循环问题。
下图展示了链表拆分的迁移过程(假设旧容量为 16,新容量为 32):
这里再详细说一下:
- 假设扩容从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 的神秘面纱,让你在日常编码中更加游刃有余。