第47篇:HashMap源码全解(上)(2026版)
📌 系列导航:《Java 100 天进阶之路》完整目录 |
⬅️ 上一篇:第46篇:LinkedList源码与对比 |
➡️ 下一篇:第48篇:HashMap源码全解(下)
文章目录
一、核心知识点
- HashMap 底层结构:数组 + 链表 + 红黑树(JDK 8+)
- 核心属性:
table(桶数组)、size(元素个数)、threshold(扩容阈值)、loadFactor(负载因子) - 哈希算法:
hash()扰动函数(高16位与低16位异或) - 索引计算:
(n - 1) & hash(位运算代替取模) - put 方法流程:计算 hash → 定位桶 → 插入/覆盖 → 树化/扩容
- get 方法流程:计算 hash → 定位桶 → 遍历链表/红黑树查找
- JDK 7 vs JDK 8 核心差异:数据结构、插入方式、扩容优化
二、通俗讲解(1分钟开心学)
1. HashMap 是什么?
HashMap 是一个基于哈希表的键值对容器,通过 key 的哈希值快速定位 value 的存储位置,实现 O(1) 的平均查找和插入性能。
生活类比:
HashMap 就像一个带编号的储物柜(数组),每个柜子里面可能挂着一串钥匙(链表)。你把东西存进去时,工作人员根据你的名字(key)计算出一个柜子编号(hash),直接把东西放到对应柜子里;取东西时,同样根据名字计算编号,直接去那个柜子取,不用翻遍所有柜子。

2. 为什么要有链表?
不同 key 可能计算出相同的柜子编号(哈希冲突)。HashMap 的解决方案是:每个柜子里放一条链,把冲突的 key-value 串在一起。找东西时,先找到柜子,再顺着链挨个比对 key。
3. 为什么 JDK 8 引入红黑树?
当某个柜子的链太长时,查找效率会从 O(1) 退化成 O(n)。JDK 8 规定:当链表长度 ≥ 8 且数组容量 ≥ 64 时,将链表转换为红黑树,查找效率提升到 O(log n)。
生活类比:
某个柜子的钥匙串越挂越长,每次找钥匙都要从头捋到尾(链表 O(n))。红黑树就像把这串钥匙整理成一棵有规律的树形目录,找起来快多了(O(log n))。

4. HashMap 的扩容机制
当元素个数超过 容量 × 负载因子(默认 16 × 0.75 = 12)时,HashMap 会扩容为原来的 2 倍,并将所有元素重新分配到新数组中。
三、源码核心片段 + 场景说明
3.1 核心属性与构造方法
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// 默认初始容量:16(必须是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;
// 存储元素的数组(桶)
transient Node<K,V>[] table;
// 元素个数
transient int size;
// 扩容阈值(容量 × 负载因子)
int threshold;
// 负载因子
final float loadFactor;
}
💡 为什么容量必须是 2 的幂? 计算索引时使用
(n - 1) & hash代替hash % n,位运算更快;且n - 1的二进制全是 1,能保证哈希值均匀分布。
Node 内部类结构
static class Node<K,V> implements Map.Entry<K,V> {
final int 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 K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
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;
}
}
每个桶中的链表节点就是 Node 对象,包含 hash、key、value 和指向下一个节点的指针。红黑树节点 TreeNode 继承自 LinkedHashMap.Entry,后者又继承自 Node,因此树节点也兼容链表操作。
3.2 哈希算法——扰动函数
static final int hash(Object key) {
int h;
// key 为 null 时 hash = 0(所以 HashMap 支持 null key)
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
扰动函数原理:将 key 的 hashCode() 高 16 位与低 16 位进行异或运算。
为什么要扰动? 数组索引计算只取 hash 的低 n 位((n - 1) & hash)。如果只依赖原始 hashCode 的低位,当 hashCode 低位分布不均时,哈希碰撞会非常严重。扰动函数让高 16 位也参与低位计算,使哈希值分布更均匀。
💡 JDK 7 vs JDK 8 扰动函数对比:
- JDK 7:进行了 4 次右移 + 4 次异或 + 1 次二次哈希(共 9 次位运算),扰动强度大但性能开销高。
- JDK 8:简化为 1 次右移 + 1 次异或(
h ^ (h >>> 16))。JDK 8 简化扰动的原因是引入了红黑树,即使哈希碰撞较多,链表过长时也会转为红黑树,无需过度依赖扰动函数来减少碰撞。
3.3 索引计算方法
// 计算数组索引:n 为数组长度,hash 为扰动后的哈希值
i = (n - 1) & hash
为什么用 & 代替 %? 当 n 是 2 的幂时,(n - 1) & hash 等价于 hash % n,但位运算比取模快得多。例如 n=16 时,n-1=15(二进制 1111),hash & 1111 只取 hash 的低 4 位,相当于 hash % 16。
3.4 put 方法完整流程
源码入口:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
核心流程(putVal 方法):
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 步骤1:如果 table 为空,先初始化(第一次 put 触发 resize)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤2:计算索引,如果该位置为空,直接插入新节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 步骤3:该位置已有元素(哈希冲突)
Node<K,V> e; K k;
// 3.1 如果第一个节点的 key 与要插入的 key 相同,准备覆盖
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 3.2 如果该节点是树节点,走红黑树插入
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 3.3 否则遍历链表
else {
for (int binCount = 0; ; ++binCount) {
// 遍历到链表尾部,插入新节点(JDK 8 尾插法)
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;
}
}
// 步骤4:如果找到相同 key,覆盖旧值并返回旧值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
}
// 步骤5:结构性修改计数 + 1(用于 fail-fast)
++modCount;
// 步骤6:如果元素个数超过阈值,触发扩容
if (++size > threshold)
resize();
return null;
}
put 流程总结:

