Skip to content

HashMap 底层实现:从数组到红黑树

HashMap 不是哈希表,是「数组 + 链表 + 红黑树」

很多人以为 HashMap 就是「哈希表」,但它的底层结构经历了两次进化:

  • JDK 1.7:数组 + 链表(头插法)
  • JDK 1.8+:数组 + 链表/红黑树(尾插法)——加入红黑树是为了解决链表过长导致的性能退化问题。

核心数据结构

java
public class HashMap<K, V> extends AbstractMap<K,V> {
    // 哈希桶数组
    transient Node<K, V>[] table;
    // 键值对数量
    transient int size;
    // 扩容阈值 = 容量 × 负载因子
    int threshold;
    // 负载因子,默认 0.75
    final float loadFactor;
}

关键参数:

参数默认值说明
DEFAULT_INITIAL_CAPACITY16初始桶数量(必须是 2 的幂)
DEFAULT_LOAD_FACTOR0.75负载因子
TREEIFY_THRESHOLD8链表转红黑树的阈值
UNTREEIFY_THRESHOLD6红黑树转回链表的阈值
MIN_TREEIFY_CAPACITY64最小树形化容量(数组太小不转树)

Node 节点

java
static class Node<K, V> implements Map.Entry<K, V> {
    final int hash;      // key 的哈希值(避免重复计算)
    final K key;         // 不可变
    V value;             // 可以变
    Node<K, V> next;     // 链表下一节点
}

哈希函数:如何把 key 变成数组索引

hash() 方法

java
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

为什么要 ^ (h >>> 16)?因为数组长度通常很小(如 16),直接用 hashCode() 的低 4 位会产生大量冲突。用高位和低位异或混合,能让哈希分布更均匀。

索引计算

java
int index = (n - 1) & hash; // n 是数组长度,必须是 2 的幂
// 等价于 hash % n,但位运算更快

put() 流程详解

putVal 源码(简化)

java
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) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;

    // 第一步:数组为空或长度为 0,扩容初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    // 第二步:计算桶位置,桶为空,直接创建节点
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;

        // 第三步:桶不为空,检查第一个节点
        if (p.hash == hash &&
            ((k = p.key) == key || key.equals(k)))
            e = p;

        // 第四步:第一个节点不匹配,检查是否是红黑树
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

        // 第五步:否则遍历链表
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 第六步:链表长度 ≥ 8,转红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || key.equals(k)))
                    break;
                p = e;
            }
        }

        // 第七步:key 已存在,替换旧值
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }

    // 第八步:修改次数递增,检查扩容
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

流程图

put("hello", 1) 时:

┌─────────────────────────────────────────────────────────────┐
│ 第一步:数组为空?                                          │
│   └─ 是 → resize() 初始化数组(容量 16)                    │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 第二步:计算桶位置                                           │
│   hash("hello") = 31                                        │
│   index = 31 & 15 = 15    ← 在 bucket[15] 找               │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 第三步:bucket[15] 为空?                                   │
│   └─ 是 → 直接插入 Node                                     │
│   └─ 否 → 遍历链表/红黑树,找相同 key 或遍历到末尾            │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 第四步:key 已存在?                                        │
│   └─ 是 → 替换旧值,返回旧值                                │
│   └─ 否 → 插入新节点,检查是否需要转红黑树 / 扩容              │
└─────────────────────────────────────────────────────────────┘

get() 流程:和 put 相反

java
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 检查第一个节点
        if (first.hash == hash &&
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 检查链表或红黑树
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

扩容机制:容量翻倍,元素重分配

扩容触发条件

当 size > threshold 时触发扩容
threshold = capacity × loadFactor = 16 × 0.75 = 12

resize() 做了什么

  1. 创建新数组(容量翻倍,如 16 → 32)
  2. 重新计算每个元素的桶位置
  3. 将节点迁移到新数组

扩容后元素位置的计算技巧

旧数组容量 oldCap = 16(0b00010000)
新数组容量 newCap = 32(0b00100000)

对于每个元素,只需判断:
  hash & oldCap == 0 → 新位置 = 原位置
  hash & oldCap != 0 → 新位置 = 原位置 + oldCap

示例:
  hash = 31(0b00011111),31 & 16 = 0 → 新位置不变
  hash = 47(0b00101111),47 & 16 = 16 → 新位置 = 原位置 + 16

这叫 "高位分流":利用 hash 的高位(第 oldCap 那位)决定去留。


红黑树:解决链表过长的性能退化

何时转红黑树

链表长度 ≥ 8 且 数组长度 ≥ 64 → 转红黑树
红黑树节点数 ≤ 6 → 转回链表

为什么是 8 和 6

这是 JDK 作者根据泊松分布计算的结果:

  • 负载因子 0.75 时,链表长度达到 8 的概率极低(~0.00000006)
  • 超过 8 说明哈希分布极差,需要用红黑树优化查找
  • 6 是留的缓冲,避免在临界点反复转换

红黑树的查找

红黑树是自平衡二叉搜索树,查找路径长度不超过 O(log n):

假设有 1000 个节点:
  链表查找:最多 1000 次比较
  红黑树查找:最多 ~10 次比较

数据结构演进图

JDK 1.7 之前:
┌─────────┬─────────┬─────────┬─────────┐
│ bucket 0│ bucket 1│ bucket 2│ bucket 3│
│  [E1]   │  [E2]   │  [E3]   │  [E4]   │
│  ↓null  │  [E5]   │  ↓null  │  ↓null  │
└─────────┴─────────┴─────────┴─────────┘
  链表解决冲突(头插法,并发问题)

JDK 1.8+:
┌─────────┬─────────┬─────────┬─────────┐
│ bucket 0│ bucket 1│ bucket 2│ bucket 3│
│  [E1]   │  [E2]   │  [E3]   │  [E4]   │
│  ↓null  │  [E5]   │  ↓null  │  ↓null  │
└─────────┴─────────┴─────────┴─────────┘
  链表(≤8 个节点)或红黑树(>8 个节点)

桶内节点少 → 链表(开销小)
桶内节点多 → 红黑树(查找快)

线程安全性

HashMap 是非线程安全的:

java
// ❌ 并发不安全
HashMap<Integer, Integer> map = new HashMap<>();

// ✅ ConcurrentHashMap
ConcurrentHashMap<Integer, Integer> safe = new ConcurrentHashMap<>();

总结

要点说明
底层结构数组 + 链表(JDK 1.7)或数组 + 链表/红黑树(JDK 1.8+)
哈希函数(h = key.hashCode()) ^ (h >>> 16) 混合高低位
索引计算(n - 1) & hash,等价于 hash % n
扩容容量翻倍,高位分流确定新位置
红黑树链表长度 ≥ 8 且数组 ≥ 64 时转换,查找 O(log n)
线程安全非安全,生产用 ConcurrentHashMap

一句话:HashMap 是「数组定位桶 + 链表/红黑树解决冲突 + 自动扩容」的组合设计。


相关链接

基于 VitePress 构建