Lesson 01 · 集合框架源码

HashMap 源码精讲:数组+链表+红黑树

高级·⭐ 必问·#集合·#核心·#源码

第 1 站

从一道面试题开始

先来看一道大厂高频面试题:

"请你详细说一下 HashMap 的底层实现原理?" —— 90% 的 Java 开发者都会遇到这道题,但真正能说清楚的人不超过 30%。

这个问题看似简单,却可以层层递进,从数据结构问到哈希算法,从扩容机制问到线程安全,从 JDK 1.7 问到 1.8 的优化。面试官通过这一个问题,就能判断你的 Java 功底到底有多深。

在日常工作中,HashMap 是使用频率最高的数据结构之一。无论是缓存数据、配置映射、请求参数解析、还是作为 Spring 容器的底层存储(DefaultListableBeanFactory 内部就是用 ConcurrentHashMap 存储 BeanDefinition),HashMap 的身影无处不在。

在正式开始之前,先抛几个你工作中可能遇到过的问题:

  • 为什么有时候遍历 HashMap 的顺序和插入顺序不一样?
  • 为什么阿里规约要求初始化 HashMap 时必须指定容量?
  • 为什么说 HashMap 的 key 最好用不可变对象(如 String、Integer)?
  • 线上服务突然 CPU 飙到 100%,排查后发现是 HashMap 死循环——这是怎么发生的?

带着这些问题,我们从最底层开始,一步步拆解 HashMap 的设计。

第 2 站

核心数据结构:数组 + 链表 + 红黑树

HashMap 的底层结构一句话概括:数组 + 链表 + 红黑树。这个结构不是一蹴而就的,而是在 JDK 演进中逐步优化的。

table[] 数组 —— 默认长度 16,每个位置称为一个"桶(bucket)" [0] null [1] Node(key,value) Node(key,value) Node(key,value) 链表(next 指针串联) [n] 红黑树 链表长度 < 8 链表长度 ≥ 8 JDK 1.7 数组 + 链表(头插法) JDK 1.8 数组 + 链表 + 红黑树(尾插法)
图 1HashMap 底层结构全景:数组作为主干,每个桶通过链表或红黑树解决哈希冲突

我们先看几个写死在源码里的关键常量——面试里被反复追问的"16""0.75""8""64"都在这里:

HashMap.java · JDK 1.8 关键常量
// 默认初始容量 = 16(1 << 4),必须是 2 的幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// 最大容量 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认负载因子 0.75(时间与空间的折中)
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 链表转红黑树的阈值:链表长度 ≥ 8
static final int TREEIFY_THRESHOLD = 8;

// 红黑树退化回链表的阈值:节点数 ≤ 6
static final int UNTREEIFY_THRESHOLD = 6;

// 树化的另一前提:数组长度必须 ≥ 64,否则优先扩容
static final int MIN_TREEIFY_CAPACITY = 64;

再看桶里存的节点结构。普通链表节点是 Node,它就是一个最朴素的单向链表节点——保存 hash、key、value 和指向下一个节点的 next 指针:

HashMap.Node · 链表节点(实现 Map.Entry)
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;   // 缓存的 hash 值,避免重复计算
    final K key;     // key 用 final 修饰 —— 这也是不能改 key 的原因
    V value;
    Node<K,V> next;   // 指向同一个桶里的下一个节点

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
}

当链表树化后,节点会升级为 TreeNode(继承自 LinkedHashMap.Entry),额外保存红黑树需要的 parent、left、right、red 等字段。从 16 字节的 Node 膨胀到约 48 字节的 TreeNode,这也是为什么树化要有数组 ≥ 64 的前提——避免小表里频繁树化反而浪费内存

为什么要引入红黑树?

极端情况下,所有 key 的哈希值都落到同一个桶里,链表长度会变成 n。此时 get() 的时间复杂度从 O(1) 退化为 O(n)。1.8 引入红黑树后,即使所有元素碰撞到同一个桶,查找复杂度也能保证在 O(log n)

但注意,树化有两个条件同时满足才触发:① 链表长度 ≥ 8(TREEIFY_THRESHOLD);② 数组长度 ≥ 64(MIN_TREEIFY_CAPACITY)。如果数组还很小,优先选择扩容而不是树化——因为扩容能更均匀地分散元素。

第 3 站

Hash 计算:为什么 hashCode 要"扰动"?

