我们来用通俗易懂的方式解释一下 Java 8 中 HashMap
的原理,让你对它的结构、运行机制有清晰的理解。
🌳 什么是 HashMap?
HashMap
是 Java 中非常常用的数据结构,用于存储键值对(key-value)。你可以把它理解成一个“大数组”,每个位置可以存储一个或多个数据。
🧱 HashMap 的基本结构
Java 8 中的 HashMap 底层由 数组 + 链表 + 红黑树 组成:
HashMap└── 数组 table[] ← 每个元素称为“桶”├── 链表/树 ← 存储实际的 key-value 节点(Node)
🛠️ 核心工作流程
我们来一步步看 HashMap 是怎么工作的:
✅ 插入 put(key, value)
举个例子:
map.put("apple", 100);
背后做了这些事:
-
计算 hash 值:
- 对 key(“apple”)调用
hashCode()
,然后做了一些扰动处理。 - 比如
"apple".hashCode()
= 93029210,经过扰动后变成一个新的 hash 值。
- 对 key(“apple”)调用
-
计算数组索引:
index = hash & (table.length - 1)
,定位到数组的某一桶。
-
判断该桶是否有元素:
-
如果没有:直接放进去。
-
如果有:判断 key 是否相同。
- 相同:替换旧值。
- 不同:链表/红黑树追加新节点。
-
-
链表转红黑树(Java8 新特性):
- 当同一个桶内元素数量超过 8 个 且数组长度超过 64,会把链表转成红黑树,提高查询效率。
🔍 查询 get(key)
比如:
map.get("apple");
-
计算 hash。
-
定位到数组索引。
-
在对应的桶里找:
- 是链表:顺序比较 key。
- 是红黑树:用树的方式查找。
🔄 发生了哈希冲突怎么办?
多个不同的 key 计算后可能落到同一个数组位置(桶),这就叫 哈希冲突。Java 8 中:
- 前几个冲突的 key 用 链表 挂在一起;
- 如果冲突太多(链表长度 > 8 且数组长度 > 64),链表就会转成 红黑树,加快查找速度。
📈 扩容机制
HashMap 的默认容量是 16,装载因子是 0.75,当元素数量 > 16 * 0.75 = 12 时,就会发生 扩容:
- 扩容为原来容量的 2 倍。
- 重新计算所有 key 的 hash 值和位置,迁移元素。
这也是为什么我们建议在使用 HashMap 时 预估容量大小,以避免多次扩容影响性能。
🧠 为什么要用红黑树?
因为链表查找是 O(n),而红黑树是 O(log n),当冲突严重时,查询效率会大幅下降。引入红黑树可以在极端情况下保持较好的性能。
🔥 小结图示:
插入:hash → 定位数组桶 → 空就放,有就比较 → 链表 / 红黑树
查询:hash → 定位 → 链表/树中查找 key
扩容:元素太多 → 数组翻倍 → 元素重新分布
✅ Java 8 中 HashMap 的优点
特性 | 优点说明 |
---|---|
引入红黑树 | 避免链表过长导致查找变慢 |
扰动函数优化 | 减少哈希冲突,分布更均匀 |
结构清晰 | 数组 + 链表 + 红黑树,灵活扩展 |
我们来用通俗易懂的语言 + 举例方式,深入讲讲 Java 8 中 HashMap
的 扰动函数(hash 扰动函数),帮助你真正理解它的作用和原理。
一、什么是扰动函数?
✅ 定义一句话:
扰动函数是为了让 hash 值的高位信息也能参与数组下标的计算,从而让 key 更均匀地分布在 HashMap 的数组中。
二、为什么需要扰动函数?
🚨 问题:低位碰撞严重!
在 HashMap 中,定位数组下标的方式是这样的:
index = hash(key) & (table.length - 1)
由于 table.length
是 2 的幂,这样的 &
操作会只保留 hash 值的低位。
➡️ 如果两个 key 的 hash 值只有高位不同,而低位相同,它们就会落入同一个桶(发生冲突)。
🧠 举个例子:
hash1 = 0b10110000_00000000_00000000_00000001
hash2 = 0b01010000_00000000_00000000_00000001
虽然高位差很多,但最后的几位一样。由于我们只用低位参与数组下标计算,这两个 key 会落在同一个位置!
✅ 解决方案:扰动函数
通过扰动函数把 高位信息混合到低位,避免冲突更均匀。
三、Java 8 中的扰动函数源码分析
HashMap
中源码如下(简化):
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
🚀 解释:
h = key.hashCode()
:拿到 key 的原始 hashCode(32 位整数)。h >>> 16
:无符号右移 16 位,把高位移动到低位。^
:做异或运算,把原来的高位和低位混合起来,让高位也参与到最终结果中。
四、通俗举例讲解
假设我们有两个 key:
key1.hashCode() = 0b10110000_00000000_00000000_00000001
key2.hashCode() = 0b01010000_00000000_00000000_00000001
它们的低位是一样的,都会落到相同数组位置,发生 hash 冲突。
有了扰动函数:
扰动结果 = h ^ (h >>> 16)
扰动后:
key1
的高位会被移到低位并参与混合;- 即便低位一样,只要高位不同,扰动后结果就不一样。
这样就打乱了 hashCode 的原始规律,使得分布更随机,降低 hash 冲突概率。
五、总结(口诀记忆)
- 🚩 低位计算索引,高位白白浪费。
- 🧠 扰动函数混高低,分布均匀更合理。
- ✅ 异或右移搞起来,冲突少,效率高。
六、动手试一试(简单代码示例)
public class HashDisturbExample {public static void main(String[] args) {String key = "apple";int h = key.hashCode();int disturbed = h ^ (h >>> 16);System.out.println("原始 hashCode: " + h);System.out.println("扰动后结果: " + disturbed);}
}
运行后你会看到两个不同的值,这说明扰动函数确实改变了 hash 值结构,帮助分布更均匀。
✅ 第一个问题:为什么 table.length
是 2 的幂?
🔧 原因:
为了 优化取模(mod
)操作为位运算(&
)操作,提升性能!
🧠 解释:
在 HashMap
中,我们要根据 key 的 hash 值决定存放的位置,也就是数组下标:
index = hash(key) % table.length
如果 table.length 是任意数字,比如 10,那就必须使用 除法运算 %
,这是一个比较慢的操作。
而如果 table.length 是 2 的幂,比如 16、32、64,我们就可以写成:
index = hash(key) & (table.length - 1)
⚠️ 这行代码的效果和 %
是一样的,但效率更高!
🧠 举例说明:
假设 table.length = 16(即 2⁴),那么 16 的二进制是:
16 = 10000(即 2 的 4 次方)
16 - 1 = 15 = 01111(二进制)
这样做 & 15
相当于取 hash 值的 低 4 位,效果等同于 hash % 16
,而且更快!
✅ 第二个问题:为什么 &
的操作和 %
是一样的?
🎯 只有在 table.length
是 2 的幂时,才成立!
hash % 16 == hash & (16 - 1)
这是因为 2 的幂次减 1 得到的是全 1 的二进制位数。比如:
table.length | 二进制 | table.length - 1 | 二进制 |
---|---|---|---|
8 | 1000 | 7 | 0111 |
16 | 10000 | 15 | 01111 |
32 | 100000 | 31 | 011111 |
当你对一个数 x
执行 x & (n - 1)
,就等于 取 x 的低几位,这等价于 x % n
(当 n 是 2 的幂时)。
✅ 举个例子:
hash = 0b101010 // 十进制 42
table.length = 16 // 2⁴
mask = 16 - 1 = 15 = 0b111142 % 16 = 10
42 & 15 = 0b101010 & 0b1111 = 0b1010 = 10 ✅
结果一样,但是 &
运算效率远高于 %
运算。
✅ 第三个问题:为什么是“低位”参与运算?
📌 因为 hash & (table.length - 1)
只保留 低位信息!
你刚刚也看到了,举个例子:
hash = 0b10110000_00000000_00000000_00001101
table.length = 16 (即 2⁴),mask = 0b00000000_00000000_00000000_00001111
执行 &
:
hash & mask =
0b10110000_00000000_00000000_00001101
&
0b00000000_00000000_00000000_00001111
=
0b00000000_00000000_00000000_00001101 => 13
➡️ 所以只有 低 4 位被保留下来,高位被舍弃了!
🤔 那高位的 hash 值不是白计算了吗?
是的,如果不做处理,高位信息就浪费了,这就是为啥我们需要:
🚀 扰动函数:
h ^ (h >>> 16)
它会把高 16 位的信息也混入低 16 位中,确保高位不会被浪费。
✅ 总结归纳:
问题 | 解答 |
---|---|
为什么 table.length 是 2 的幂? | 为了将取模 % 优化成更快的 & 位运算。 |
为什么 & 可以代替 % ? | 当除数是 2 的幂时,x & (n - 1) 等价于 x % n ,而效率更高。 |
为什么是低位参与? | 因为 & 操作只保留了低位的信息(如 & 15 保留低 4 位),高位信息被忽略。 |
🧠 为什么要扩容?
HashMap
是通过数组 + 链表(或红黑树)来存储键值对的。它的性能依赖于:
键值对在数组中的分布是否均匀(冲突少)
如果 HashMap
存放的元素太多,冲突越来越多,链表变长,性能会急剧下降,所以需要:
扩容(resize):将原数组变大,把原来的元素“重新分布”到新的数组中。
⚙️ HashMap 扩容时机
HashMap 会在满足以下条件时扩容:
当前元素个数 > 阈值(threshold) = 容量 × 负载因子(loadFactor)
- 默认初始容量是
16
- 默认负载因子是
0.75
- 所以默认第一次扩容是在元素数量超过
16 × 0.75 = 12
时发生
🚀 扩容做了什么?
当发生扩容时,HashMap
会做几件事:
- 将数组容量扩大为 原来的两倍
- 创建一个新的数组(新的 table)
- 重新计算每个键值对的索引(index)
- 将所有旧元素 “搬家” 到新数组中
📊 举个例子来说明
假设你有一个容量为 4
的 HashMap,插入了 3
个元素(已接近 0.75 的阈值):
原数组(长度4):
index 0: [A -> 1]
index 1: [B -> 2]
index 2: [C -> 3]
index 3: null
你再插入一个元素 D -> 4
,此时元素数目为 4,超过 4 × 0.75 = 3
,触发扩容。
扩容过程:
- 数组变为长度
8
- 所有键的
hash
会重新计算 index - 每个键值对会根据新的 index 放到新数组中,分布更均匀
扩容后数组(长度8):
index 0: null
index 1: [A -> 1]
index 2: null
index 3: [C -> 3]
index 4: null
index 5: [B -> 2]
index 6: [D -> 4]
index 7: null
这样就避免了原来在小数组中“拥挤”的问题。
🔁 元素“搬家”的优化:位运算重分布
Java 8 做了一个优化,在扩容时 不再重新计算 hash 值,而是通过如下逻辑判断元素是留在原位置还是移动到“新位置”:
// 假设 oldCap 是原数组长度,newCap = oldCap * 2
if ((e.hash & oldCap) == 0) {// 留在原地
} else {// 移动到原位置 + oldCap
}
这个技巧利用了 2 的幂次
的特性,通过 hash & oldCap
直接判断是否需要挪动,提高了效率!
💡 为什么扩容不是一开始就做得很大?
为了节省内存。HashMap 的容量和负载因子设计是为了在性能和内存之间做权衡。
✅ 总结:HashMap 扩容机制
步骤 | 内容 |
---|---|
扩容时机 | 元素个数 > 阈值(容量 × 负载因子) |
扩容操作 | 数组变为原来的两倍 |
元素处理 | 每个元素根据新数组重新定位(高效位运算) |
优化手段 | hash & oldCap 判断是否搬家 |
目的 | 降低 hash 冲突,提高查询性能 |