本文旨在拆解 Java HashMap 解决哈希冲突的机制,深入到每个步骤和设计考量。核心围绕 桶数组 (Buckets)链表 (Chaining)红黑树 (Treeify)扩容 (Resizing) 展开


核心机制深度剖析:

  1. 底层结构:桶数组 (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+)
  2. 定位桶:哈希计算与索引 (index = (n - 1) & hash)

    • 步骤:
      1. 计算原始键的哈希码: int h = key.hashCode()。这是 Object.hashCode() 方法返回的值。重写 equals 必须重写 hashCode 的根源! 相等的对象 必须 返回相等的哈希码,否则会导致同一个逻辑对象被映射到不同桶或无法覆盖
      2. 扰动函数 (JDK 8+ 优化): hash = h ^ (h >>> 16)
        • 目的: 让原始哈希码 h高 16 位 参与到后续的低位运算中。
        • 原因: 当桶数组 n 较小时(如初始 16),取模掩码 (n-1) 的有效位很少(例如 0b1111 只有低 4 位)。如果多个键的哈希码只在 高位 不同,低位完全相同,那么 (n-1) & h 的结果就会相同(造成低位冲突)。扰动将高位特征“扩散”到低位,增加了低位的变化性,显著减少了这种高位不同低位相同导致的冲突概率
      3. 计算桶索引: 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]
  3. 哈希冲突的发生

    • 定义: 当两个不同的键 key1key2 满足 (table.length - 1) & hash1 == (table.length - 1) & hash2 时,它们会被定位到同一个桶索引 index。无论 hash1hash2 是否相等(即使不等也可能映射到同一索引),它们都需要存储在同一个桶 table[index] 中。这就是 哈希冲突
  4. 解决冲突:分离链接法 (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)增加了稳定性,减少转换开销
  5. 扩容 (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 的值变小,平均每个桶的元素数减少

关键点总结与设计哲学:

  1. 核心目标: 在空间和时间效率之间取得平衡,保证 getput 操作的平均时间复杂度为 O(1)
  2. 解决冲突三板斧:
    • 链表 (基础): 处理少量冲突,简单高效
    • 红黑树 (优化): 解决极端冲突导致的性能退化 (O(n) -> O(log n)),JDK 8 的重大改进。通过树化和退化阈值 (8 和 6) 避免频繁转换。
    • 扩容 (根本): 通过增加桶数量,从根本上降低冲突概率和链表/树的长度。负载因子 (0.75) 是空间利用率和时间效率的经验折中
  3. “Bucket” (桶): 哈希表底层数组的槽位 (table[index]),是冲突发生的物理位置
  4. “溢出桶” 的实质: 在 Java HashMap 的实现中,没有独立的、物理的“溢出桶”数据结构。链表和红黑树 直接挂载在冲突发生的那个桶 (table[index]) 上。它们 逻辑上 是该桶处理“溢出”元素(即哈希到同一个位置的后续元素)的存储结构。因此,链表或树 就是 该桶的“溢出处理机制”

图示流程 (JDK 8+ put 操作简化版):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
1. 计算 key 的扰动哈希: hash = (h = key.hashCode()) ^ (h >>> 16)
2. 计算桶索引: index = (table.length - 1) & hash
3. 访问桶: bucket = table[index]
|
+-- 桶为空? (bucket == null) ------------------------------------> 新建 Node 直接放入桶 (table[index] = newNode)
|
+-- 桶不为空 ---------------------------------------------------+
|
+-- 桶是树? (bucket instanceof TreeNode) ------------------> 调用 treeNode.putTreeVal(...)
| |
| +-- 树中找到 key? --------------------------------> 更新 value, 返回 oldValue
| |
| +-- 树中未找到 key? ------------------------------> 插入新 TreeNode, 检查树结构平衡
| |
| +-- 插入后检查树大小? (可能触发退化检查) -----> (在remove时退化)
|
+-- 桶是链表? --------------------------------------------+
|
| 遍历链表 (binCount 计数):
|
+-- 找到 key 相等的节点? (p.key == key 或 key.equals(p.key)) ---> 更新 value, 返回 oldValue
|
+-- 未找到且遍历结束? (p.next == null) ------------+
|
| 创建新 Node 插入链表尾部 (p.next = newNode)
|
| 增加链表长度计数: binCount++
|
| binCount >= TREEIFY_THRESHOLD (8)? --+
| | |
| Y N --> 结束链表插入
| | |
| table.length >= MIN_TREEIFY_CAPACITY (64)? --+
| | |
| Y N --> 触发扩容 (resize()) 优先
| | |
| V V
| 调用 treeifyBin(table, hash) 转换链表为红黑树
|
| 检查全局 size: size++
|
| size > threshold? -------------------------> 触发扩容 (resize())
|
V
结束插入 (返回 null)