⚠️ 注意:
treeifyBin方法内部会检查数组容量是否 ≥MIN_TREEIFY_CAPACITY(64)。如果容量不足,会先调用resize()扩容,而不是直接树化。这是很多面试题的陷阱。
3.5 get 方法流程
源码:
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;
// 步骤1:table 不为空且对应位置有元素
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 步骤2:检查第一个节点
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 步骤3:遍历链表或红黑树
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 流程总结:
- 计算
hash(key),定位到桶数组的对应位置 - 如果该位置为空,返回
null - 如果第一个节点 key 匹配,直接返回
- 否则遍历链表/红黑树,用
equals()比较 key,找到则返回
3.6 resize 扩容机制
扩容触发条件:size > threshold(threshold = capacity × loadFactor)。
扩容核心逻辑:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 1. 计算新容量和新阈值
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 新容量 = 旧容量 × 2,新阈值 = 旧阈值 × 2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
// 初始化场景...
else {
newCap = DEFAULT_INITIAL_CAPACITY; // 16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
}
// 2. 创建新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 3. 将旧数组数据迁移到新数组
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)
// 红黑树节点拆分
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
// 链表节点:拆分为低位链表和高位链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 关键判断:(e.hash & oldCap) == 0 留在原位,否则移动 oldCap
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);
// 低位链表放在原索引位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 高位链表放在 (原索引 + oldCap) 位置
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
扩容时为什么元素要么在原位,要么在原位 + oldCap?
由于容量翻倍(从 n 到 2n),索引计算从 (n-1) & hash 变为 (2n-1) & hash。判断 (e.hash & oldCap) == 0 可以快速决定元素位置:
- 结果为 0:新索引 = 旧索引
- 结果不为 0:新索引 = 旧索引 + oldCap
举例说明:假设 oldCap = 16(二进制 10000),扩容后 newCap = 32(100000)。原来索引由 hash & 15 决定(低 4 位)。新索引由 hash & 31 决定(低 5 位)。如果 hash 的第 5 位(对应数值 16)是 0,则新索引 = 旧索引;如果是 1,则新索引 = 旧索引 + 16。而 (e.hash & oldCap) 恰好就是检测 hash 的第 5 位是否为 1。
这种设计不需要重新计算每个元素的 hash,极大提升了扩容效率。
四、JDK 7 vs JDK 8 HashMap 核心差异
| 对比维度 | JDK 7 | JDK 8 |
|---|---|---|
| 底层结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 链表插入方式 | 头插法(新节点插入链表头部) | 尾插法(新节点插入链表尾部) |
| 扰动函数次数 | 9 次位运算(4 次右移 + 4 次异或 + 1 次二次哈希) | 2 次位运算(1 次右移 + 1 次异或) |
| 扩容后元素迁移 | 重新计算每个元素的 hash | 通过 (e.hash & oldCap) 判断,拆分为高低位链表 |
| 扩容死循环 | 多线程扩容可能死循环 | 尾插法 + 高低位拆分,避免死循环 |
| 树化阈值 | 无 | 链表长度 ≥ 8 且数组容量 ≥ 64 |
⚠️ 多线程安全问题:HashMap 不是线程安全的,多线程环境下使用
ConcurrentHashMap或Collections.synchronizedMap。
五、避坑要点
| 错误/误区 | 后果 | 正确做法 |
|---|---|---|
| 默认容量 16,频繁插入大量数据 | 多次扩容,性能损耗 | 预估数据量,指定初始容量:new HashMap<>(预估容量) |
用自定义对象作为 key 但不重写 hashCode()/equals() | 无法正确获取 value | 重写 hashCode() 和 equals(),且遵守契约 |
| 多线程环境使用 HashMap | 数据丢失、死循环(JDK 7)、size 不准 | 使用 ConcurrentHashMap |
| 遍历时修改 HashMap | ConcurrentModificationException | 用迭代器的 remove() 或 ConcurrentHashMap |
| 负载因子设置过高(如 0.9) | 哈希冲突增加,查询变慢 | 保持 0.75,除非内存极度受限 |
| 依赖 HashMap 的遍历顺序 | 业务逻辑出错 | HashMap 不保证顺序,用 LinkedHashMap 或 TreeMap |
六、面试高频考点
Q1:HashMap 的底层数据结构是什么?
JDK 7:数组 + 链表。JDK 8+:数组 + 链表 + 红黑树。当链表长度 ≥ 8 且数组容量 ≥ 64 时,链表转换为红黑树。
Q2:HashMap 的默认容量和负载因子是多少?为什么是 0.75?
默认容量 16,负载因子 0.75。0.75 是时间和空间的折中选择:太高(如 1.0)哈希冲突多,查询慢;太低(如 0.5)空间浪费严重。
Q3:HashMap 的 put 流程?
① 计算
hash(key)→ ② 若 table 为空则初始化 → ③ 通过(n-1) & hash计算索引 → ④ 若桶为空直接插入 → ⑤ 若桶不为空,比较 key,相同则覆盖;若是树节点走红黑树插入;若是链表则遍历,找到相同 key 则覆盖,否则尾插法插入 → ⑥ 若链表长度 ≥ 8 尝试树化(还需检查数组容量 ≥ 64,否则先扩容)→ ⑦size++,若超过阈值则扩容。
Q4:扰动函数的作用是什么?
将
hashCode()的高 16 位与低 16 位异或,让高位参与低位计算,使哈希值分布更均匀,减少哈希碰撞。
Q5:HashMap 为什么容量必须是 2 的幂?
① 计算索引用
(n-1) & hash代替hash % n,位运算更快;②n-1的二进制全是 1,保证哈希值均匀分布;③ 扩容时元素迁移只需判断高位,无需重新 hash。
Q6:JDK 8 为什么引入红黑树?
当链表过长时,查找效率退化为 O(n)。红黑树的查找效率为 O(log n),能保证极端情况下的性能。
Q7:JDK 7 和 JDK 8 的 HashMap 有什么区别?
① JDK 8 引入红黑树;② JDK 7 头插法,JDK 8 尾插法;③ JDK 7 扰动 9 次位运算,JDK 8 扰动 2 次;④ JDK 8 扩容时通过
(e.hash & oldCap)判断元素位置,效率更高。
Q8:HashMap 和 Hashtable 的区别?
HashMap 线程不安全,允许 null key 和 null value;Hashtable 线程安全(方法 synchronized),不允许 null。Hashtable 是遗留类,新代码用 HashMap 或 ConcurrentHashMap。
Q9:HashMap 支持 null key 吗?
支持。null key 的 hash 值为 0,存放在数组第 0 个位置。
七、练习题
-
源码推导:初始容量 16,负载因子 0.75,插入第几个元素时会触发第一次扩容?
💡 答案:第 13 个。阈值 = 16 × 0.75 = 12,
size从 12 变为 13 时触发扩容。 -
代码分析:以下代码有什么问题?
HashMap<MyKey, String> map = new HashMap<>(); map.put(new MyKey(1), "value"); map.get(new MyKey(1)); // 返回 null💡 思路:
MyKey没有重写hashCode()和equals(),两个内容相同的MyKey对象会产生不同的哈希值,导致定位到不同桶,get返回null。必须同时重写hashCode()和equals()才能正常工作。 -
场景设计:如果确定要存储 1 万条数据,如何初始化 HashMap 能获得最佳性能?
💡 思路:
new HashMap<>(10000 / 0.75 + 1),预估容量时考虑负载因子,避免频繁扩容。实际可用new HashMap<>(15000)或new HashMap<>(20000)。 -
对比分析:为什么 JDK 8 中 HashMap 的扰动函数从 9 次位运算简化到 2 次?
💡 思路:JDK 8 引入了红黑树,即使哈希碰撞较多,链表过长时会转为红黑树,查找效率从 O(n) 提升到 O(log n),因此不需要过度依赖扰动函数来减少碰撞。
📊 你的学习进度
- 当前:第47篇 / 共108篇 · 进阶篇:集合框架源码解析(第45~50篇)
- ✅ 已完成:基础篇44篇 + 第45~47篇
- 📖 正在学:第47篇
- ⏳ 待学习:第48~108篇
👉 📚 完整目录 & 学习指南 | 🔥 订阅本专栏,不错过每一篇
👉 下一篇文章预告
内容简介:红黑树转换机制(
treeifyBin)、扩容resize深度剖析、JDK 7 多线程死循环问题、JDK 7 vs JDK 8 完整对比、常见面试题深度解析。
💡 学完这篇,你将彻底拿下 HashMap 源码,成为真正的 Java 集合专家。
📌 《Java 100 天进阶之路 | 从入门到上岗就业》 每天一篇,建议收藏 + 关注,一起100天拿offer!
👉 点击关注我,更新后第一时间收到推送!
522


(2026版)&spm=1001.2101.3001.11974&articleId=162256851&d=1&t=3&u=9d76217c7c0242649b2ffb5ce717304d)

被折叠的 条评论
为什么被折叠?