HashMap 的核心是哈希函数——如何把一个任意对象映射到数组的某个下标上。一个好的哈希函数应该让元素均匀分布在各个桶中,最大限度地减少碰撞。

JDK 1.8 的哈希计算分为两步:

第一步:扰动函数
hash = key.hashCode() ^ (key.hashCode() >>> 16)

第二步:取模定位
index = hash & (table.length - 1)
key.hashCode() 例如: 12345678 扰动: h ^ (h >>> 16) 高16位 ⊕ 低16位 hash & (n - 1) 等价于 hash % n(但更快) 为什么 hashCode 需要"扰动"? 如果直接用 hashCode() 的低位来决定桶下标 —— 而 hashCode() 的高位变化大、低位可能相似 扰动函数让高位也参与运算(高16位异或低16位),使最终的下标更"随机"、碰撞更少 这就是面试时要说的"为什么不是直接 hashCode % n"
图 2HashMap 的哈希计算:先扰动、再定位,让你的 key 均匀散落在数组各处

来看 hash() 方法的真实源码,整个扰动只有一行:

HashMap.hash() · 扰动函数
static final int hash(Object key) {
    int h;
    // key 为 null 时 hash = 0 —— 这就是 HashMap 允许 null key 的原因,
    // 且 null key 永远落在 0 号桶
    return (key == null) ? 0
         : (h = key.hashCode()) ^ (h >>> 16);
    //       ↑ 取原始 hashCode      ↑ 高 16 位无符号右移后异或
}

h ^ (h >>> 16) 的含义:把 32 位 hashCode 的高 16 位右移下来,和低 16 位做异或。这样高位的信息被"混入"了低位。因为后面定位桶用的是 hash & (n-1),当 n 较小时(比如 16),只有最低 4 位参与运算——如果不扰动,高位再不一样的两个 key 也容易碰撞。

定位桶下标的代码藏在 putVal() 里,就是那个经典的 (n - 1) & hash

HashMap.putVal() · 桶定位片段
// n 是数组长度,tab 是 table 数组
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
// (n-1) & hash 等价于 hash % n,但位运算比取模快得多
思考:为什么 table.length 必须是 2 的幂?

因为当 n = 2k 时,n - 1 的二进制形式是 k 个连续的 1(比如 16-1=15=1111₂),这样 hash & (n-1) 的结果就只在 [0, n-1] 范围内均匀分布。如果 n 不是 2 的幂,n-1 的二进制中会有 0 位,导致某些桶永远分不到元素,浪费空间且增大碰撞。

实战 Tips:面试时你可以补充"这就是为什么阿里规约要求 new HashMap(n) 时,建议传 (int) (n / 0.75 + 1),这样既避免了扩容,又保证了容量会被 tableSizeFor() 自动纠正为 2 的幂"。

第 4 站

put() 完整流程:8 个关键步骤

面试官说:"你画一下 HashMap 的 put 流程。" —— 这是一个经典的"白板编程题"。我们通过一张图把整个过程串起来:

put(key, value) table 为空?→ resize() 初始化 计算 hash,定位桶下标 i table[i] == null? 是 → ③ 直接插入 否 ↓ ④⑤ 遍历链表/红黑树 key 相同 → 覆盖 value(onlyIfAbsent 为 false 时) 尾插法:追加到链表末尾(1.7 是头插法) 链表长度 ≥ 8?→ treeifyBin() ++size > threshold?→ resize() return oldValue
图 3HashMap put() 的完整流程图 —— 一次记不住没关系,理解逻辑比背步骤更重要

下面是 putVal() 的核心源码(去掉了红黑树分支,保留主干),图 3 的 8 个步骤都能在这里一一对应上:

HashMap.putVal() · 插入主流程(精简)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;

    // ① table 为空 → 触发 resize() 初始化
    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;
        // ④ 桶第一个节点 key 就相同 → 记下,待会儿覆盖
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && 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 != null && key.equals(k))))
                    break;  // 链表中找到相同 key
                p = e;
            }
        }
        // 找到了相同 key 的节点 → 覆盖 value
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            return oldValue;   // 返回旧值
        }
    }
    ++modCount;
    // ⑧ size 超过阈值 → 扩容
    if (++size > threshold)
        resize();
    return null;   // 新增 key,返回 null
}

重点理解第 ④⑤ 步中 key 相同的判断逻辑(注意源码里的短路顺序):

p.hash == hash && (p.key == key || key.equals(p.key))

