下面以 JDK 8+ 的 java.util.HashMap 源码实现 为主,从底层结构、put、get、扩容、红黑树化等角度说明 HashMap 的原理。
1. HashMap 的底层数据结构
JDK 8 之后,HashMap 的底层结构是:
text
数组 + 链表 + 红黑树
核心字段大致如下:
java
transient Node<K,V>[] table;
transient int size;
int threshold;
final float loadFactor;
其中:
table:哈希桶数组Node<K,V>:链表节点size:当前键值对数量threshold:扩容阈值loadFactor:负载因子,默认0.75
节点结构如下:
java
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
每个元素最终都会落到 table 数组的某个位置上。如果多个 key 落到同一个数组下标,就会形成链表;当链表过长时,链表会转换成红黑树。
2. put 的源码流程
调用:
java
map.put(key, value);
实际会进入:
java
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
2.1 先计算 hash
源码中 hash 方法类似:
java
static final int hash(Object key) {
int h;
return key == null ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里不是直接使用 key.hashCode(),而是做了一次扰动:
java
h ^ (h >>> 16)
原因是 HashMap 定位桶下标时主要依赖低位:
java
(n - 1) & hash
如果只用原始 hashCode,高位信息可能浪费。通过高 16 位和低 16 位异或,可以让高位信息参与到低位计算中,减少哈希冲突。
3. HashMap 如何定位数组下标
HashMap 的数组长度始终是 2 的幂,例如:
text
16, 32, 64, 128 ...
定位下标的方式是:
java
index = (n - 1) & hash;
这里 n 是数组长度。
如果 n = 16,那么:
text
n - 1 = 15
二进制:0000 1111
所以:
text
hash & 0000 1111
相当于取 hash 的低 4 位。
这和取模类似:
java
hash % n
但位运算效率更高。
前提是数组长度必须是 2 的幂,否则 (n - 1) & hash 不能等价于取模,也会导致分布不均匀。
4. putVal 核心逻辑
putVal 的核心流程可以概括为:
java
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
i = (n - 1) & hash;
if ((p = tab[i]) == null) {
tab[i] = newNode(hash, key, value, null);
} else {
Node<K,V> e;
K k;
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);
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
return null;
}
put 的主要步骤
- 如果
table为空,先初始化数组。 - 通过
(n - 1) & hash计算桶下标。 - 如果该位置为空,直接插入新节点。
- 如果该位置不为空:
- 如果第一个节点 key 相同,覆盖 value。
- 如果是红黑树节点,走红黑树插入逻辑。
- 否则遍历链表:
- 找到相同 key,覆盖 value。
- 找不到,则尾插新节点。
- 插入后,如果链表长度达到阈值,尝试树化。
- 插入成功后,
size++。 - 如果
size > threshold,触发扩容。
5. get 的源码流程
调用:
java
map.get(key);
实际会进入:
java
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
核心逻辑是:
java
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;
}
get 的主要步骤
- 计算 key 的 hash。
- 通过
(n - 1) & hash找到桶下标。 - 如果桶为空,返回
null。 - 先检查桶中的第一个节点。
- 如果第一个节点不是目标:
- 如果是红黑树,按红黑树查找。
- 否则遍历链表查找。
- 找到返回 value,找不到返回
null。
6. 为什么 HashMap 的容量必须是 2 的幂
源码中容量会被调整为 2 的幂。
例如构造方法中指定容量:
java
new HashMap<>(10)
内部不会真的使用 10,而是会调整为 16。
相关方法是:
java
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 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 的最小 2 的幂。
例如:
text
cap = 10 => 16
cap = 17 => 32
cap = 33 => 64
原因主要有两个:
6.1 位运算替代取模
java
(n - 1) & hash
比:
java
hash % n
更快。
6.2 扩容时迁移更高效
当容量从 oldCap 扩容到 newCap = oldCap * 2 时,一个节点的新位置只有两种可能:
text
原下标
原下标 + oldCap
判断依据是:
java
(e.hash & oldCap) == 0
如果为 0,位置不变;否则位置变成 原下标 + oldCap。
7. resize 扩容机制
默认情况下,第一次插入时初始化容量为 16,负载因子为 0.75。
所以默认扩容阈值:
text
threshold = 16 * 0.75 = 12
当元素数量超过 12 时,就会扩容到 32。
resize 的核心逻辑
源码核心思想如下:
java
if (++size > threshold)
resize();
扩容时:
- 新容量变为旧容量的 2 倍。
- 新阈值也通常变为旧阈值的 2 倍。
- 旧数组中的节点重新分布到新数组中。
JDK 8 的扩容优化很重要。
对于旧桶中的链表,扩容时不会重新计算完整 hash,而是拆成两组:
java
if ((e.hash & oldCap) == 0) {
// 留在原位置
} else {
// 移动到 原位置 + oldCap
}
比如旧容量是 16,扩容后是 32:
text
旧下标:i
新下标:i 或 i + 16
这是因为扩容后参与下标计算的二进制位多了一位。
8. 链表什么时候变红黑树
JDK 8 引入红黑树,是为了避免极端哈希冲突时链表过长,导致查询退化到 (O(n))。
相关常量:
java
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
含义:
TREEIFY_THRESHOLD = 8- 单个桶中链表长度达到 8 时,尝试树化。
UNTREEIFY_THRESHOLD = 6- 红黑树节点数量减少到 6 以下时,可能退化回链表。
MIN_TREEIFY_CAPACITY = 64- 只有数组容量至少为 64 时,才允许树化。
注意:链表长度达到 8 并不一定马上树化。
源码中 treeifyBin 会先判断:
java
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else
// treeify
也就是说:
- 如果数组长度小于 64,优先扩容。
- 如果数组长度至少 64,才真正转红黑树。
原因是:冲突严重有可能是数组太小导致的,此时扩容比树化更划算。
9. 红黑树节点 TreeNode
链表节点是 Node,红黑树节点是 TreeNode。
TreeNode 继承自 LinkedHashMap.Entry,大致结构如下:
java
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
boolean red;
}
红黑树查找时,如果 hash 不同,根据 hash 大小走左右子树。
如果 hash 相同,则还需要比较 key:
- 如果
equals相同,说明找到目标。 - 如果 key 实现了
Comparable,按比较结果决定方向。 - 如果不能比较,则使用
tieBreakOrder做兜底排序。
10. HashMap 如何处理 hash 冲突
hash 冲突指的是不同 key 计算出的数组下标相同。
处理方式:
text
链地址法
即同一个桶下多个节点通过 next 串起来。
JDK 8 之后:
text
冲突较少:链表
冲突严重:红黑树
所以复杂度大致是:
| 场景 | 时间复杂度 |
|---|---|
| 理想情况 | (O(1)) |
| 链表冲突严重 | (O(n)) |
| 红黑树化后 | (O(\log n)) |
11. key 相等的判断逻辑
源码中判断 key 是否相等,通常是:
java
p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))
也就是说,判断一个 key 是否相同,需要同时满足:
- hash 值相同
- key 引用相同,或者
equals返回 true
因此,如果自定义对象作为 HashMap 的 key,必须正确重写:
java
hashCode()
equals()
并且要求:
text
equals 相等的对象,hashCode 必须相等
否则 HashMap 可能无法正确查找元素。
12. null key 如何处理
HashMap 允许一个 null key。
源码中:
java
static final int hash(Object key) {
int h;
return key == null ? 0 : ...
}
所以:
java
hash(null) = 0
null key 一定会落到:
java
index = (n - 1) & 0 = 0
也就是 table[0]。
因此,HashMap 中 null key 存储在第 0 个桶中。
13. JDK 7 和 JDK 8 的主要区别
JDK 7
底层结构:
text
数组 + 链表
插入方式:
text
头插法
扩容时链表顺序可能反转。
在并发环境下,JDK 7 的 HashMap 扩容可能导致链表形成环,进而出现死循环问题。
JDK 8
底层结构:
text
数组 + 链表 + 红黑树
插入方式:
text
尾插法
扩容时会保持链表相对顺序。
并且引入红黑树,降低极端冲突情况下的查询复杂度。
不过需要注意:
text
HashMap 仍然不是线程安全的
并发场景应使用:
java
ConcurrentHashMap
14. 为什么负载因子默认是 0.75
负载因子影响两个方面:
- 空间利用率
- 查询效率
如果负载因子太小,例如 0.5:
text
优点:冲突少,查询快
缺点:数组更稀疏,占用空间更多
如果负载因子太大,例如 1.0:
text
优点:空间利用率高
缺点:冲突概率增加,查询可能变慢
0.75 是时间和空间之间的折中选择。
默认容量 16,负载因子 0.75:
text
16 * 0.75 = 12
即插入第 13 个元素时触发扩容。
15. remove 的源码流程
remove(key) 最终会调用:
java
removeNode(hash(key), key, null, false, true)
主要逻辑:
- 根据 hash 找到桶下标。
- 在桶中查找目标节点:
- 第一个节点
- 红黑树节点
- 链表节点
- 找到后删除:
- 链表中修改
next - 红黑树中删除树节点并重新平衡
- 链表中修改
size--modCount++
其中 modCount 用于支持 fail-fast 机制。
16. fail-fast 机制
HashMap 的迭代器不是线程安全的。
迭代时会记录当前的 modCount:
java
int expectedModCount = modCount;
如果遍历过程中发现:
java
modCount != expectedModCount
就会抛出:
java
ConcurrentModificationException
例如:
java
for (String key : map.keySet()) {
map.put("x", "y");
}
这类结构性修改会触发 fail-fast。
注意,fail-fast 不是并发安全机制,只是一种快速失败检测。
17. HashMap 的整体 put 示例
假设:
java
HashMap<String, Integer> map = new HashMap<>();
map.put("abc", 1);
流程如下:
text
1. table 初始为空
2. 调用 put
3. 计算 hash("abc")
4. table 为空,调用 resize 初始化为 16
5. index = (16 - 1) & hash
6. table[index] 为空
7. 创建 Node 放入该桶
8. size 变为 1
9. size <= threshold,不扩容
如果继续插入另一个 key,算出的 index 相同:
text
1. 找到同一个桶
2. 发现桶不为空
3. 判断第一个节点 key 是否相同
4. 不相同,则遍历链表
5. 插入到链表尾部
6. 如果链表长度达到 8,尝试树化
18. 总结
HashMap 的底层原理可以概括为:
text
HashMap 使用数组作为主体结构,通过 key 的 hash 定位桶位置。
当多个 key 落到同一个桶时,使用链表解决冲突。
JDK 8 之后,当单个桶中链表过长并且数组容量足够大时,会转换为红黑树。
put 时先计算扰动 hash,再通过 (n - 1) & hash 定位桶。
get 时使用同样的方式定位桶,然后在桶中比较 hash 和 equals。
当元素数量超过 threshold 时触发 resize,容量扩为原来的 2 倍。
扩容时节点只会留在原位置或移动到 原位置 + oldCap。
面试中可以简洁回答:
JDK 8 的
HashMap底层是数组、链表和红黑树。它先对 key 的hashCode做扰动运算,然后通过(table.length - 1) & hash定位桶。桶为空就直接插入,桶不为空则比较 hash 和 equals,相同则覆盖,不同则接到链表尾部;当链表长度达到 8 且数组容量至少 64 时,链表转红黑树。默认容量 16,负载因子 0.75,元素数量超过阈值时扩容为原来的 2 倍。扩容时节点的新位置要么不变,要么变为原下标加旧容量。HashMap允许一个 null key,但不是线程安全的。