HashMap-博客园版
一文吃透 HashMap:从结构图到 JDK 8 源码
这是博客园专用版:三张结构图已直接内嵌在文中(不用另外上传图片),第八节的交互演示可直接在页面上点击测试。
一句话概括:HashMap = 数组(负责一步定位)+ 链表(负责解决冲突)+ 红黑树(负责防止退化)。
本文源码全部取自 JDK 8(java.util.HashMap),为便于阅读做了少量删减。
文末有一个可以在线点击的交互演示,输入 key 就能看到它落在哪个桶、什么时候碰撞、什么时候扩容。
一、先看整体长什么样
<text style="font-family:-apple-system, "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;font-size:12px" x="212" y="418" fill="#CECBF6" dominant-baseline="central">链表长度 ≥ 8 且容量 ≥ 64 → 转成红黑树
只需要记住三个零件:
| 零件 | 是什么 | 作用 |
|---|---|---|
table[] |
主干数组,每个位置叫一个桶(bucket / bin) | 用下标直接访问,O(1) 定位 |
Node |
存 4 个字段:hash / key / value / next |
真正装数据的容器 |
| 桶里的内容 | null、链表头节点、或红黑树根节点 |
空 / 少量冲突 / 大量冲突 三种形态 |
Node 的源码(JDK 8):
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 预先算好的 hash,避免每次比较都重算
final K 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;
}
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final boolean equals(Object o) {
if (o == this) return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
注意 hash 是 final 的 —— 节点一旦创建,它的哈希值就不许再变。这也是后面"key 要不可变"要求的根源。
二、先记住这几个常量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16,默认初始容量
static final int MAXIMUM_CAPACITY = 1 << 30; // 2^30,容量上限
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认负载因子
static final int TREEIFY_THRESHOLD = 8; // 链表 → 树 的阈值
static final int UNTREEIFY_THRESHOLD = 6; // 树 → 链表 的阈值
static final int MIN_TREEIFY_CAPACITY = 64; // 允许树化的最小数组容量
| 常量 | 值 | 一句话解释 |
|---|---|---|
DEFAULT_INITIAL_CAPACITY |
16 | 默认数组长度 |
DEFAULT_LOAD_FACTOR |
0.75 | 装到 75% 就扩容,空间与时间的折中 |
TREEIFY_THRESHOLD |
8 | 链表挂到第 9 个节点时申请树化 |
UNTREEIFY_THRESHOLD |
6 | 树中节点掉到 6 个以下时退回链表 |
MIN_TREEIFY_CAPACITY |
64 | 数组没到 64,宁可扩容也不树化 |
为什么树化阈值是 8、退化的阈值是 6 而不是 7?中间隔的 7 就是缓冲带 —— 防止某个桶在阈值附近反复增删时,链表和树来回转换、白白消耗性能。
另外源码注释里提到:在负载因子 0.75 的假设下,hashCode 分布良好时,单个桶达到 8 个元素的概率约为 6×10⁻⁸,属于极端情况。
三、hash 值到底是怎么算出来的
3.1 扰动函数
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
只有两行,但藏着一个关键设计:h ^ (h >>> 16)。
定位数组下标时用的是 (n - 1) & hash,而 n 通常只有 16 / 32 / 64,也就是说真正参与运算的只有 hash 的最低几位,高位完全被忽略。如果只拿原始 hashCode() 去定位,高位再怎么变化也影响不了结果,碰撞概率会明显上升。
所以把高 16 位右移下来,跟低 16 位做一次异或 —— 高位的信息被"折"进了低位,这就是所谓的扰动函数。它不保证不碰撞,但让分布更均匀,代价只是一次异或。
顺带一提,key == null 时 hash 固定为 0,这就是为什么 HashMap 允许一个 null key(它永远落在 0 号桶)。
3.2 为什么用 & (n-1) 而不是 % n
当 n 是 2 的幂时,下面两个式子完全等价:
hash % n == hash & (n - 1)
举例:n = 16,n - 1 = 15,二进制是 1111。hash & 15 就是取 hash 的最低 4 位,正好是 hash % 16 的结果。
位运算比取模快一个数量级,这就是容量必须是 2 的幂的根本原因。
3.3 那我传了 10 怎么办?tableSizeFor
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;
}
这一串或运算的作用:把最高位 1 右边的所有位全填成 1,最后 +1 就得到大于等于 cap 的最小 2 的幂。
比如 cap = 10 → n = 9 (0b1001) → 一路填位得到 0b1111 = 15 → 返回 16。
所以 new HashMap<>(10) 的真实初始容量是 16,不是 10。
四、put 一次,内部到底发生了什么
put 本身只是一层皮,真正干活的是 putVal:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// ① 懒加载:第一次 put 时才分配数组
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;
// ③-a 头节点就是要找的 key,记下来等会儿覆盖
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// ③-b 这个桶已经是一棵树了,走红黑树的插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// ③-c 是链表,尾插法逐个往后挂
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;
}
// 中途发现相同的 key,停止遍历
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// ④ 找到了已存在的 key:覆盖 value,返回旧值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// ⑤ 元素个数超过阈值 → 扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
几个容易被忽略的点:
- 数组是懒加载的:
new HashMap<>()时table还是null,第一次put才分配 16 个格子。 - 判断 key 相同的完整条件:
p.hash == hash && (p.key == key || key.equals(k))—— 先比哈希(快),哈希相同再用==或equals确认。这就是为什么重写equals()必须同时重写hashCode(),否则两个逻辑相等的对象会有不同的 hash,永远落不到同一个桶,也永远"找不到"。 - JDK 8 用尾插法:JDK 7 是头插法,多线程扩容时会把链表反转成环导致死循环;JDK 8 改成尾插,修掉了这个 bug(但 HashMap 依然线程不安全)。
binCount >= TREEIFY_THRESHOLD - 1:binCount从 0 开始数,所以减 1,实际是链表第 9 个节点时触发。
五、哈希碰撞与树化
<text style="font-family:-apple-system, "Segoe UI", "Microsoft YaHei", "PingFang SC", sans-serif;font-size:12px" x="56" y="280" fill="#B4B2A9" dominant-baseline="central">查找从 O(n) 降到 O(log n);节点数 ≤ 6 时退化回链表(untreeify)
注意 treeifyBin 的第一个判断 —— 树化的两个条件是"与"关系,不是"或":
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 数组还太小?先扩容,不树化
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;
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); // 真正的建树 + 红黑着色
}
}
为什么要加"容量 ≥ 64"这一条?因为链表变长的更常见原因是数组太小,扩容一次就能把长链表拆成两条短的,成本远低于维护一棵红黑树(TreeNode 的内存占用约是 Node 的两倍)。只有数组已经足够大、碰撞依然严重(说明 hashCode 本身质量差或遭遇了哈希攻击),才值得动用红黑树把 O(n) 降到 O(log n)。
六、扩容 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) {
if (oldCap >= MAXIMUM_CAPACITY) { // 已经到顶了,不再扩
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 容量翻倍,阈值也翻倍
}
else if (oldThr > 0)
newCap = oldThr; // 构造时指定了初始容量
else {
newCap = DEFAULT_INITIAL_CAPACITY; // 默认 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
}
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null) // 单节点,直接算新下标
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode) // 树,调用 split
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 链表:拆成 lo / hi 两条
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) { // 关键判断:这一位是 0 还是 1
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);
if (loTail != null) { loTail.next = null; newTab[j] = loHead; }
if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }
}
}
}
}
return newTab;
}
(e.hash & oldCap) == 0 是全段最漂亮的一句。
容量从 16 扩到 32,掩码从 0b1111 变成 0b11111,多出来的那一位就是 0b10000(正好等于 oldCap)。所以判断新下标只需要看 hash 的这一位:
- 这一位是 0 → 新下标 = 旧下标,留在原地;
- 这一位是 1 → 新下标 = 旧下标 + oldCap。
也就是说:不需要重新计算 hash,不需要逐个取模,遍历一次链表就能把它拆成两条。而且 loHead / hiTail 是保持原顺序拼接的,不会像 JDK 7 那样反转链表 —— 这也是修复死循环的另一半原因。
七、get 是怎么找到的
get 就是 put 的逆向操作,同样先定位桶,再在桶里找:
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;
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) // 树:O(log n)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do { // 链表:O(n)
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
时间复杂度一目了然:无碰撞 O(1),链表 O(n),红黑树 O(log n)。
顺带一个高频问题:
HashMap里get返回null,既可能是"key 不存在",也可能是"key 存在但 value 就是 null"。要区分请用containsKey()。
八、动手试一试:在线模拟桶、碰撞与扩容
下面的演示完全复刻 Java 的规则:String.hashCode() 的 31 倍累加 → 扰动函数 h ^ (h>>>16) → 定位 (n-1) & hash → 负载因子 0.75 → 超阈值扩容 2 倍。
九、高频问题速查
| 问题 | 答案 |
|---|---|
| 默认容量 / 负载因子 | 16 / 0.75,阈值 12 |
| 什么时候扩容 | ++size > threshold,即元素数超过 容量 × 0.75 |
| 扩容变成多少 | 2 倍(左移 1 位) |
| 什么时候树化 | 链表长度 ≥ 8 且 数组容量 ≥ 64;否则优先扩容 |
| 什么时候退化 | 扩容或删除后树中节点 ≤ 6 |
| 为什么容量是 2 的幂 | 为了用 & (n-1) 代替 % n,并且扩容时能靠一位判断拆分链表 |
| 允许 null 吗 | 允许 1 个 null key(hash 固定为 0)、任意多个 null value |
| 线程安全吗 | 不安全。多线程用 ConcurrentHashMap,或 Collections.synchronizedMap |
| JDK 7 的死循环 | JDK 7 扩容用头插法会反转链表,并发下成环;JDK 8 改尾插修复 |
| 有序吗 | 无序,遍历顺序不保证,也不保证长期不变 |
实践建议:
- 已知元素数量时预设初始容量:
new HashMap<>(expected / 0.75 + 1),避免反复扩容和 rehash。 - key 用不可变对象(
String、Integer、或final字段的自定义类)。放进 HashMap 后再修改参与hashCode()的字段,这个元素就永久失联了。 - 自定义 key 必须同时重写
hashCode()和equals(),且遵守"equals 相等则 hashCode 必相等"。 - 不要在遍历过程中
remove—— 用iterator.remove(),否则ConcurrentModificationException。
十、发布到博客园(cnblogs)的注意事项
这篇里的三张图已经直接内嵌在 Markdown 当中(就是那三段 <svg>...</svg>),不需要另外上传图片文件;第八节的交互演示是 HTML + JS,同样内嵌在本文件里。
发布步骤
- 进博客园后台 → 随笔 → 新建,编辑器切到 Markdown 模式
- 把本文件全文复制粘贴进去
- 勾选编辑器的「允许 HTML」;如果交互演示没反应,需要在「设置 → 博客设置」里开通 JS 代码 权限
- 保存 / 发布
出问题时的备选方案
| 现象 | 解决办法 |
|---|---|
| 三张图不显示 | 用编辑器工具栏的「上传图片」把 images/ 里的三个 PNG 传上去,再把对应的 <svg>...</svg> 整段替换成博客园生成的  链接 |
| 交互演示出不来 | 账号未开通 JS 权限,或平台过滤了 <script>。可把 hashmap-demo.html 传到任意静态托管,然后在文末放一行「在线演示地址:xxx」 |
| 代码块格式乱 | 确认代码块用的是三个反引号 + 语言名(java / html / text) |
小结
回过头看,HashMap 的全部设计其实就是围绕一个矛盾展开的:
数组定位快但怕冲突,链表解决冲突但查找慢。
于是它用 & (n-1) 把定位做到极致(O(1)),用拉链法兜住冲突(O(n)),再用红黑树给极端情况兜底(O(log n)),最后用 0.75 的负载因子在时间和空间之间取平衡。
理解了这一条主线,剩下的源码细节都是它的自然延伸。

浙公网安备 33010602011771号