先比较 hash(快,int 比较)→ 再比较 ==(引用相同,最快)→ 最后才 equals()(可能较慢)。这是典型的"先用低成本过滤,再用高成本确认"。也正因为这个顺序,hashCode 不同的两个对象,HashMap 直接认为它们不相等,根本不会调 equals() ——这就是必须同时正确实现 hashCode 和 equals 的根本原因。

不可变 key 的重要性

如果你用可变对象做 key,并且修改了影响 hashCode 的字段,那么 HashMap 就再也找不到这个 entry 了——它还在原来的桶里,但 containsKey() 返回 false。这就是为什么推荐用 String、Integer 等不可变对象做 key。

第 5 站

扩容机制:1.7 vs 1.8 的关键差异

扩容是 HashMap 中最"贵"的操作——需要重新计算每个元素的位置并搬移。触发条件是 size > capacity × loadFactor,默认 16 × 0.75 = 12,即第 13 个元素插入时触发扩容。

JDK 1.7 扩容 1. 创建 newTable(2 倍容量) 2. 遍历 oldTable 每个桶 3. 头插法转移到 newTable 4. 重新 hash 计算位置 ⚠ 头插法导致顺序反转 ⚠ 并发下可能形成环形链表 ⚠ 导致 get() 死循环 CPU 100% JDK 1.8 扩容 1. 创建 newTable(2 倍容量) 2. 遍历 oldTable 每个桶 3. 尾插法保持原有顺序 4. 无需重新 hash! ✓ hash & oldCap = 0 → 原位不动 ✓ hash & oldCap ≠ 0 → 原位 + oldCap ✓ 尾插法避免环形链表
图 4JDK 1.7 vs 1.8 扩容对比:核心差异在于插入方式 + 位置计算

JDK 1.7 的扩容过程

1.7 的扩容方法是 resize() + transfer()。它的逻辑很直接:创建一个 2 倍大小的新数组,然后把旧数组里每一个元素逐个重新计算下标,再用头插法放进新数组:

// JDK 1.7 transfer 核心逻辑(简化)
for (Entry<K,V> e : oldTable) {
  while (e != null) {
    Entry<K,V> next = e.next;  // 先存下一个
    int i = indexFor(e.hash, newCapacity); // 重新算下标
    e.next = newTable[i];  // 头插:当前节点指向桶里的旧头
    newTable[i] = e;     // 当前节点成为新的头
    e = next;
  }
}

这里有两个关键问题:

  • 每个元素都要重新 hash:调用 indexFor(hash, newCap) 重新计算桶位置,开销大。
  • 头插法导致链表逆序:新元素插在桶的头部,所以原本 A→B→C 的链表,扩容后会变成 C→B→A。正是这个"逆序 + 节点引用交错"的特性,在多线程并发扩容时会形成环形链表——也就是第 6 站要详细讲的 CPU 100% 死循环根源。

JDK 1.8 扩容位置计算的精妙优化

1.8 最大的改进是:扩容时不需要重新计算每个元素的 hash。因为 HashMap 的容量始终是 2 的幂,扩容后新容量 = 旧容量 × 2(比如 16 → 32),相当于在二进制下让掩码多出最高一位。利用这个特性,只需用元素的 hash 值和旧容量 oldCap 做一次按位与,就能判断它在新数组里的位置:

  • 如果 hash & oldCap == 0:元素在新表中的位置不变(因为新增的高位是 0)
  • 如果 hash & oldCap != 0:元素的新位置 = 原位 + oldCap(因为新增的高位是 1)

所以 1.8 在扩容时,会把每个桶的链表拆成两条:"低位链表"(位置不变)和"高位链表"(位置 + oldCap),然后分别挂到新数组的两个位置上。这个优化省掉了每个元素重新调用 hashCode() 和取模运算的开销。面试时说到这个细节,面试官就知道你真正读过源码。

这段"低位 / 高位链表拆分"的逻辑,对应 resize() 里这段经典源码:

HashMap.resize() · 链表 rehash 片段(JDK 1.8)
// loHead/loTail:低位链表(留在原索引 j)
// hiHead/hiTail:高位链表(移到索引 j + oldCap)
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
    next = e.next;
    // 关键:用 hash & oldCap 判断新增的那一位是 0 还是 1
    if ((e.hash & oldCap) == 0) {
        if (loTail == null) loHead = e;
        else loTail.next = e;
        loTail = e;        // 尾插,保持原有顺序
    } else {
        if (hiTail == null) hiHead = e;
        else hiTail.next = e;
        hiTail = e;
    }
} while ((e = next) != null);

