在 Java 面试中,HashMap 相关的问题是面试官最喜欢考察的知识点之一,尤其是 HashMap 的哈希算法,以及由此引发的一个关键问题——"为什么 HashMap 每次扩容都是两倍?"。
对于初学者来说,理解哈希算法可能会有些困难。因此,本文将用 三个核心步骤 拆解 HashMap 的哈希计算逻辑,并从这一原理出发,深入探讨 HashMap 采用 2 倍扩容 的原因。希望你读完后能对这个问题有更深入的理解。
1. 三步理解HashMap的哈希算法
在理解扩容问题前,我们需要先理解HashMap的哈希算法,主要包括以下三步:
在分析 HashMap 的扩容机制之前,我们需要先弄清楚 HashMap 是如何计算存储索引的,这个过程主要包括以下三步:
(1) 计算哈希值:调用 hashCode()
当我们向 HashMap 插入键值对时,JDK 首先会调用 hashCode() 方法计算 key 的哈希值。例如:
String key = "HashMap";
int h = key.hashCode(); // 计算哈希码 h
hashCode() 返回一个 32 位的整数(范围:-2147483648 ~ 2147483647),理论上可能产生 40 亿种不同的 key。如果直接使用 hashCode() 作为数组索引,那就意味着数组的长度需要达到 40 亿,这显然是不现实的。因此,HashMap 并不会直接使用 hashCode(),而是对其进行 进一步处理,使其能映射到合适的数组索引。
(2) 计算索引:位运算取模 (n - 1) & hash
为了高效地确定存储位置,HashMap 采用 (n - 1) & hash 计算存储索引,而不是传统的 hash % n 取模计算。为什么呢?因为位运算 & 比取模运算 % 更高效,计算速度更快。
这里的 n 是 数组长度,假设 n = 16,则 n-1 = 15,其二进制为 1111。来看一个示例:
hash = 10000000 00100000 00001001
& mask = 00000000 00000000 00001111
-----------------------------------
index = 00000000 00000000 00001001 // 取最低 4 位,范围 0 ~ 15
由于 n-1 是一个全 1 的二进制数,& 运算可以确保索引始终落在 [0, n-1] 之间,从而保证 key 能够均匀分布在 HashMap 的桶(bucket)中。
(3) 扰动函数——高低位混合运算
HashMap 采用 (n - 1) & hash 来计算索引,而这个操作只依赖 hash 的低几位。如果哈希值的低位分布不均匀,就会导致哈希冲突,使多个 key 被映射到同一个索引位置,从而影响查询效率。
假设 hashCode() 计算出的值总是 00000000 00000000 00000000 XXXX(低 4 位分布不均匀),那么 (n-1) & hash 的计算结果也会出现不均匀分布,导致某些桶位过载,而其他桶位空置。
为了减少哈希冲突,JDK 采用 扰动函数(bitwise perturbation function),通过高低位混合提高散列均匀性:
hash = h ^ (h >>> 16);
其作用是:
h >>> 16:将h右移 16 位,取高 16 位的值。h ^ (h >>> 16):将h和高 16 位的值做 异或运算,混合高低位信息,使最终哈希值的低位部分更加均匀分布。
所以,经过上面的分析,HashMap背后的哈希算法可以总结为如下图所示的三步:
最终,计算得到的索引值为
0000 = 0,所以该键值对会存储在 HashMap 数组的索引 0 位置。
2. 为什么HashMap每次扩容都是2倍?
了解了 HashMap 的哈希计算原理后,我们再来看一个核心问题:为什么 HashMap 的容量总是 2 的幂,每次扩容都是 2 倍?
(1) 2 的幂让索引计算更加高效
HashMap 的容量 n 必须是 2 的幂,这样 n-1 在二进制中就会是全 1,比如:
n = 16,则n-1 = 15(1111)n = 32,则n-1 = 31(11111)
这样 (n-1) & hash 可以让哈希值的 低位最大化映射到不同的桶,保证数据均匀分布,减少哈希冲突。
如果 n 不是 2 的幂,比如 n = 10,那么 n-1 = 9,其二进制 1001。在 & 运算时,索引的取值范围只能是 0、1、8、9,导致 key 只能映射到部分桶,增大冲突概率,影响性能。
(2) 扩容时,索引要么保持不变,要么新增偏移
当 HashMap 触发扩容(默认容量 16,当元素个数超过 16 * 0.75 = 12 时触发扩容),会创建一个新的数组,其大小是原来的 2 倍,并重新计算元素的存储索引。扩容后,索引分布变得更加均匀,进一步减少哈希冲突。
举个例子,假设有两个哈希值:
hash1 = 0000 0000 0000 0000 0000 0000 0000 1010 // 低 5 位:01010
hash2 = 0000 0000 0000 0000 0000 0000 0001 1010 // 低 5 位:11010
我们计算它们在 扩容前(n=16)和扩容后(n=32) 的索引:
扩容前 (n=16, n-1=15 (00001111))
index1 = hash1 & 00001111 = 0000 1010 & 0000 1111 = 1010 (10)
index2 = hash2 & 00001111 = 0001 1010 & 0000 1111 = 1010 (10)
- 两个
hash计算出的索引都是 10,意味着它们原本会发生哈希冲突。
扩容后 (n=32, n-1=31 (00011111))
index1 = hash1 & 00011111 = 0000 1010 & 0001 1111 = 1010 (10)
index2 = hash2 & 00011111 = 0001 1010 & 0001 1111 = 11010 (26)
hash1的第 5 位是0,索引仍然是10。hash2的第 5 位是1,索引从10变成26(10 + 16)。
扩容后,元素的索引计算方式依旧是 (n-1) & hash,但由于 n-1 变大了,索引会有 两种情况:
- 如果哈希值的低 5 位是
0,索引保持不变。 - 如果哈希值的低 5 位是
1,索引在原索引基础上 +16。
所以HashMap 扩容后,元素的位置要么保持不变,要么偏移 +n,迁移效率更高,查询速度更快。