1. 一次线上事故引发的思考:HashMap 的 key 分布到底怎么算的
先把话说在前面:HashMap 的底层原理,几乎是 Java 后端面试的必问题,同时也是线上性能隐患的高发区。我有一次处理过一个真实案例——一个接口在流量上来之后突然从 5ms 涨到 2000ms,查到最后,问题就出在一个 HashMap 上。更准确地说,是出在那个 HashMap 选择 key 的方式上,大量 key 被映射到了同一个桶位,查询直接退化成链表遍历,几百个节点串在一个桶里,性能自然就崩了。
那次排查让我重新梳理了一遍 HashMap 的索引计算逻辑。很多人看完源码就只记得一句话:"HashMap 用 (n - 1) & hash 计算下标。" 但几乎没人能立刻回答出三个追问:
- hash 到底是什么?
- 为什么下标要对数组长度减一取与运算?
- 扰动函数在这个流程里到底在做什么?
这篇文章就用一个完整的链路把这些问题一次性摊开讲清楚。你不需要背八股文,只需要跟着推演一遍索引从 key 到数组下标的完整旅程,就能彻底理解为什么 JDK 的开发者要设计扰动函数,也知道以后在项目里定义自己的 key 类型时,hashCode 到底该怎么写才不容易踩坑。
先说结论,避免后面被细节绕晕:
HashMap 定位一个 key 的桶位,经历了两步。第一步是计算 key 的 hashCode,然后让高位参与运算,这就是扰动函数的作用。第二步是把扰动后的 hash 与数组容量减一进行按位与,得到最终的数组下标。
这两步缺一不可,第二步是为了让下标均匀分布,第一步则是为了"救"回第一步里被浪费的高位信息。听起来有点绕,下面慢慢拆。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么非要 (n - 1) & hash:把取模变成位运算的数学基础
很多教材讲 HashMap 下标计算时,会直接扔给你一行代码:
java复制index = (n - 1) & hash
然后告诉你"这等价于 hash % n,前提是 n 是 2 的幂"。这句话没错,但绝大多数人没想通一件事:为什么 HashMap 的容量永远要设计成 2 的幂?直接用取模不也行吗?
其实直接把 hash % n 作为下标,逻辑上完全没问题。但取模操作在 CPU 层面是相对昂贵的运算,而按位与是几乎零成本的。既然底层数组的长度被固定为 2 的幂次方,那么 n - 1 的二进制形式就一定是"低位全 1"的形态,比如容量 16,n - 1 = 15,二进制是 1111。此时拿 hash 和 1111 做按位与,结果就是 hash 的低四位,等效于 hash % 16。
用数字验证一下。假设 hash 是 28,二进制是 11100:
code复制 11100
& 1111
--------
01100 = 12
而 28 % 16 = 12,结果完全一致。
再假设 hash 是 18,二进制是 10010:
code复制 10010
& 1111
--------
00010 = 2
18 % 16 = 2,依然一致。
所以"容量是 2 的幂"不是一个可有可无的巧合,而是把取模替换成位运算的前提条件。HashMap 的初始容量 16 就是 2^4,你如果通过构造函数传参指定容量,源码里也会调用 tableSizeFor 把你传的数值强行转成最接近的 2 的幂。
这种方式在绝大多数场景下表现优秀,但它隐藏了一个致命短板:下标的取值完全取决于 hash 的低位,高位就算差异再大,只要低位相同,最后都会落进同一个桶。
举个例子。hash 分别是 16 和 48,在容量 16 的情况下:
code复制16 的二进制:10000,低位4位是 0000
48 的二进制:110000,低位4位也是 0000
和 1111 做按位与之后,下标都是 0。这两个不同 key 就被塞进了同一个桶位。如果 key 的 hashCode 设计得不好,大量 hashCode 的低位相同,那这些 key 就会全部堆积在一个桶里,HashMap 直接退化成一个链表。这也就是引子里面线上接口从 5ms 涨到 2000ms 的原因——我没有在一开始控制 key 的 hashCode 分布,导致 HashMap 里某个桶挂了上千个节点。
这也是为什么后面必须引入扰动函数。如果说 (n - 1) & hash 是"用位运算代替取模"的工程优化,那扰动函数就是针对这个方案缺陷的补救措施。
3. 扰动函数的真面目:从 JDK 7 到 JDK 8 的演进
这里先把源码摆出来。JDK 8 里的 hash 方法是这么写的:
java复制static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
总共两件事:
- 拿到 key 的 hashCode,赋值给 h。
- 让 h 和 h 右移 16 位后的值做异或。
右侧无符号右移 16 位,等于把高 16 位挪到了低 16 位的位置上,然后通过异或运算,让原始 hashCode 的高位特征能够影响到最终 hash 值的低位。这样一来,即使某个 key 的 hashCode 高半区差异很大、低半区完全一致,扰动后的结果也会出现明显区分。
我实际算一组数给你看。假设有两个 key,hashCode 分别是 0xFFFF0000 和 0xFFFF1234,两个低 16 位不同,但在容量 16 时,只看低 4 位,结果都一样是 0:
code复制0xFFFF0000 & 1111 = 0
0xFFFF1234 & 1111 = 0
扰动之后呢?先看第一个:
h = 0xFFFF0000,h >>> 16 = 0x0000FFFF,两者异或得到 0xFFFFFFFF。
再看第二个:
h = 0xFFFF1234,h >>> 16 = 0x0000FFFF,异或结果变成了 0xFFFEEDCB。
此时再看低 4 位,第一个是 1111,第二个是 1011,下标截然不同。本来会挤在同一个桶里的两个 key,因为扰动函数的介入被分散开了。这就是扰动函数存在的全部意义:在不增加太多运算成本的前提下,让高位信息参与低位计算,降低碰撞概率。
顺嘴提一下 JDK 7 的做法,因为很多人面试被问到"JDK 7 和 JDK 8 的扰动有什么不同"时会卡住。JDK 7 的 hash 方法做了四次右移和异或:
java复制static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
看明白了就知道,JDK 8 把扰动简化成了一次右移加一次异或。原因是经过实际测试,一次 16 位的右移异或已经能达到足够的散列效果,而 JDK 7 那种四次运算,在 CPU 层面的多余消耗超过了它能换来的那一丁点分布改善。加上 JDK 8 引入了红黑树(后面细说),单个桶即使碰撞变多,最坏情况下的性能上限也提升了不少,所以原本针对链表退化而做的重度扰动,就没有必要了。
这里要专门回应一个常见的误区:"扰动函数是解决 hash 冲突的。"严格来说这个说法不完全准确。扰动函数是降低 hash 冲突概率的预防措施,而不是冲突发生之后的兜底方案。 真正处理"冲突已经发生了怎么办"的,是下面说的链表和红黑树机制。扰动函数做的是"尽量让你们别撞上",链表和树做的是"你们既然已经撞上了,我想办法让你们住得下、找得着"。
4. 冲突发生之后:链地址法、树化和扩容的配合
扰动函数降低了碰撞概率,但没办法完全消除碰撞。HashMap 采用的冲突解决策略是链地址法——每个桶位存放的是一个链表的头节点,撞上来的 key 就依次往链表后面挂。查询的时候,先定位桶,然后在链表里逐个比较 key 的 equals 结果。
这里需要区分两个概念:
- hashCode 决定你进哪个桶。
- equals 决定你在桶内链表中是否命中。
所以 Java 里有一条硬性约定:两个对象 equals 相等,hashCode 必须相等;但 hashCode 相等的两个对象,equals 可以不等。这条约定在 HashMap 的查找逻辑中是根基。你自定义一个类作为 key,如果只重写 equals 不重写 hashCode,或者故意让 equals 相等但 hashCode 不同,HashMap 就永远找不到这个 key 了。
继续往下,链地址法在 JDK 8 里加了一个重要升级:当单个桶位链表长度超过阈值 8 时,链表会转换成红黑树。 阈值是 8,不是 7、不是 10,这个数字背后有统计推断支撑。
链表查询的时间复杂度是 O(n),红黑树是 O(log n)。当链表长度是 8 时,线性查找最多比较 8 次,红黑树查找最多比较 4 次左右,看起来差别不大。但长度上涨到几十甚至上百时,差别就极其恐怖了。JDK 官方注释里给了一个数据:在随机 hashCode 的分布下,某个桶位链表长度达到 8 的概率约为千万分之六。也就是说,正常场景下,链表根本长不到 8,一旦长到 8 了,说明 hashCode 分布已经严重异常,这个时候再用红黑树兜底,至少能守住最坏情况不退化到 O(n)。
不过树化不是永久的。当 HashMap 发生扩容、元素被重新散列之后,如果某个桶里的树节点数量降到 6 以下,红黑树又会转换回链表。为什么是 8 转树、6 转回链表,中间留出 7 的缓冲?因为如果树化阈值和退化阈值相同,元素数量在 7 和 8 之间晃动时,就会频繁触发树化和退树的转换,白白消耗性能。留一个缓冲带,是典型的延迟策略。
树化和扩容是两条不同的性能防线:
| 机制 | 触发条件 | 作用 |
|---|---|---|
| 扰动函数 | 每次 put 计算 hash 时 | 让高位参与低位运算,降低碰撞概率 |
| 链地址法 | 碰撞发生后 | 在桶内挂链表,解决多 key 共存问题 |
| 树化 | 单桶链表长度 > 8 | 防止链表过长导致查询退化为 O(n) |
| 扩容 | 元素数量超过容量 × 负载因子 | 让数组变大,重新散列,从根上减少冲突 |
扩容的条件是 元素数量 > 容量 × 负载因子。默认负载因子是 0.75,容量是 16 时,元素数到 12 就会触发扩容。扩容后容量翻倍为 32,所有元素重新计算下标分布到新的数组上。这也是为什么 HashMap 不推荐设置过小的初始容量——如果预知要放 1000 个元素却用默认容量,会经历多次扩容和 rehash,开销很大。
我自己的建议是:如果知道大概的数据量,初始化时直接给一个够用的容量,计算公式是 所需容量 / 负载因子 + 1,再用 tableSizeFor 转成 2 的幂。比如要存 1000 个元素,1000 / 0.75 + 1 ≈ 1334,向上取最近的 2 的幂是 2048。这样既省了扩容次数,还控制了下标分布的均匀性。
5. 实战排查:什么样的 key 设计会让 HashMap 变成"链表地狱"
光讲原理不够,我拿一个真实项目的例子复盘一遍。这个接口的业务逻辑大致是把一批用户 ID 映射到对应的订单列表上,代码长这样:
java复制Map<Long, List<Order>> userOrderMap = new HashMap<>();
for (Order order : orders) {
userOrderMap.computeIfAbsent(order.getUserId(), k -> new ArrayList<>()).add(order);
}
从常规角度看,这段代码没有任何问题,key 是 Long 类型,hashCode 是 JDK 内置的,分布天然均匀。但问题是这个 userId 不是业务主键,而是一个雪花算法生成的 ID。雪花算法的结构是:1 位符号位 + 41 位时间戳 + 10 位机器码 + 12 位序列号。
表面上看,Long 的 hashCode 返回的就是它自身的数值,这没问题。但当 MySQL 端为了兼容某些老系统,把 ID 强制做了一次数字类型转换,低位出现大量连续相同值时,问题就来了:在这种 ID 序列里,许多 userId 的二进制高位不同,但低位完全相同。 容量较小时,下标算只看低位,这些 ID 就会撞进同一个桶。接口流量上来后,那个桶的链表长度快速增长,HashMap 就被拖垮了。
排查链路大致是这样的:
- 先用
jmap -histo:live确认 HashMap 实例数量正常,排除对象泄漏。 - 再在出现性能瓶颈的代码段前后打点,发现耗时就集中在
computeIfAbsent这一行。 - 用
jstack抓线程栈,确认大量线程阻塞在 HashMap 的putVal方法里。 - 通过日志和抽样,发现触发的桶位高度集中在某几个固定下标的数组桶中。
- 进一步对 userId 做二进制分析,确认冲突根因是低位分布不均匀。
- 最终修改策略:不再直接用原始 userId 作为 key,而是封装成一个内部 Key 对象,重写 hashCode,在原始值基础上做了一次"二次散列"。实际做法参考
Long.hashCode的味道,先右移再异或,把高位差异显式拉进低位参与计算。改完之后,同容量下这个桶的链表长度从接近百位数降到了个位数。
这个案例最有参考价值的地方在于:内置类型的 hashCode 设计是合理的,但当 HashMap 容量远小于 hashCode 的理论取值范围时,低位分布会成为决定性因素。 扰动函数能把一部分高位差异带到低位,但它的能力不是无限的。如果你的业务 ID 刚好存在"高位差异大、低位趋同"的规律,单靠内置扰动可能依然不够,需要在自定义 key 的 hashCode 层面再做一层处理。
所以,自定义 key 类型时有几条很实际的建议:
- 永远遵守 equals 和 hashCode 的约定,这是底线。
- 不要直接返回某个字段值当 hashCode,除非你确定它在各个位上都足够分散。
- 多字段组合时,用 31 作为乘数的经典写法是有道理的,31 是奇素数,乘法可以被 JVM 优化成移位和减法,碰撞率在实测中表现良好。
- 如果业务 key 有明显规律,先用样本数据跑一遍
hashCode & (capacity - 1),看看分布是否均匀,再决定要不要加自定义扰动。
6. 和面试官聊 HashMap 时的回答框架,以及一个容易忽略的细节
你如果正在准备面试,我给一个可以直接套用的回答框架,这和面向源码的八股文不同,更偏向"真的理解"的路径:
确定桶位分两步。第一步是计算 key 的 hashCode,并通过扰动函数把高位信息混合到低位,JDK 8 用的是 h ^ (h >>> 16),一次右移一次异或。第二步是把扰动后的 hash 和容量减一做按位与,因为容量是 2 的幂,所以这等价于取模运算但性能更好。碰撞发生之后,HashMap 把多个 key 挂在同一个桶位的链表上,链表过长时树化成红黑树来守住最坏情况的复杂度。当元素数达到容量乘以负载因子时,触发扩容,容量翻倍,所有元素重新散列。
这个框架把"为什么有容量是 2 的幂""扰动函数解决什么问题""冲突之后怎么办""扩容是什么"四条线全部串起来了,比单纯背一句"数组加链表加红黑树"要有说服力得多。
还有一个容易忽略但面试官很喜欢追问的细节,就是"为什么 HashMap 允许 null 作为 key,而且永远存在下标 0 的桶里"。看到源码里这行你就明白了:
java复制return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
key 为 null 时,hash 直接返回 0,0 和任何数做按位与都是 0,所以 null 这个 key 永远落在数组的 0 号桶位。如果你问"多个 null key 会怎样",答案是 null key 永远只有一个,后 put 的会覆盖先 put 的,equals 判断全部返回 true。这种设计其实就是"null 也是一种合法 key,给个固定桶位省得每次都要特判"。
7. 干扰动函数性能账:一次异或和一次右移,换来多少收益
最后把这笔账算清楚。很多人觉得扰动函数是 JDK 8 才开始加的,其实不对——它在 JDK 8 中简化了,但思想从 JDK 1.4 开始就有雏形。如果它真有那么大的性能负担,JDK 团队不会把它保留到现在。
算一笔直接的成本:
- 一次
h >>> 16,即无符号右移,CPU 单周期操作。 - 一次
^异或,同样是单周期操作。 - 加上取 hashCode 本身的开销,整体 add 操作多出的成本可以忽略不计。
收益呢?在没有扰动的情况下,如果 key 的 hashCode 低 4 位完全趋同,容量 16 的 HashMap 中 100 个 key 会有大量元素挤在同一个桶。引入扰动后,高位特征进入低位,冲突率明显下降。尤其是从 JDK 7 到 JDK 8 的简化,正是权衡了"分布均匀性收益"和"多轮运算成本"之后给出的最优解。
我还做过一次本地压测小实验,跑了 100 万个由"固定前缀 + 递增序号"组成的字符串 key,分别测试带扰动和不带扰动的下标计算,确认了两点:
- 带扰动后,桶长度的标准差明显小于不带扰动,说明分布更均匀。
- 不带扰动时,确实存在个别桶长度异常偏大的情况,这正是性能瓶颈的火种。
这个实验不严谨,但基本能验证扰动函数在"key 有规律性"时带来的收益。所以,下次再有人告诉你"HashMap 的 hash 方法就是个位运算小技巧",你可以把里面的门道说给他听。它不是什么高深的算法,但它是哈希表工程化设计里非常典型的一手决策——用两个 CPU 周期,换整个数据结构在异常输入下的稳健性。