// 低位链表:原位 j
if (loTail != null) { loTail.next = null; newTab[j] = loHead; }
// 高位链表:新位 j + oldCap
if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }

对比第 5 站开头给出的 1.7 transfer() 源码:1.7 是头插e.next = newTable[i])所以会逆序;1.8 这里是尾插loTail.next = e)所以顺序不变——这正是 1.8 不会形成环形链表的根本原因。

实战思考:如果预知要存 1000 个元素,HashMap 初始容量应该设多少?

答案:2048(不是 1024)。公式:capacity = tableSizeFor((int)(expectedSize / 0.75 + 1)) = tableSizeFor(1334) = 2048。这样在整个插入过程中一次扩容都不会发生。如果你设 1024,容量不够大,还是会触发 1-2 次扩容。

第 6 站

线程安全问题:致命的死循环

真实生产事故

某电商系统在"双十一"促销期间,多个线程同时往一个全局 HashMap 中写入缓存数据。JDK 1.7 环境下运行一段时间后,服务器 CPU 突然飙到 100%。排查发现:HashMap 在并发扩容时形成了环形链表,后续的 get() 操作陷入死循环。最终通过重启恢复,紧急将缓存容器替换为 ConcurrentHashMap。

环形链表的形成(JDK 1.7 头插法 + 并发扩容) 线程 A(扩容中,头插法转移): A→B B→C C→null 线程 B(同时扩容,交错操作后): B→A A→B ⚠ B → A → B → A … 死循环!
图 5JDK 1.7 并发扩容时头插法导致的环形链表形成过程
生产环境方案对比
方案线程安全性能推荐
ConcurrentHashMap✅ CAS + synchronized极高,粒度到桶✅ 首选
Collections.synchronizedMap()✅ 锁整表⚠ 低并发
Hashtable✅ 所有方法 synchronized❌ 已淘汰
第 7 站

面试追问 & 最佳实践

追问 1:HashMap 和 Hashtable 的区别?

HashMap:非线程安全 | 允许 null key/value | 初始 16 | 扩容 ×2 | 1.8 引入红黑树
Hashtable:线程安全(synchronized) | 不允许 null | 初始 11 | 扩容 ×2+1 | 只有链表

追问 2:为什么重写 equals() 必须重写 hashCode()?

HashMap 用 hashCode() 定位桶、用 equals() 在桶内查找。如果你只重写了 equals() 不重写 hashCode(),那么两个逻辑上相同的对象可能落在不同桶里——HashMap 永远找不到它。这是 Java 开发中最常见的 bug 之一。

追问 3:loadFactor 为什么默认是 0.75?

这是空间与时间的折中:

  • 1.0:空间满,碰撞严重,查询慢
  • 0.5:碰撞少,一半空间浪费
  • 0.75:泊松分布下,桶中元素超过 8 个的概率低于千万分之一(源码注释可查)

生产环境 Checklist

✅ 最佳实践
  • 指定初始容量new HashMap((int)(expectedSize / 0.75 + 1))
  • 用不可变对象做 key:String、Integer、LocalDate 等
  • 重写 equals() 必须重写 hashCode():IDEA/Lombok 可自动生成
  • 并发场景用 ConcurrentHashMap:不要在并发环境用 HashMap
  • JDK 1.8+ 可放心使用:尾插法修复了死循环问题
  • 序列化注意:确保 key 和 value 都实现了 Serializable
总结

这一篇你掌握了什么

核心知识点回顾

  • 数据结构:JDK 1.7 数组+链表(头插法)→ JDK 1.8 数组+链表+红黑树(尾插法)
  • 哈希计算:扰动函数 (h ^ h>>>16) + 位运算取模 (hash & n-1),n 必须是 2 的幂
  • put 流程:8 步 —— 判空→定位→遍历→覆盖/插入→树化→扩容
  • 扩容优化:1.8 无需重新 hash,hash & oldCap 直接决定新位置(0→原位,1→原位+oldCap)
  • 线程安全:1.7 头插法并发扩容可形成死循环,1.8 尾插法修复
  • 树化条件:链表 ≥ 8 数组 ≥ 64,缺一不可

👉 下一篇:ConcurrentHashMap —— 从分段锁到 CAS + synchronized 的演进