JAVA · Vol.I · DAY 01 · 集合框架源码
HashMap 源码精讲:数组+链表+红黑树
从一道高频面试题开始
先来看一道大厂高频面试题:
这个问题看似简单,却可以层层递进:从数据结构问到哈希算法,从扩容机制问到线程安全,从 JDK 1.7 问到 1.8 的优化。面试官通过这一个问题,就能判断你的 Java 功底到底有多深。
先给你一个 30 秒版答案,后面每一站都是在给这句话"上细节":
HashMap 底层是数组(Node[] table)+ 链表 + 红黑树:先用扰动函数 h ^ (h >>> 16) 打散 hashCode,再用 (n - 1) & hash 定位桶下标;桶内冲突少就挂链表,链表长度 ≥ 8 且数组 ≥ 64 时树化成红黑树;当 size > capacity × 0.75 时触发 2 倍扩容,JDK 8 扩容用 hash & oldCap 分流,无需重新计算下标。
在日常工作中,HashMap 是使用频率最高的数据结构之一。无论是缓存数据、配置映射、请求参数解析、还是作为 Spring 容器的底层存储(DefaultListableBeanFactory 内部就是用 ConcurrentHashMap 存储 BeanDefinition),HashMap 的身影无处不在。
在正式开始之前,先抛几个你工作中可能遇到过的问题:
- 为什么有时候遍历 HashMap 的顺序和插入顺序不一样?
- 为什么阿里规约要求初始化 HashMap 时必须指定容量?
- 为什么说 HashMap 的 key 最好用不可变对象(如 String、Integer)?
- 线上服务突然 CPU 飙到 100%,排查后发现是 HashMap 死循环——这是怎么发生的?
带着这些问题,我们从最底层开始,一步步拆解 HashMap 的设计。
核心数据结构:数组 + 链表 + 红黑树
HashMap 的底层结构一句话概括:数组 + 链表 + 红黑树。这个结构不是一蹴而就的,而是在 JDK 演进中逐步优化的。
我们先看几个写死在源码里的关键常量——面试里被反复追问的"16""0.75""8""6""64"都在这里:
// 默认初始容量 = 16(1 << 4),必须是 2 的幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量 2^30(1<<31 会溢出为负数,所以取 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)。对比一下两种节点:
| 维度 | Node(链表节点) | TreeNode(树节点) |
|---|---|---|
| 字段 | hash、key、value、next(共 4 个) | 继承 Node 全部字段,另加 parent、left、right、prev、red(共 5 个) |
| 链接方式 | 单向:只有 next | 双向:prev/next 串链表,parent/left/right 建树 |
| 约占用内存 | 约 16~32 字节(视 JVM 是否开启指针压缩) | 约 48 字节,约为 Node 的 2 倍 |
| 查找复杂度 | O(n) | O(log n) |
从约 16 字节的 Node 膨胀到约 48 字节的 TreeNode,这也是为什么树化要有数组 ≥ 64 的前提——避免小表里频繁树化反而浪费内存。具体数值随 JVM 是否开启指针压缩(-XX:+UseCompressedOops)而浮动,但"树节点大约是链表节点的 2 倍"这个量级是确定的。
极端情况下,所有 key 的哈希值都落到同一个桶里,链表长度会变成 n。此时 get() 的时间复杂度从 O(1) 退化为 O(n)。1.8 引入红黑树后,即使所有元素碰撞到同一个桶,查找复杂度也能保证在 O(log n)。
但注意,树化有两个条件同时满足才触发:① 链表长度 ≥ 8(TREEIFY_THRESHOLD);② 数组长度 ≥ 64(MIN_TREEIFY_CAPACITY)。如果数组还很小,优先选择扩容而不是树化——因为扩容能更均匀地分散元素。这两个阈值为什么这么定,下一站专门拆。
阈值设计:8 / 6 / 64 / 0.75 的数字密码
面试官经常会追问一句:"为什么是 8?为什么是 0.75?"——这可不是拍脑袋定的,源码注释里白纸黑字写着依据。
为什么 TREEIFY_THRESHOLD = 8:泊松分布
在 TreeNode 类的 Javadoc 注释里,JDK 作者贴出了一张泊松分布的概率表。假设 hashCode 是随机均匀分布的、负载因子为 0.75,那么一个桶里链表长度为 k 的概率是:
| 链表长度 k | 出现概率 | 说明 |
|---|---|---|
| 0 | 0.60653066 | 超过六成的桶是空的 |
| 1 | 0.30326533 | 约三成的桶只有 1 个元素 |
| 2 | 0.07581633 | —— |
| 3 | 0.01263606 | —— |
| 4 | 0.00157952 | —— |
| 5 | 0.00015795 | —— |
| 6 | 0.00001316 | —— |
| 7 | 0.00000094 | —— |
| 8 | 0.00000006(约 6×10⁻⁸) | 不足千万分之一 |
含义很清晰:在正常随机分布下,链表长度根本不可能涨到 8。一旦某个桶的链表真的达到 8,几乎可以断定是 hashCode 分布出了大问题(比如大量 key 碰撞、或者用了 16 的倍数做 key,见第 11 站)。此时花 2 倍内存去换 O(log n) 的查找,是划算的兜底方案。换句话说:8 这个阈值,平时永远用不到,用到就是"救火"。
为什么 MIN_TREEIFY_CAPACITY = 64:双重检查
树化还有一个前置条件——数组长度 ≥ 64。因为 TreeNode 的内存约为 Node 的 2 倍,在小表(如容量 16)上树化,内存开销大、收益小;而扩容只需要 O(n) 搬移、且能让元素重新均匀分布,比树化更"划算"。所以源码里 treeifyBin() 会做一次双重检查:数组不够大就直接扩容,不树化(完整源码在第 6 站)。
为什么 UNTREEIFY_THRESHOLD = 6:滞回(hysteresis)设计
树化阈值是 8,退化阈值却是 6,中间留了 7 这个缓冲带。这是典型的滞回设计:
想象一台空调:26° 开启制冷、25.5° 关闭——如果开关温度设成同一个值,室温会在临界点反复震荡,压缩机频繁启停报废。数据库连接池的 max/min、负载均衡的熔断恢复阈值,用的都是同一个思想。如果树化/退化都用 8,那么在 8 附近增删元素会导致链表↔树来回切换,白白浪费转换成本。
所以真实语义是:链表涨到 ≥ 8 才树化;扩容拆分后节点 ≤ 6 才退化回链表(UNTREEIFY_THRESHOLD 真正用武之地在 TreeNode.split(),第 6 站见源码)。
为什么 DEFAULT_LOAD_FACTOR = 0.75
- 1.0:空间利用率 100%,但碰撞严重、查询变慢,还要频繁处理长链表;
- 0.5:碰撞很少,但一半空间浪费,扩容频繁;
- 0.75:时间与空间的折中点。源码注释的原话是 "offers a good tradeoff between time and space costs",同时在这个负载因子下,泊松分布的参数 λ ≈ 0.5,正好引出"链表长度 8 的概率不足千万分之一"——0.75 和 8 是配套设计的。
默认容量 16 同样有讲究:太小(如 4)会频繁扩容,太大(如 1024)浪费内存;16 是 2 的 4 次方,配合位运算定位恰好合适。而 MAXIMUM_CAPACITY = 1 << 30 是因为 Java 的 int 最高位是符号位,1 << 31 会变成负数,数组长度也受 JVM 限制,所以取 2³⁰。
- 8:泊松分布下"正常情况几乎不可能出现"的长度,触发即救火;
- 64:TreeNode 更占内存,小表优先扩容而非树化;
- 6:与 8 之间留 2 个缓冲,防止链表↔树反复切换(滞回);
- 0.75:时间/空间折中,与 λ≈0.5 的泊松分布、阈值 8 配套设计。
Hash 计算:扰动函数与 (n-1) & hash 下标定位
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 号桶(0 & (n-1) == 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 也容易碰撞。
用一组具体数字验证扰动的作用(n = 16,只看低 4 位):
| key 的 hashCode | 不扰动:hashCode & 15 | 扰动后:h ^ (h>>>16) | 扰动后 & 15 |
|---|---|---|---|
0x12345678 | 8 | 0x1234444C | 12(0xC) |
0xABCD5678 | 8 ← 撞车 | 0xABCDFDB5 | 5 ← 错开了 |
两个 hashCode 的低 16 位都是 0x5678,不扰动时必然撞进同一个桶;扰动后高位信息混入低位,一个落 12 号桶、一个落 5 号桶,碰撞被化解了。
① 为什么用 ^:& 的结果偏向 0(任一操作数为 0 结果就是 0),| 偏向 1,只有 ^ 让每一位 0/1 各占一半,分布最均匀;② 为什么是 16:32 位整数正好一分为二,把高 16 位完整地"折叠"进低 16 位,信息损失最小。顺便对比:JDK 7 的扰动做了 4 次(^ (h>>>20) ^ (h>>>12) 再 ^ (h>>>7) ^ (h>>>4)),JDK 8 精简为 1 次——因为 JDK 8 的红黑树兜底让"更弱的扰动"也可接受,而 1 次异或性能更好。
定位桶下标的代码藏在 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 位,导致某些桶永远分不到元素,浪费空间且增大碰撞。
其实 2 的幂还有一个更关键的用途:扩容时可以用 hash & oldCap 一步判断新位置(第 7 站详讲),这是 JDK 8 不用重新计算下标的前提。所以"必须是 2 的幂"不是洁癖,是两处优化的地基。
那用户传入的容量不是 2 的幂怎么办?构造器里用 tableSizeFor() 强制纠正:
static final int tableSizeFor(int cap) {
int n = cap - 1; // 先减 1:防止 cap 本身是 2 的幂时结果翻倍
n |= n >>> 1; // 从最高位的 1 开始,向右全部"涂"成 1
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1
: (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
// 例:cap=7 → n=6(0110) → 涂完 7(0111) → 返回 8
// 例:cap=16 → n=15(1111) → 返回 16(不会错误地变成 32)
实战 Tips:面试时你可以补充"这就是为什么阿里规约要求 new HashMap(n) 时,建议传 (int) (n / 0.75 + 1),这样既避免了扩容,又保证了容量会被 tableSizeFor() 自动纠正为 2 的幂"。比如 new HashMap(7) 实际容量是 8,而不是 7。
put() 完整流程:putVal 源码逐行拆解
面试官说:"你画一下 HashMap 的 put 流程。" —— 这是一个经典的"白板编程题"。我们通过一张图把整个过程串起来:
入口 put() 本身极薄,真正干活的是 putVal():
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// onlyIfAbsent=true 时不覆盖旧值 —— putIfAbsent() 就是走这个参数
public V putIfAbsent(K key, V value) {
return putVal(hash(key), key, value, true, true);
}
// evict 参数服务于 LinkedHashMap 的"访问顺序"LRU 扩展,HashMap 不用
下面是 putVal() 的核心源码(含红黑树分支),图 4 的 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) // ⑤ 红黑树分支 → putTreeVal
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 → 尝试树化(treeifyBin 内部还会校验容量 ≥ 64)
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; // 结构性修改计数,fail-fast 依赖它(见第 10 站)
// ⑧ size 超过阈值 → 扩容
if (++size > threshold)
resize();
return null; // 新增 key:返回 null
}
几个容易忽略的细节:
- binCount 的计数起点:它从 0 开始,数的是"桶头节点 p 之后"的节点。当追加第 8 个节点时
binCount == 7 == TREEIFY_THRESHOLD - 1,触发treeifyBin()——注意这只是"申请",真正建树前还要过容量 ≥ 64 这一关。 - 返回值语义:覆盖已存在的 key 返回旧值,新增 key 返回 null。但如果旧值本身就是 null,返回值也是 null——所以不能用返回值区分"新增"和"覆盖为 null"。
- ⑤ 红黑树分支:
putTreeVal()在树里按红黑树规则插入;若 key 没实现Comparable,会退化用System.identityHashCode兜底比较(tieBreakOrder)。
重点理解第 ④ 步中 key 相同的判断逻辑(注意源码里的短路顺序):
先比较 hash(快,int 比较)→ 再比较 ==(引用相同,最快)→ 最后才 equals()(可能较慢)。这是典型的"先用低成本过滤,再用高成本确认"。也正因为这个顺序,hashCode 不同的两个对象,HashMap 直接认为它们不相等,根本不会调 equals() ——这就是必须同时正确实现 hashCode 和 equals 的根本原因。
如果你用可变对象做 key,并且修改了影响 hashCode 的字段,那么 HashMap 就再也找不到这个 entry 了——它还在原来的桶里,但 containsKey() 返回 false。这就是为什么推荐用 String、Integer 等不可变对象做 key。这也解释了 Node.key 为什么是 final:源码层面根本不打算让你改 key。
树化与退化:treeifyBin 与 split 方法级源码
上一站看到 putVal() 在链表第 8 个节点插入时调用 treeifyBin(tab, hash)。但树化不是"说树就树"的,treeifyBin() 内部会做双重检查:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 双重检查①:数组长度 < 64 → 不树化,直接扩容!
// 容量翻倍后元素重新分布,链表往往自己就变短了
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
// 双重检查②:先把链表节点逐个替换成 TreeNode,串成双向链表
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null) hd = p;
else { p.prev = tl; tl.next = p; }
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab); // 真正构建红黑树(按 hash 建树、旋转染色)
}
}
所以链表转红黑树要同时满足:putVal 侧的"长度 ≥ 8" + treeifyBin 侧的"容量 ≥ 64",缺一个都不树化。这也是面试官最爱挖的坑:"链表 9 个节点就一定会树化吗?" —— 答:不一定,容量不足 64 时走的是扩容。
容量 < 64 说明元素总量小(最多 64×0.75 ≈ 48 个),此时扩容成本极低,而且扩容后 hash & oldCap 会把一个长链拆成两半,问题迎刃而解;树化反而要付出 2 倍内存的 TreeNode 和旋转开销。类比后端:缓存容量还很大时,与其给单个热点 key 上复杂的淘汰策略,不如直接加实例扩容分流。
那么红黑树什么时候退化为链表?入口在扩容时:resize() 遇到树节点桶会调用 TreeNode.split(),它把一棵树按 hash & oldCap 拆成 lo/hi 两棵子树,拆完如果哪边节点数 ≤ 6,就直接退化成链表——这就是 UNTREEIFY_THRESHOLD = 6 真正的用武之地:
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
// bit = oldCap(旧容量),与链表拆分同一个判断:hash & oldCap
TreeNode<K,V> b = this;
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0; // 记录两条链各自节点数
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
if ((e.hash & bit) == 0) { // 新增位为 0 → 留原位 index
if ((e.prev = loTail) == null) loHead = e;
else loTail.next = e;
loTail = e; ++lc;
} else { // 新增位为 1 → 移 index + oldCap
if ((e.prev = hiTail) == null) hiHead = e;
else hiTail.next = e;
hiTail = e; ++hc;
}
}
if (loHead != null) {
// 关键:拆完 ≤ 6 个 → untreeify 退化为链表(省内存)
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else { tab[index] = loHead; if (hiHead != null) loHead.treeify(tab); }
}
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else { tab[index + bit] = hiHead; if (loHead != null) hiHead.treeify(tab); }
}
}
补充一个边界:除了扩容退化,remove() 删节点时如果树被删得"太扁"(根节点都没有右子树之类的结构判断),removeTreeNode() 也会直接 untreeify() 退化。也就是说退化路径有两条:扩容 split 按 6 判断、删除按结构判断,但"≤ 6"这个数字只出现在 split 里。
顺便把红黑树"是什么"补一句,面试被追问时不至于卡壳:红黑树是自平衡二叉查找树,5 条性质——① 节点非红即黑;② 根是黑;③ 叶子(null)是黑;④ 红节点的两个孩子必须是黑(不能出现连续红);⑤ 从任一节点到其每个叶子的所有路径,黑色节点数相同。这 5 条保证了树高 ≤ 2×log₂(n+1),所以查找稳定在 O(log n)。
- 树化双条件:
putVal里长度 ≥ 8(binCount ≥ TREEIFY_THRESHOLD - 1)+treeifyBin里容量 ≥ 64; - 容量不足 64 时"扩容优先于树化",因为扩容能拆散长链且成本低;
UNTREEIFY_THRESHOLD = 6用在TreeNode.split():扩容拆分后 ≤ 6 个节点就退化为链表;- 红黑树 5 条性质保证树高 O(log n),是"最坏情况"的兜底。
扩容 resize():2 倍扩容与 lo/hi 拆分
扩容是 HashMap 中最"贵"的操作——需要搬移已有元素。触发条件是 size > capacity × loadFactor,默认 16 × 0.75 = 12,即第 13 个元素插入时触发扩容。先看 resize() 的前半段——容量与阈值的计算:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) { // ① 常规扩容:容量 × 2
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE; // 到顶了:不再扩容,阈值放开
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY
&& oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 阈值也翻倍:12 → 24
}
else if (oldThr > 0) // ② 构造时指定过容量:
newCap = oldThr; // 容量暂存在 threshold 里,首次 resize 拿来建数组
else { // ③ 无参构造:用默认值
newCap = DEFAULT_INITIAL_CAPACITY; // 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
}
if (newThr == 0) { // ② 分支的阈值在这里补算
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY
? (int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab; // 新数组挂上去(后续再逐个搬元素)
这一段信息量很大,拆三点:
- 为什么容量能暂存在 threshold 里:构造器只算容量、不建数组(懒加载),于是把
tableSizeFor()的结果先塞进threshold,等第一次put()触发 resize 时再"物归原主"。这也是为什么new HashMap(7)第一次 put 才真正分配 8 长度的数组。 - 阈值翻倍的前提:只有
oldCap ≥ 16才走newThr = oldThr << 1;如果旧容量是 4/8 这类"用户指定的小容量",newThr保持 0,走下面ft = newCap × loadFactor重新算——因为此时旧阈值本来就不是"容量 × 0.75"(它被容量占用了)。 - 到顶保护:容量 ≥ 2³⁰ 时不再扩容,把 threshold 放开到
Integer.MAX_VALUE,让 map 继续能存数据。
再看后半段——元素搬移。这是 JDK 8 最精妙的部分:不需要重新计算每个元素的 hash。因为容量始终是 2 的幂,扩容 16 → 32 相当于掩码多出最高一位(1111₂ → 11111₂),每个元素的新下标只取决于 hash 新增的那一位是 0 还是 1:
- 如果
hash & oldCap == 0:新增位是 0,元素在新表中的位置不变; - 如果
hash & oldCap != 0:新增位是 1,元素的新位置 = 原位 + oldCap。
所以 1.8 扩容时把每个桶的链表拆成两条:"低位链表"(lo,位置不变)和"高位链表"(hi,位置 + oldCap),分别挂到新数组的两个位置上。整个搬移过程没有一次取模、没有一次重新调用 hashCode。面试时说到这个细节,面试官就知道你真正读过源码。
这段"低位 / 高位链表拆分"的逻辑,对应 resize() 里的经典源码:
// 桶里只有 1 个节点:直接按新掩码定位(还是位运算,无取模)
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 树桶 → 第 6 站
else {
// 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; }
}
对比:HashMap ×2 vs ArrayList ×1.5
面试常把两个"扩容"放一起问,一张表说清:
| 维度 | HashMap | ArrayList |
|---|---|---|
| 扩容倍数 | ×2(oldCap << 1) | ×1.5(old + (old >> 1),见 grow()) |
| 容量约束 | 必须是 2 的幂 | 任意正整数(默认 10) |
| 底层结构 | Node[] 哈希桶 | Object[] 连续数组 |
| 为什么这个倍数 | 必须保持 2 的幂,才能用 (n-1)&hash 和 hash&oldCap 优化 | 无位运算约束;1.5 倍在"扩容次数"与"平均内存浪费"之间折中(×2 浪费更多空间,×1.25 扩容太频繁) |
| 搬移成本 | O(n) 逐桶拆链(节点引用搬家) | O(n) 整块 Arrays.copyOf(内存拷贝) |
答案:2048(不是 1024)。公式:capacity = tableSizeFor((int)(expectedSize / 0.75 + 1)) = tableSizeFor(1334) = 2048。这样整个插入过程中一次扩容都不会发生(2048 × 0.75 = 1536 > 1000)。如果你设 1024,容量不够大,还是会触发 1-2 次扩容——扩容要搬移全部元素,批量加载场景下是实打实的 CPU 与 GC 开销。
JDK 7 头插 vs JDK 8 尾插:一次历史性的修复
1.8 扩容时用尾插保持顺序,1.7 却是头插、顺序会反转。这看起来只是"插在哪头"的差异,背后却是当年一起线上事故的根因。
JDK 1.7 的扩容:transfer + 头插
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;
}
}
两个关键差异:
- 逐元素重新计算下标:每个元素都要过一遍
indexFor(e.hash, newCapacity),虽然只是位运算,但 1.8 连这一步都省了(用hash & oldCap一次分流)。 - 头插导致链表逆序:新元素插在桶的头部,所以原本
A→B→C的链表,扩容后会变成C→B→A。
JDK 7 之所以用头插,是因为实现最简单:新节点直接挂桶头,不需要维护尾指针。但代价是扩容时链表逆序——而"逆序 + 节点引用交错"在多线程并发扩容时就会形成环形链表(两个线程同时 transfer 同一链表,线程 A 挂起,线程 B 迁移完(已逆序),线程 A 恢复后用旧的 next 引用继续头插,链就环上了)。环形链表一旦形成,后续 get() 在环里永远走不到 null,CPU 直接 100%。这个事故细节第 10 站展开。
1.8 改为尾插,配合 lo/hi 拆分保持链表顺序,从根上消除了环形链表。对比一下 1.7 与 1.8 的全貌:
| 维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 插入方式 | 头插 | 尾插 |
| hash 扰动 | 4 次:^(h>>>20) ^ (h>>>12) ^ (h>>>7) ^ (h>>>4) | 1 次:h ^ (h>>>16) |
| 扩容下标计算 | 每个元素 indexFor() 重新算 | hash & oldCap 一次分流,无需重算 |
| 扩容后链表顺序 | 逆序 | 保持原序 |
| 并发扩容 | 可能环形链表 → get 死循环 CPU 100% | 不再死循环(但仍不安全,见第 10 站) |
不是。尾插只是治好了"死循环",但并发 put() 依然会丢数据:两个线程同时发现某个桶为空,各自 new 一个节点往同一位置写,后写的覆盖先写的;size++ 也不是原子的,计数会错;扩容时两个线程同时 resize 会互相覆盖数组引用。所以 1.8 的 HashMap 依然绝不能用于并发——并发场景只有一个答案:ConcurrentHashMap。
get() 读路径:为什么平均是 O(1)
读操作是 HashMap 最频繁的路径,核心方法 getNode():
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;
// ① 表非空 && 桶非空(桶空直接返回 null,最快的失败路径)
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) {
// ③ 树桶 → 树内查找,O(log n)
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// ④ 链表 → 逐个比较,直到命中或走到 null
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null; // 没找到
}
读路径和写路径共享同一个 key 比较逻辑:先比 hash(int 相等),再比 ==,最后 equals(),层层过滤。复杂度结论:
| 场景 | get() 复杂度 | 说明 |
|---|---|---|
| 理想(hash 均匀) | O(1) | 桶首节点直接命中,只比一次 hash |
| 桶内是链表 | O(链长) ≤ O(n) | 平均很短,最坏退化 |
| 桶内是红黑树 | O(log n) | 即使全部碰撞也有兜底 |
所以"HashMap 是 O(1)"这个说法要严谨:平均 O(1),最坏 O(log n)(树化后),完全退化才是 O(n)。树化 + 良好扰动正是为了把"最坏情况"从 O(n) 压到 O(log n)。
HashMap 用 hashCode() 定位桶、用 equals() 在桶内查找。如果你只重写了 equals() 不重写 hashCode(),那么两个逻辑上相同的对象可能落在不同桶里——HashMap 永远找不到它。这是 Java 开发中最常见的 bug 之一。
把数据库查询结果按"订单号"聚合到 HashMap<OrderKey, List>,OrderKey 只重写了 equals() 没重写 hashCode()。结果同样的订单号查出两次,两次都落不同桶,containsKey() 永远 false,数据被重复聚合、报表翻倍——排查了半天才发现是 hashCode 契约被破坏。这就是阿里规约里"重写 equals 必须重写 hashCode"的来由。
还有一个小坑:get() 返回 null 可能是"key 不存在",也可能是"value 就是 null"。要区分必须用 containsKey()(它走同一个 getNode(),只看节点是否存在)。
- get 与 put 共用同一套"hash → 桶 → 桶内查找"路径,比较顺序永远是 hash → == → equals;
- 平均 O(1)、树桶 O(log n),前提是 hashCode 分布均匀;
- equals/hashCode 契约必须同时遵守,否则 HashMap 形同虚设;
- get 返回 null 有歧义,判断 key 是否存在用 containsKey()。
线程安全:死循环、fail-fast 与并发选型
某电商系统在"双十一"促销期间,多个线程同时往一个全局 HashMap 中写入缓存数据。JDK 1.7 环境下运行一段时间后,服务器 CPU 突然飙到 100%。排查发现:HashMap 在并发扩容时形成了环形链表,后续的 get() 操作陷入死循环。最终通过重启恢复,紧急将缓存容器替换为 ConcurrentHashMap。
1.8 虽然根治了死循环,但并发下还有两处"软伤":
- 数据丢失:两个线程同时命中空桶各自 new 节点,后写覆盖先写;
- 计数错乱:
++size非原子,多线程累加会丢更新,导致扩容时机漂移。
fail-fast:遍历时的保护机制
HashMap 不是线程安全,但它在遍历时有个防御机制——modCount。每次结构性修改(put 新增、remove、clear)都会 ++modCount;迭代器创建时记录期望值,每次 next() 校验,不一致立刻抛 ConcurrentModificationException:
// ✗ 错误:遍历中直接 remove → ConcurrentModificationException
for (String key : map.keySet()) {
if (shouldRemove(key)) map.remove(key);
}
// ✓ 正确 1:用 Iterator.remove()(会同步维护 modCount)
Iterator<String> it = map.keySet().iterator();
while (it.hasNext()) {
if (shouldRemove(it.next())) it.remove();
}
// ✓ 正确 2:JDK 8+ 用 removeIf(内部就是迭代器)
map.keySet().removeIf(key -> shouldRemove(key));
注意:fail-fast 只是"尽量早失败",它不保证任何一致性,更不能用来实现线程安全——它在单线程误删时给你报错,仅此而已。
并发方案对比
| 维度 | HashMap | Hashtable | ConcurrentHashMap (JDK 8) |
|---|---|---|---|
| 线程安全 | ❌ 否 | ✅ 全表 synchronized | ✅ CAS + synchronized(锁桶头节点) |
| null key/value | ✅ key 最多 1 个 null,value 不限 | ❌ 都不允许 | ❌ 都不允许 |
| 初始容量 / 扩容 | 16 / ×2 | 11 / ×2+1 | 16 / ×2 |
| 容量形式 | 2 的幂 | 素数(对取模 % 友好) | 2 的幂 |
| 定位方式 | (n-1) & hash | (hash & 0x7FFFFFFF) % len | (n-1) & hash |
| 锁粒度 | — | 整表(所有方法 synchronized) | 桶头节点(粒度到桶) |
| 结论 | 单线程首选 | ❌ 已淘汰 | ✅ 并发首选 |
补充几个易混点:Hashtable 不允许 null 是因为它直接调用 key.hashCode() 而没做 null 保护;ConcurrentHashMap 不允许 null 则是因为并发下无法区分"value 是 null"与"节点不存在",get() 返回 null 的语义会变得不可靠。Hashtable 初始容量 11、扩容 ×2+1,是因为它用 % 取模,素数容量能让取模分布更均匀——这是和 HashMap 完全不同的设计路线。
面试追问 & 生产最佳实践
追问 1:HashMap 和 Hashtable 的区别?
Hashtable:线程安全(synchronized) | 不允许 null | 初始 11 | 扩容 ×2+1 | 只有链表
追问 2:loadFactor 为什么默认是 0.75?
这是空间与时间的折中,详细推导见第 3 站:
- 1.0:空间满,碰撞严重,查询慢
- 0.5:碰撞少,一半空间浪费
- 0.75:泊松分布下,桶中元素超过 8 个的概率低于千万分之一(源码注释可查),与树化阈值 8 配套
追问 3:HashMap 遍历顺序为什么不稳定?
桶下标由 hash & (n-1) 决定,而 n(容量)会随扩容变化:扩容后元素下标重新分布(lo/hi 拆分),插入顺序自然被打乱。即使不扩容,不同 key 的 hash 落点也没有任何顺序可言。需要稳定顺序就用 LinkedHashMap(插入序/访问序)或 TreeMap(key 序),别指望 HashMap 保序。
生产环境 Checklist
- 指定初始容量:
new HashMap((int)(expectedSize / 0.75 + 1)),批量加载前先算好,避免反复扩容搬移; - 用不可变对象做 key:String、Integer、LocalDate 等;可变 key 改字段后
get()永远找不到; - 重写 equals() 必须重写 hashCode():IDEA/Lombok 可自动生成;
- 并发场景用 ConcurrentHashMap:不要在并发环境用 HashMap(1.8 不死循环了,但会丢数据);
- 遍历删除用 Iterator.remove() / removeIf():直接
map.remove()会抛 ConcurrentModificationException; - 需要顺序用 LinkedHashMap:比如做 LRU 缓存(
accessOrder=true+ 重写 removeEldestEntry); - 序列化注意:确保 key 和 value 都实现了 Serializable;
- 警惕"规律性"的 key:如 16 的倍数(0、16、32…)在容量 16 时全落 0 号桶,扰动函数救不了这类低位恒为 0 的 hashCode,分布会塌缩成单链——这也是为什么说"hashCode 的均匀性靠业务方保证"。
桶 = 数据库分片,扰动函数 = 分片键的散列算法,扩容 = 分库分表,红黑树 = 热点分片上的二级兜底(如缓存 + 索引)。好的分片键让流量均匀;分片键选得差(低位规律),一个分片被打爆——和 16 倍数 key 挤进 0 号桶是同一个道理。理解了 HashMap 的权衡,你就理解了分布式系统的权衡。
这一篇你掌握了什么
核心知识点回顾
- 数据结构:JDK 1.7 数组+链表(头插法)→ JDK 1.8 数组+链表+红黑树(尾插法);Node 与 TreeNode 是两代节点,内存约差 2 倍;
- 哈希计算:扰动函数
h ^ (h>>>16)(JDK 7 是 4 次扰动)+ 位运算取模hash & (n-1),n 必须是 2 的幂(tableSizeFor保证); - 阈值设计:树化 8 且 64(泊松分布 + 内存权衡),退化 6(滞回缓冲),负载因子 0.75(时间/空间折中,λ≈0.5);
- put 流程:判空初始化 → 定位桶 → 桶空直插 / 桶内遍历 → 覆盖或尾插 → 触发 treeifyBin → 超阈值 resize;put 返回旧值、新增返回 null;
- 扩容优化:1.8 无需重新 hash,
hash & oldCap直接决定新位置(0→原位,1→原位+oldCap),lo/hi 两条链尾插保序;HashMap ×2 与 ArrayList ×1.5 原因不同; - 线程安全:1.7 头插法并发扩容可形成环形链表死循环(CPU 100%),1.8 尾插法根治;但 1.8 仍会丢数据/计数错乱,并发必须用 ConcurrentHashMap;遍历有 fail-fast 保护(modCount);
- 最佳实践:指定初始容量
(expectedSize / 0.75 + 1)、不可变 key、equals/hashCode 契约、遍历删除用迭代器、警惕低位规律的 key。
一句话总结
HashMap = 用位运算换速度(扰动 + 掩码定位)、用阈值换平衡(8/64 树化、6 退化、0.75 负载)、用尾插换安全(1.8 修复死循环)。每一个数字、每一次迁移都不是偶然,背后都是"时间换空间、空间换时间"的经典权衡。
Comments · 评论