Java-HashMap浅析
本文旨在拆解 Java
HashMap
解决哈希冲突的机制,深入到每个步骤和设计考量。核心围绕 桶数组 (Buckets)、链表 (Chaining)、红黑树 (Treeify) 和 扩容 (Resizing) 展开
核心机制深度剖析:
底层结构:桶数组 (
Node<K,V>[] table
)- 本质: 一个固定长度的数组(初始化后长度可动态扩容),数组的每个元素称为一个 桶 (Bucket)
- 初始容量: 默认为
16
(DEFAULT_INITIAL_CAPACITY
),可指定。容量总是 2 的幂次方(如 16, 32, 64…)。这是为了高效使用位运算(n - 1) & hash
代替取模hash % n
- 桶的状态:
null
:该桶当前没有存储任何键值对Node
对象:该桶存储了一个键值对节点。这个Node
可能是:- 一个 单节点:该桶目前只有一个键值对(理想情况)
- 一个 单向链表 (Singly Linked List) 的头节点:该桶有多个键值对,通过链表连接
- 一个 红黑树 (Red-Black Tree) 的根节点 (
TreeNode
):该桶链表过长已转为树(JDK8+)
定位桶:哈希计算与索引 (
index = (n - 1) & hash
)- 步骤:
- 计算原始键的哈希码:
int h = key.hashCode()
。这是Object.hashCode()
方法返回的值。重写equals
必须重写hashCode
的根源! 相等的对象 必须 返回相等的哈希码,否则会导致同一个逻辑对象被映射到不同桶或无法覆盖 - 扰动函数 (JDK 8+ 优化):
hash = h ^ (h >>> 16)
- 目的: 让原始哈希码
h
的 高 16 位 参与到后续的低位运算中。 - 原因: 当桶数组
n
较小时(如初始 16),取模掩码(n-1)
的有效位很少(例如0b1111
只有低 4 位)。如果多个键的哈希码只在 高位 不同,低位完全相同,那么(n-1) & h
的结果就会相同(造成低位冲突)。扰动将高位特征“扩散”到低位,增加了低位的变化性,显著减少了这种高位不同低位相同导致的冲突概率
- 目的: 让原始哈希码
- 计算桶索引:
index = (table.length - 1) & hash
- 利用
table.length
(n
) 是 2 的幂次方,n-1
的二进制形式是低位连续1
(如16-1=15=0b1111
) &
操作相当于保留hash
值的低k
位(k = log2(n)
),效果等同于hash % n
,但效率更高(位运算 vs 除法)
- 利用
- 计算原始键的哈希码:
- 结果: 这个
index
决定了键值对(key, value)
应该存储在哪个桶table[index]
中
- 步骤:
哈希冲突的发生
- 定义: 当两个不同的键
key1
和key2
满足(table.length - 1) & hash1 == (table.length - 1) & hash2
时,它们会被定位到同一个桶索引index
。无论hash1
和hash2
是否相等(即使不等也可能映射到同一索引),它们都需要存储在同一个桶table[index]
中。这就是 哈希冲突
- 定义: 当两个不同的键
解决冲突:分离链接法 (Separate Chaining)
- 核心思想: 允许一个桶存储多个键值对。当冲突发生时,将新的键值对添加到该桶对应的 链式数据结构 中
- JDK 7 及之前:纯链表 (头插法)
- 每个桶
table[index]
指向一个Entry<K,V>
链表的头节点 - 插入冲突元素: 新元素插入到链表 头部 (头插法)。优点是插入快 (O(1))
- 问题:
- 并发不安全下死链: 在多线程并发扩容时,头插法可能导致环形链表,造成
get
操作无限循环(JDK 8 改为尾插法解决了此问题) - 性能退化: 链表过长时查找效率 O(n)
- 并发不安全下死链: 在多线程并发扩容时,头插法可能导致环形链表,造成
- 每个桶
- JDK 8 及之后:链表 + 红黑树 (尾插法)
- 每个桶
table[index]
指向一个Node<K,V>
链表的头节点 或 一棵TreeNode
红黑树的根节点 - 插入冲突元素 (链表阶段):
- 遍历该桶对应的链表
- 如果找到
key
相等的节点(p.key == key || (key != null && key.equals(p.key))
),则更新其value
- 如果遍历完链表未找到,则创建一个新的
Node
,并插入到链表 尾部 (尾插法) - 插入后检查:树化条件 (treeifyBin)
- 检查当前链表长度
binCount
>=TREEIFY_THRESHOLD
(默认 8) - 检查桶数组总长度
table.length
>=MIN_TREEIFY_CAPACITY
(默认 64) - 两个条件同时满足,则调用
treeifyBin(tab, hash)
方法将该链表 转换为红黑树 - 为什么需要两个条件?
binCount >= 8
:表示该桶冲突严重table.length >= 64
:表示哈希表整体规模足够大。如果表很小(比如只有 16),优先考虑 扩容 (resize) 来分散冲突,而不是立即树化(因为扩容可能更有效解决该桶冲突)。如果表很小但强行树化,扩容时拆树开销大
- 检查当前链表长度
- 树化 (
treeifyBin
):- 略
- 树的操作 (
putTreeVal
,getTreeNode
,removeTreeNode
):- 当操作发生在树化的桶上时,调用对应树方法
- 退化 (Untreeify):
- 在对树进行删除操作 (
removeTreeNode
) 后,会检查当前树中的节点数size
- 如果
size <= UNTREEIFY_THRESHOLD
(默认 6),则调用untreeify
方法将红黑树 转换回普通链表 - 为什么退化阈值 (6) < 树化阈值 (8)? 这是为了避免在链表长度在 7-8 之间频繁增删时,发生 树化<->退化 的 反复震荡 (thrashing)。设置一个 2 的缓冲区间(6-8)增加了稳定性,减少转换开销
- 在对树进行删除操作 (
- 每个桶
扩容 (Resize) - 根本性减少冲突
- 目的:
- 降低负载因子 (Load Factor): 减少单个桶的平均元素数,降低冲突概率
- 分散冲突: 将原本冲突严重的桶中的元素重新分配到更多桶中
- 维持 O(1) 均摊时间: 核心目标
- 触发条件:
size
(当前键值对总数) >threshold
(阈值)threshold = capacity * loadFactor
- 默认
loadFactor = 0.75f
(DEFAULT_LOAD_FACTOR
) - 例如:默认初始
capacity=16
,threshold=16*0.75=12
。当插入第 13 个键值对时触发扩容
- 为什么扩容能有效解决冲突?
- 桶数量翻倍: 大大增加了存储位置
- 哈希空间细化:
(newCap - 1)
比(oldCap - 1)
多了一个1
位(例如0b1111
->0b11111
),这意味着参与决定桶索引的哈希码位数增加了,散列更均匀 - 负载因子降低:
size / capacity
的值变小,平均每个桶的元素数减少
- 目的:
关键点总结与设计哲学:
- 核心目标: 在空间和时间效率之间取得平衡,保证
get
和put
操作的平均时间复杂度为 O(1) - 解决冲突三板斧:
- 链表 (基础): 处理少量冲突,简单高效
- 红黑树 (优化): 解决极端冲突导致的性能退化 (O(n) -> O(log n)),JDK 8 的重大改进。通过树化和退化阈值 (8 和 6) 避免频繁转换。
- 扩容 (根本): 通过增加桶数量,从根本上降低冲突概率和链表/树的长度。负载因子 (0.75) 是空间利用率和时间效率的经验折中
- “Bucket” (桶): 哈希表底层数组的槽位 (
table[index]
),是冲突发生的物理位置 - “溢出桶” 的实质: 在 Java
HashMap
的实现中,没有独立的、物理的“溢出桶”数据结构。链表和红黑树 直接挂载在冲突发生的那个桶 (table[index]
) 上。它们 逻辑上 是该桶处理“溢出”元素(即哈希到同一个位置的后续元素)的存储结构。因此,链表或树 就是 该桶的“溢出处理机制”
图示流程 (JDK 8+ put
操作简化版):
1 | 1. 计算 key 的扰动哈希: hash = (h = key.hashCode()) ^ (h >>> 16) |
All articles on this blog are licensed under CC BY-NC-SA 4.0 unless otherwise stated.