HashMap 一篇讲透:从数组、链表、红黑树到扩容,面试再也不怕被追问
如果 Java 面试只能挑一个集合类往死里问,那大概率就是 HashMap。 
很多人背过这样的答案:
JDK 8 的 HashMap 底层是数组 + 链表 + 红黑树,链表长度达到 8 会转红黑树,容量不足 64 会先扩容,负载因子默认 0.75。
这段话没错。
但问题是,面试官通常不会停在这里。
他很可能继续问:
- 为什么数组长度一定是 2 的幂?
- 为什么不是链表长度达到 8 就直接树化?
- HashMap 到底什么时候扩容?
- 扩容以后原来的节点去哪了?
- 为什么 JDK 8 扩容不用重新计算完整 Hash?
- 红黑树什么时候又退化成链表?
- 为什么树化是 8,退化却是 6?
- 为什么负载因子偏偏是 0.75?
put()从进去到结束到底经历了什么?
如果这些问题只能靠背,很容易问两层就断了。
其实 HashMap 没那么玄学。
只要把它当成一个不断在"查询效率、空间占用、冲突概率、维护成本"之间做平衡的数据结构,很多设计一下就能串起来。
这篇文章不打算带着你逐行啃源码,而是用一条完整的数据演化链,把 HashMap 真正讲明白。
一、先别管红黑树:HashMap 最核心的东西其实只有一个数组
先看最简单的 HashMap。
java
HashMap<String, String> map = new HashMap<>();
map.put("name", "阿豪");
map.put("age", "28");
很多人听到 Map,会下意识觉得它的底层一定存在某种神奇的 Key-Value 映射结构。
其实最核心的东西就是:
java
Node<K,V>[] table;
也就是一个数组。
可以先把 HashMap 想象成一排储物柜:
text
HashMap.table
下标
0 [ null ]
1 [ null ]
2 [ Node ]
3 [ null ]
4 [ Node ]
5 [ null ]
6 [ null ]
7 [ Node ]
...
15 [ null ]
数组里的每一个位置通常称为一个 桶(Bucket)。
Node 中则保存:
java
static class Node<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
你会发现它除了:
text
hash
key
value
之外,还有一个非常关键的东西:
text
next
这意味着 Node 天生就可以串成链表。
为什么要有链表?
因为 HashMap 无法保证两个不同的 Key 永远不会进入同一个数组位置。
这就是 Hash 冲突。
二、一个 Key 是怎么找到数组位置的?
假设:
java
map.put("name", "阿豪");
HashMap 不可能遍历整个数组寻找空位置。
它首先会根据 Key 得到一个 Hash 值。
JDK 8 中可以简化理解成:
java
int hash = key.hashCode();
实际上 HashMap 还会进行一次扰动:
java
(h = key.hashCode()) ^ (h >>> 16)
也就是:
text
原始 hashCode
↓
高 16 位参与扰动
↓
得到新的 hash
为什么要这么干?
因为最终计算数组位置的时候使用的是:
java
(n - 1) & hash
如果数组比较小,真正决定数组下标的主要是 Hash 值的低位。
如果一个对象:
text
高位变化很大
低位却高度相似
直接使用的话就容易发生碰撞。
所以 JDK 8 做了一次:
java
hash ^ (hash >>> 16)
让高位信息也参与到低位计算中。
最后定位桶:
java
index = (n - 1) & hash;
比如数组容量:
text
n = 16
那么:
text
index = hash & 15
最终得到一个 0~15 的下标。
整个过程就是:
text
Key
│
▼
hashCode()
│
▼
h ^ (h >>> 16)
│
▼
(n - 1) & hash
│
▼
得到数组下标
│
▼
table[index]
这就是 HashMap 为什么平均情况下查询速度能够接近 O(1)。
因为它不是:
text
从 table[0] 一路找
而是:
text
Key
↓
Hash
↓
直接定位 table[13]
三、真正的问题来了:两个 Key 算出了同一个位置怎么办?
假设:
text
Key A → table[5]
Key B → table[5]
Key C → table[5]
数组的 table[5] 总不能同时保存三个独立数组元素。
于是 HashMap 使用链表解决冲突。
最开始:
text
table[5]
│
▼
A
后来 B 也来了:
text
table[5]
│
▼
A ──→ B
C 又来了:
text
table[5]
│
▼
A ──→ B ──→ C
于是整个 HashMap 就从:
数组
变成了:
数组 + 链表
注意,这不是说 HashMap 整体变成了一条链表。
而是:
数组仍然是主体,只不过发生 Hash 冲突的某个桶内部使用链表保存多个节点。
例如:
text
HashMap
table[0] ──→ null
table[1] ──→ A
table[2] ──→ null
table[3] ──→ B ──→ C ──→ D
table[4] ──→ E
table[5] ──→ null
table[6] ──→ F ──→ G
所以理解 HashMap 的第一个关键点就是:
数组负责快速定位,链表负责解决 Hash 冲突。
四、put() 一个元素,到底经历了什么?
现在把整个 put() 流程展开。
假设:
java
map.put("name", "阿豪");
首先计算:
text
key
↓
hash
↓
数组下标 index
然后查看:
java
table[index]
情况一:这个桶是空的
例如:
text
table[6] → null
那最简单:
text
table[6]
│
▼
Node(name, 阿豪)
直接创建 Node 放进去。
情况二:桶里面已经有元素
例如:
text
table[6]
│
▼
A
这时候不能直接添加。
HashMap 首先需要判断:
当前 Key 和已有 Key 是不是同一个 Key?
判断会涉及:
java
hash
以及:
java
equals()
如果 Key 相同:
java
map.put("name", "张三");
map.put("name", "李四");
第二次不会创建两个 "name"。
而是:
text
name → 张三
更新为:
text
name → 李四
也就是覆盖旧 Value。
情况三:Key 不相同,只是 Hash 冲突
那就需要继续寻找。
如果当前桶是链表:
text
A → B → C
就沿着链表检查。
最终没有找到相同 Key:
text
A → B → C → D
JDK 8 使用尾插方式加入新节点。
但是问题很快就来了。
如果冲突越来越严重:
text
A → B → C → D → E → F → G → H → I → J → K...
HashMap 的性能是不是又退化了?
没错。
五、为什么链表长了以后必须考虑红黑树?
数组定位非常快。
理论上:
text
O(1)
但如果大量 Key 都落在同一个桶:
text
table[5]
A
↓
B
↓
C
↓
D
↓
E
↓
F
↓
...
那你虽然 O(1) 找到了:
text
table[5]
却还得继续在里面找。
链表查询复杂度:
text
O(n)
如果极端情况下 1000 个节点全部进入一个桶:
text
A → B → C → ... → 第 1000 个
最坏可能接近遍历 1000 个节点。
这时候:
HashMap 外面看起来还是 HashMap,里面已经快查成 LinkedList 了。
于是 JDK 8 引入了红黑树。
红黑树查找复杂度:
text
O(log n)
1000 个节点:
text
链表:最坏接近 1000 次
红黑树:
log₂(1000) ≈ 10
这就是非常巨大的差距。
所以 JDK 8 的 HashMap 实际结构应该描述成:
数组 + 链表 + 红黑树。
六、是不是链表达到 8 就一定变红黑树?
这是 HashMap 面试最大的坑之一。
很多人的答案:
链表长度达到 8 就转红黑树。
不完整。
HashMap 中有两个关键参数:
java
TREEIFY_THRESHOLD = 8;
MIN_TREEIFY_CAPACITY = 64;
树化除了链表足够长,还必须考虑:
text
table.length
完整逻辑应该理解成:
text
链表变长
│
▼
达到树化条件?
│
┌──────┴──────┐
│ │
否 是
│ │
保持链表 ▼
数组容量 >= 64?
│ │
否 是
│ │
▼ ▼
扩容 红黑树化
也就是说:
链表够长 + 数组容量小于 64
HashMap:
先扩容。
链表够长 + 数组容量至少 64
HashMap:
才真正考虑树化。
为什么?
因为红黑树不是免费的。
七、为什么容量小的时候宁愿扩容,也不愿直接上红黑树?
假设当前容量只有:
text
16
结果某个桶已经非常拥挤:
text
table[3]
A → B → C → D → E → F → G → H
这时候有两种可能。
第一种:
这些对象的 Hash 真的特别烂。
第二种:
数组本身太小了。
如果是第二种情况,最合理的方法根本不是建红黑树,而是把数组扩大:
text
16
↓
32
↓
64
数组扩大之后,原来挤在一个桶里的节点可能自然分散。
例如:
text
扩容前:
table[5]
A → B → C → D → E → F
扩容后:
text
table[5]
A → C → F
table[21]
B → D → E
原来的长链表自己就被拆开了。
这时候再建红黑树反而浪费。
所以 HashMap 的思路其实很聪明:
空间不够,优先扩空间;空间已经够大,冲突依然严重,才使用更复杂的数据结构。
这句话比死背:
text
8、64
重要得多。
八、红黑树到底解决了什么?
树化前:
text
table[5]
│
▼
A
│
▼
B
│
▼
C
│
▼
D
│
▼
E
│
▼
F
│
▼
G
│
▼
H
树化后可以抽象成:
text
D
/ \
B F
/ \ / \
A C E H
/
G
链表查询:
text
O(n)
红黑树查询:
text
O(log n)
但为什么 HashMap 不干脆从第一天开始:
每个桶全部用红黑树?
因为红黑树有成本。
Node 本来只需要:
text
hash
key
value
next
而 TreeNode 还需要维护类似:
text
parent
left
right
prev
red
插入、删除的时候还可能涉及:
text
旋转
变色
重新平衡
因此当一个桶里只有:
text
2~3 个节点
的时候,链表反而更加轻量。
这就是一个非常典型的工程思想:
数据少的时候选择简单结构,数据冲突严重的时候再切换复杂结构。
九、HashMap 到底什么时候扩容?
现在讲第二个核心机制:
text
resize()
HashMap 不可能一直使用一个长度为 16 的数组。
否则你往里面放:
text
10 万个元素
冲突会越来越严重。
所以 HashMap 有一个非常重要的概念:
text
threshold
扩容阈值大体可以理解成:
text
threshold = capacity × loadFactor
默认负载因子:
java
loadFactor = 0.75f;
如果容量:
text
16
那么阈值:
text
16 × 0.75 = 12
也就是说大体可以理解成:
text
容量:16
阈值:12
size 超过 12
│
▼
resize
│
▼
容量:32
继续:
text
32 × 0.75 = 24
于是整个容量变化大致是:
text
16
│
▼
32
│
▼
64
│
▼
128
│
▼
256
│
▼
512
...
每次基本翻倍。
十、为什么负载因子偏偏是 0.75?
这是一个非常适合面试继续追问的问题。
为什么不是:
text
0.5
也不是:
text
1.0
其实它是在:
空间利用率和 Hash 冲突概率之间做平衡。
如果负载因子特别小,比如:
text
0.2
16 个位置只放几个元素就扩容。
冲突确实少了。
但是:
text
大量数组位置一直是 null
空间浪费严重。
反过来,如果:
text
loadFactor = 1
甚至更高:
数组塞得越来越满。
空间利用率高了,但:
text
Hash 冲突概率 ↑
链表长度 ↑
查询成本 ↑
所以默认的:
text
0.75
就是一个工程上的折中。
可以把它理解成:
text
空间浪费 ◄──────────────► Hash 冲突
0.75
↑
平衡位置
不是一个神秘数字,而是时间和空间之间的 trade-off。
十一、HashMap 为什么要求容量是 2 的幂?
这是 HashMap 最漂亮的设计之一。
假设容量:
text
16
二进制:
text
0001 0000
那么:
text
n - 1 = 15
二进制:
text
0000 1111
HashMap 计算位置:
java
index = hash & (n - 1);
比如:
text
hash:
1011 0101
与:
text
0000 1111
进行 AND:
text
1011 0101
&
0000 1111
-----------
0000 0101
结果:
text
5
于是进入:
text
table[5]
这和:
java
hash % 16
在这种情况下能达到类似取模效果。
但是:
text
位运算通常非常高效
更重要的是,2 的幂还能让 HashMap 扩容时完成一个非常漂亮的优化。
十二、HashMap 扩容为什么不用重新计算完整位置?
假设原数组长度:
text
16
扩容后:
text
32
原位置:
java
hash & 15
新位置:
java
hash & 31
二进制看:
text
15 = 0000 1111
31 = 0001 1111
你会发现:
新计算只比原来多看了一位。
因此扩容后,一个元素的新位置只有两种情况:
第一种:位置不变
text
oldIndex
第二种:移动 oldCap
text
oldIndex + oldCap
比如原来:
text
table[5]
扩容:
text
16 → 32
那么这些节点的新位置只可能:
text
5
或者:
text
5 + 16 = 21
判断方式就是:
java
(hash & oldCap) == 0
十三、扩容时一条链表是怎么被拆开的?
这个地方非常值得画图。
假设扩容前:
text
capacity = 16
table[5]
A → B → C → D → E → F
HashMap 遍历这些节点。
根据:
java
(hash & oldCap)
将它们分成两组。
第一组:
text
lo 链表
第二组:
text
hi 链表
于是:
text
扩容前:
table[5]
│
▼
A → B → C → D → E → F
resize:16 → 32
┌───────────────┐
│ │
▼ ▼
lo 链表 hi 链表
A → C → E B → D → F
│ │
▼ ▼
table[5] table[21]
注意:
text
21 = 5 + 16
这就是 JDK 8 resize 很漂亮的地方。
不是重新:
text
重新算 Hash
重新取模
重新乱七八糟分配
而是:
根据新增的那一位,把原桶拆成 low 和 high 两组。
十四、红黑树会不会永远都是红黑树?
不会。
HashMap 不是:
text
链表 → 红黑树
以后就再也回不去了。
还有一个重要常量:
java
UNTREEIFY_THRESHOLD = 6;
可以简单记:
text
TREEIFY_THRESHOLD = 8
UNTREEIFY_THRESHOLD = 6
也就是:
text
节点较多
│
▼
红黑树
│
节点减少
▼
重新变回链表
注意,源码中的实际退化判断还会结合删除、扩容拆树后的具体树结构,并不是所有场景都能机械理解成"size 一到 6 立即退化"。
但面试理解层面可以抓住核心:
节点少了以后继续维护红黑树已经不划算,所以 HashMap 会在适当条件下重新链表化。
十五、为什么树化是 8,退化却是 6?
如果两个阈值都是:
text
8
会发生什么?
假设节点数量不停变化:
text
7
↓
8
↓
7
↓
8
↓
7
↓
8
那么数据结构可能不断:
text
链表
↓
红黑树
↓
链表
↓
红黑树
↓
链表
这会产生大量没有意义的结构转换。
所以 HashMap 故意留出一个缓冲区:
text
6 8
│-------------│
缓冲区域
可以把它理解成一种"防抖"。
和很多系统设计其实是一个思想。
比如 CPU 温度控制如果:
text
80℃ 开风扇
80℃ 关风扇
温度在 79.9 和 80.1 之间波动时,风扇可能不停:
text
开
关
开
关
更合理的是:
text
80℃ 开
70℃ 再关
HashMap 的:
text
8 / 6
本质上也有类似思想:
避免临界值附近反复横跳。
十六、所以 HashMap 的完整数据结构到底是什么?
到这里,我们终于可以把整张图画出来。
text
HashMap
│
▼
Node[] table
│
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
table[1] table[5] table[9]
│ │ │
▼ ▼ ▼
Node A Node B TreeNode
│ │
▼ ▼
Node C D
│ / \
▼ B F
Node D / \ / \
A C E G
单节点 链表 红黑树
因此所谓:
JDK 8 HashMap = 数组 + 链表 + 红黑树
并不是三个东西平级混在一起。
更准确的说法应该是:
HashMap 最外层永远是数组;数组的每一个桶,根据冲突情况,可能是空、单节点、链表或者红黑树结构。
这个理解非常重要。
十七、把 put() 的完整流程一张图串起来
如果只允许背一张图,我建议背下面这张。
text
map.put(key, value)
│
▼
计算 key 的 hash
│
▼
index = (n - 1) & hash
│
▼
找到 table[index]
│
┌────────────┴────────────┐
│ │
桶为空 桶不为空
│ │
▼ ▼
直接插入 首节点 Key 相同?
│
┌───────────┴───────────┐
│ │
是 否
│ │
▼ ▼
覆盖 value 当前是红黑树?
│
┌──────────┴──────────┐
│ │
是 否
│ │
▼ ▼
树中查找/插入 遍历链表
│
┌──────────┴─────────┐
│ │
找到相同 Key 没找到
│ │
▼ ▼
覆盖 value 尾插节点
│
▼
链表达到树化条件?
│
┌──────────┴──────────┐
│ │
否 是
│ │
完成 table.length >= 64?
│
┌──────────┴─────────┐
│ │
否 是
│ │
▼ ▼
扩容 红黑树化
插入完成
│
▼
size 是否超过 threshold
│
┌──────────┴──────────┐
│ │
否 是
│ │
▼ ▼
结束 resize()
你会发现:
HashMap 根本不是一堆毫无关系的八股文。
它实际上是一条非常完整的逻辑链。
十八、真正理解 HashMap,只需要记住三个"矛盾"
如果不想死背源码,可以记住 HashMap 一直在解决三个问题。
第一个矛盾:查询速度 vs Hash 冲突
数组:
text
查询快
但是:
text
Hash 冲突无法避免
于是:
text
数组 + 链表
第二个矛盾:链表简单 vs 查询退化
链表:
text
结构简单
空间成本低
但是太长:
text
O(n)
于是:
text
链表 → 红黑树
把极端查询优化到:
text
O(log n)
第三个矛盾:空间利用率 vs 冲突概率
数组太空:
text
浪费内存
数组太满:
text
Hash 冲突严重
所以:
text
loadFactor = 0.75
在二者之间找平衡。
所以你会发现:
HashMap 几乎所有设计,都不是为了追求某一个指标的极致,而是在时间、空间和实现复杂度之间做工程折中。
这才是理解 HashMap 最重要的地方。
十九、面试官问"HashMap 底层原理",怎么回答最漂亮?
如果面试让我回答,我不会一上来背源码。
我会这么说:
JDK 8 的 HashMap 最外层本质上是 Node 数组,通过 Key 的 hash 值与数组长度减一进行位运算来确定桶的位置,所以正常情况下能够实现接近 O(1) 的查询。
当不同 Key 定位到同一个桶时就产生 Hash 冲突,HashMap 会通过链表保存这些节点。随着冲突增加,如果链表过长,链表查询会退化到 O(n),所以 JDK 8 引入了红黑树,将极端情况下的查询复杂度优化到 O(log n)。
不过 HashMap 并不是链表一长就直接树化。当达到树化阈值时,如果数组容量还小于 64,会优先扩容,因为很多冲突可能只是数组容量太小造成的;只有容量足够大而冲突仍然严重时,才值得维护红黑树。
HashMap 默认负载因子是 0.75,当 size 超过扩容阈值后会进行 resize,容量通常扩大为原来的两倍。因为容量始终保持为 2 的幂,扩容以后节点的新位置只可能是原位置或者原位置加 oldCap,因此 JDK 8 可以通过
(hash & oldCap)高效完成节点迁移。红黑树节点减少后,在合适条件下又可以退化为链表,从而避免节点很少时仍承担红黑树额外的空间和维护成本。
所以 HashMap 的整个设计,本质上是在查询效率、Hash 冲突、空间利用率以及复杂数据结构维护成本之间不断做平衡。
如果这一段能真正理解,而不是背出来,HashMap 基础原理这一轮基本就站住了。
二十、最后用 10 句话彻底记住 HashMap
面试前没时间了,就看这里:
- HashMap 最外层本质是 Node 数组。
- Key 经过 Hash 扰动后,通过
(n - 1) & hash定位桶。 - 不同 Key 进入同一个桶就是 Hash 冲突。
- JDK 8 使用链表保存同一个桶里的多个普通节点。
- 链表过长会导致查询从理想 O(1) 向 O(n) 退化。
- 达到树化条件且数组容量至少为 64 时,可以转换为红黑树,把桶内查询优化到 O(log n)。
- 容量不足 64 时优先扩容,而不是急着树化。
- 默认负载因子是 0.75,size 超过 threshold 后通常触发扩容。
- 容量通常按 2 倍增长,节点扩容后只需要判断留在 oldIndex,还是移动到 oldIndex + oldCap。
- 红黑树节点减少后可在适当条件下退化为链表,树化阈值 8、退化参考阈值 6,避免频繁转换。
最后你再回头看这几个数字:
text
默认初始容量:16
默认负载因子:0.75
树化阈值:8
退化阈值:6
最小树化容量:64
扩容倍率:2 倍
这时候它们就不应该再是六个需要死记硬背的数字了。
它们背后其实只有一个设计原则:
text
HashMap
│
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
查询效率 空间利用率 冲突处理
│ │ │
└─────────────┼─────────────┘
│
▼
动态平衡
│
┌───────────────┼───────────────┐
▼ ▼ ▼
数组 链表 红黑树
O(1) O(n) O(log n)
│ │ │
└───────────────┼───────────────┘
▼
resize / treeify / untreeify
HashMap 真正厉害的地方,从来不是"用了红黑树"。
而是它知道:
什么时候数组最划算,什么时候链表够用,什么时候应该花成本上红黑树,又什么时候应该扩容让数据重新分散。
这其实也是很多优秀系统设计的共同思想:
简单结构能解决的问题,不要过早复杂化;当规模真正上来以后,再用更高成本的结构换取稳定的性能。
理解了这一点,HashMap 就不再是一道需要背几十个源码细节的八股文,而是一套非常漂亮的工程设计。