从哈希碰撞到红黑树,一场代码与数学的浪漫共舞

97 阅读6分钟

一、底层数据结构:三体世界般的多维空间

HashMap 的存储结构堪称 Java 集合框架的“变形金刚”,在不同场景下切换形态:

1. 核心结构:桶数组(Bucket Array)

transient Node<K,V>[] table; // 这才是真正的藏宝图!
  • 数组长度始终是 2 的幂(如 16→32→64),用位运算 (n-1) & hash 快速定位桶位置,比取模运算快 20 倍以上(CPU:位运算是我亲儿子!)。
  • 桶下标计算示例
    若数组长度 n=16(二进制 10000),n-1=15(1111)
    哈希值 hash=35678(二进制 1000101100011110)
    桶下标 = 1111 & 1000101100011110 = 1110 → 十进制 14

2. 链表与红黑树的量子纠缠

  • 普通节点(链表)
    static class Node<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next; // 链表指针
    }
    
  • 红黑树节点(当链表长度 ≥8)
    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // 红黑树父节点
        TreeNode<K,V> left;    // 左子树
        TreeNode<K,V> right;   // 右子树
        TreeNode<K,V> prev;    // 前驱节点(仍保留链表特性)
        boolean red;          // 颜色标记
    }
    

树化不是终点:当树节点数 ≤6 时,红黑树退化为链表(避免反复横跳浪费性能)。


二、哈希计算:一场精心设计的“混乱仪式”

1. 扰动函数:让哈希值“精神分裂”

JDK8 的哈希优化堪称神来之笔:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 原理:将高 16 位与低 16 位进行异或,让高位参与桶下标计算。
  • 示例
    原始 hashCode:0000 0000 0000 0001 1000 0000 0000 0001
    右移 16 位后:0000 0000 0000 0000 0000 0000 0000 0001
    异或结果: 0000 0000 0000 0001 1000 0000 0000 0000
    → 有效避免低位相同导致的哈希碰撞(比如连续数字的 hashCode 低位可能重复)。

2. 为什么不用取模运算?

  • 位运算 vs 取模(n-1) & hash 等效于 hash % n,但位运算速度是取模的 20 倍以上。
  • 必须满足 n 是 2 的幂:否则 (n-1) & hash 无法覆盖所有桶位置。

三、插入流程:堪比谍战大片的元素争夺战

1. put() 方法核心流程图

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    // 此处隐藏着 HashMap 的灵魂代码!
}

详细步骤

  1. 数组空检查:如果 table 为空,调用 resize() 初始化(默认容量 16)。
  2. 计算桶下标i = (n-1) & hash
  3. 桶为空:直接新建节点插入。
  4. 桶不为空
    • Key 已存在:覆盖旧值(若允许覆盖)。
    • 链表节点:遍历链表,插入尾部(JDK8 改为尾插法,避免死循环)。
    • 树节点:调用红黑树的插入方法。
  5. 检查树化:链表长度 ≥8 且数组长度 ≥64 → 转为红黑树。
  6. 扩容检查:元素总数超过阈值(容量 × 负载因子)→ 扩容。

2. 树化过程:链表到红黑树的华丽转身

final void treeifyBin(Node<K,V>[] tab, int hash) {
    // 如果数组长度 <64,优先扩容而不是树化!
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 将链表节点替换为 TreeNode,并构建红黑树
    }
}

关键逻辑

  • 先扩容再树化:小表(<64)扩容更划算,减少哈希冲突概率。
  • 树节点保留链表指针:退化和遍历时更方便(开发者:我全都要!)。

四、扩容机制:HashMap 的“乾坤大挪移”

1. 扩容触发条件

  • 常规扩容:元素数量 > 容量 × 负载因子(默认 0.75)。
  • 树化前检查:数组长度 <64 时,优先扩容而非树化。

2. 扩容优化:JDK8 的位运算魔法

旧数组长度 n=16,新数组长度 32:

  • 元素新位置 = 原位置 或 原位置 + 旧容量
    判断条件
if ((e.hash & oldCap) == 0) {
    // 留在原位置(比如原位置是 3,新位置还是 3)
} else {
    // 新位置 = 原位置 + 旧容量(比如原位置 3 → 3+16=19)
}

原理
由于数组长度是 2 的幂,旧容量 oldCap 的二进制形式为 10000(当 n=16 时),e.hash & oldCap 实际上是在判断哈希值的第 5 位(从右往左)是否为 1:

  • 结果为 0 → 第 5 位是 0 → 新位置不变
  • 结果为 1 → 第 5 位是 1 → 新位置 = 原位置 + oldCap

优势

  • 避免重新计算哈希值
  • 元素均匀分布到高位和低位区域

3. 扩容数据迁移示意图

旧数组索引:0 1 2 3 ... 15  
新数组索引:0 1 2 3 ... 31  
迁移规则:  
- 原位置为 3 的元素 → 新位置为 3 或 19(3+16)  
- 原位置为 5 的元素 → 新位置为 5 或 21(5+16)  

五、红黑树操作:HashMap 的“高端玩家模式”

1. 红黑树特性

  • 近似平衡:确保最坏情况下操作时间复杂度为 O(log n)。
  • 五大铁律
    1. 节点是红或黑
    2. 根节点是黑
    3. 叶子节点(NIL)是黑
    4. 红节点的子节点必须是黑
    5. 从任一节点到其叶子的所有路径包含相同数量的黑节点

2. 树化后的操作优化

  • 插入:按红黑树规则旋转+变色(左旋/右旋)。
  • 查询:从链表 O(n) 提升到红黑树 O(log n)。
  • 退化条件:删除节点后,树节点数 ≤6 → 退化为链表。

六、线程安全问题:HashMap 的“阿喀琉斯之踵”

1. 数据覆盖(JDK8 主要问题)

场景:两个线程同时执行 put 操作,且哈希碰撞

  • 线程 A 检查到桶为空,准备插入节点
  • 线程 B 抢先插入节点
  • 线程 A 继续插入 → 覆盖 B 的数据

2. 死循环(JDK7 专属“彩蛋”)

原因:头插法 + 并发扩容导致链表成环

// JDK7 的 transfer 方法(已废弃)
void transfer(Entry[] newTable) {
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next; // 线程暂停导致 next 被修改
            e.next = newTable[i];     // 头插法
            newTable[i] = e;
            e = next;
        }
    }
}

结果:当并发扩容时,可能导致 CPU 飙升至 100%(找环就像在迷宫里转圈)。


七、设计哲学:为什么 HashMap 这样设计?

  1. 空间换时间:用更大的内存消耗换取 O(1) 的查询效率。
  2. 统计学优化:树化阈值 8 和退化阈值 6 的设定基于泊松分布计算。
  3. 工程妥协:红黑树比 AVL 树更宽松的平衡条件,减少旋转次数。
  4. 硬件亲和:位运算取代取模,充分利用 CPU 特性。

终极思考题

如果让你设计一个存储 10 亿数据的 HashMap:

  • 初始容量设为多少?(提示:考虑负载因子和扩容次数)
  • 如何避免频繁 GC?(提示:使用数组+开放寻址法?)
  • 怎样实现线程安全?(提示:分段锁 vs CAS)

总结:HashMap 的精妙设计是算法与工程实践的完美结合,理解其原理不仅为了面试,更是为了写出高效、健壮的代码。下次当你在代码中写下 new HashMap<>() 时,请记住——你调用的不是一个简单的容器,而是一个凝结无数智慧的微型宇宙!