Lesson 01 · 集合框架源码
HashMap 源码精讲:数组+链表+红黑树
从一道面试题开始
先来看一道大厂高频面试题:
这个问题看似简单,却可以层层递进,从数据结构问到哈希算法,从扩容机制问到线程安全,从 JDK 1.7 问到 1.8 的优化。面试官通过这一个问题,就能判断你的 Java 功底到底有多深。
在日常工作中,HashMap 是使用频率最高的数据结构之一。无论是缓存数据、配置映射、请求参数解析、还是作为 Spring 容器的底层存储(DefaultListableBeanFactory 内部就是用 ConcurrentHashMap 存储 BeanDefinition),HashMap 的身影无处不在。
在正式开始之前,先抛几个你工作中可能遇到过的问题:
- 为什么有时候遍历 HashMap 的顺序和插入顺序不一样?
- 为什么阿里规约要求初始化 HashMap 时必须指定容量?
- 为什么说 HashMap 的 key 最好用不可变对象(如 String、Integer)?
- 线上服务突然 CPU 飙到 100%,排查后发现是 HashMap 死循环——这是怎么发生的?
带着这些问题,我们从最底层开始,一步步拆解 HashMap 的设计。
核心数据结构:数组 + 链表 + 红黑树
HashMap 的底层结构一句话概括:数组 + 链表 + 红黑树。这个结构不是一蹴而就的,而是在 JDK 演进中逐步优化的。
我们先看几个写死在源码里的关键常量——面试里被反复追问的"16""0.75""8""64"都在这里:
// 默认初始容量 = 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 指针:
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)。如果数组还很小,优先选择扩容而不是树化——因为扩容能更均匀地分散元素。
Hash 计算:为什么 hashCode 要"扰动"?
HashMap 的核心是哈希函数——如何把一个任意对象映射到数组的某个下标上。一个好的哈希函数应该让元素均匀分布在各个桶中,最大限度地减少碰撞。
JDK 1.8 的哈希计算分为两步:
hash = key.hashCode() ^ (key.hashCode() >>> 16)
第二步:取模定位
index = hash & (table.length - 1)
来看 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:
// n 是数组长度,tab 是 table 数组
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// (n-1) & hash 等价于 hash % n,但位运算比取模快得多
因为当 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 的幂"。
put() 完整流程:8 个关键步骤
面试官说:"你画一下 HashMap 的 put 流程。" —— 这是一个经典的"白板编程题"。我们通过一张图把整个过程串起来:
下面是 putVal() 的核心源码(去掉了红黑树分支,保留主干),图 3 的 8 个步骤都能在这里一一对应上:
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 相同的判断逻辑(注意源码里的短路顺序):
先比较 hash(快,int 比较)→ 再比较 ==(引用相同,最快)→ 最后才 equals()(可能较慢)。这是典型的"先用低成本过滤,再用高成本确认"。也正因为这个顺序,hashCode 不同的两个对象,HashMap 直接认为它们不相等,根本不会调 equals() ——这就是必须同时正确实现 hashCode 和 equals 的根本原因。
如果你用可变对象做 key,并且修改了影响 hashCode 的字段,那么 HashMap 就再也找不到这个 entry 了——它还在原来的桶里,但 containsKey() 返回 false。这就是为什么推荐用 String、Integer 等不可变对象做 key。
扩容机制:1.7 vs 1.8 的关键差异
扩容是 HashMap 中最"贵"的操作——需要重新计算每个元素的位置并搬移。触发条件是 size > capacity × loadFactor,默认 16 × 0.75 = 12,即第 13 个元素插入时触发扩容。
JDK 1.7 的扩容过程
1.7 的扩容方法是 resize() + transfer()。它的逻辑很直接:创建一个 2 倍大小的新数组,然后把旧数组里每一个元素逐个重新计算下标,再用头插法放进新数组:
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() 里这段经典源码:
// 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 不会形成环形链表的根本原因。
答案:2048(不是 1024)。公式:capacity = tableSizeFor((int)(expectedSize / 0.75 + 1)) = tableSizeFor(1334) = 2048。这样在整个插入过程中一次扩容都不会发生。如果你设 1024,容量不够大,还是会触发 1-2 次扩容。
线程安全问题:致命的死循环
某电商系统在"双十一"促销期间,多个线程同时往一个全局 HashMap 中写入缓存数据。JDK 1.7 环境下运行一段时间后,服务器 CPU 突然飙到 100%。排查发现:HashMap 在并发扩容时形成了环形链表,后续的 get() 操作陷入死循环。最终通过重启恢复,紧急将缓存容器替换为 ConcurrentHashMap。
| 方案 | 线程安全 | 性能 | 推荐 |
|---|---|---|---|
| ConcurrentHashMap | ✅ CAS + synchronized | 极高,粒度到桶 | ✅ 首选 |
| Collections.synchronizedMap() | ✅ 锁整表 | 差 | ⚠ 低并发 |
| Hashtable | ✅ 所有方法 synchronized | 差 | ❌ 已淘汰 |
面试追问 & 最佳实践
追问 1:HashMap 和 Hashtable 的区别?
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,缺一不可