学习笔记05——HashMap实现原理及源码解析(JDK8)
HashMap实现原理及源码解析(JDK8)
一、核心设计思想
- 数组+链表+红黑树:桶数组存储Node节点,哈希冲突时形成链表,链表长度≥8且桶数量≥64时转红黑树
- 扰动函数:
(h = key.hashCode()) ^ (h >>> 16)
消除高位变化的影响 - 懒加载:首次put时初始化数组
- 负载因子:默认0.75,空间与时间的权衡
二、put()方法源码全解析
代码路径:java.util.HashMap#put
public V put(K key, V value) {return putVal(hash(key), key, value, false, true);
}
核心方法putVal()流程:
- 初始化检测:
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
- 计算桶下标:
(n - 1) & hash
- 处理空桶:直接创建新节点
- 处理哈希冲突:
- 红黑树节点:
putTreeVal()
- 链表节点:遍历至尾节点插入,同时检测链表长度
- 红黑树节点:
- 值替换检测:
if (e != null) { ... return oldValue; }
- 扩容检测:
if (++size > threshold) resize();
源码解读:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {Node<K,V>[] tab; // 当前哈希桶数组Node<K,V> p; // 目标桶的第一个节点int n, i; // n: 数组长度,i: 计算出的桶下标// ------------------------ 1. 初始化检测 ------------------------if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length; // 首次插入触发初始化// ------------------------ 2. 计算桶下标 ------------------------i = (n - 1) & hash; // 等价于 hash % n(n是2的幂时的优化)p = tab[i]; // 获取目标桶的第一个节点// ------------------------ 3. 处理空桶 ------------------------if (p == null)tab[i] = newNode(hash, key, value, null); // 直接插入新节点else {Node<K,V> e; // 用于记录已存在的节点(若找到相同key)K k;// ------------------- 4.1 处理首节点匹配 -------------------if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p; // 记录已存在的节点// ------------------- 4.2 处理红黑树节点 -------------------else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);// ------------------- 4.3 处理链表节点 ---------------------else {for (int binCount = 0; ; ++binCount) {// 到达链表尾部if ((e = p.next) == null) {p.next = newNode(hash, key, value, null); // 尾插法// 检查是否触发树化if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}// 发现重复keyif (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e; // 继续遍历}}// ------------------- 5. 处理值替换 ------------------------if (e != null) { // 存在重复keyV oldValue = e.value;if (!onlyIfAbsent || oldValue == null)e.value = value; // 覆盖旧值afterNodeAccess(e); // LinkedHashMap回调return oldValue; // 返回旧值}}++modCount; // 修改计数器(用于快速失败机制)// ------------------------ 6. 扩容检测 ------------------------if (++size > threshold)resize(); afterNodeInsertion(evict); // LinkedHashMap回调return null; // 插入成功返回null
}
三、扩容机制深度剖析
触发条件:元素数量 > 容量 × 负载因子
resize()核心步骤:
- 计算新容量:原容量×2(保证始终为2的幂)
- 创建新数组:
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
- 数据迁移:
- 单节点:
newTab[e.hash & (newCap - 1)] = e;
- 红黑树:
split()
方法处理 - 链表:拆分为低位链和高位链(利用
(e.hash & oldCap) == 0
判断)
- 单节点:
扩容优化点:
- 无需重新计算哈希:通过位运算快速确定新位置
- 链表保持原始顺序(JDK8改进,避免循环链表问题)
源码解析
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) { // 扩容场景// 容量已达最大值(2^30),不再扩容if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE;return oldTab;}// 新容量 = 旧容量 * 2else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap >= DEFAULT_INITIAL_CAPACITY)newThr = oldThr << 1; // 阈值 * 2}else if (oldThr > 0) // 初始化场景(指定初始容量)newCap = oldThr; // 使用构造方法中计算的阈值作为初始容量else { // 无参构造初始化(默认容量16)newCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);}// 处理新阈值为0的情况(兜底逻辑)if (newThr == 0) {float ft = (float)newCap * loadFactor;newThr = (newCap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?(int)ft : Integer.MAX_VALUE;}threshold = newThr; // 更新全局阈值// -------------------- 2. 创建新数组 --------------------Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];table = newTab; // 更新全局数组引用// -------------------- 3. 数据迁移(旧数组→新数组) --------------------if (oldTab != null) {for (int j = 0; j < oldCap; ++j) { // 遍历旧数组的每个桶Node<K,V> e;if ((e = oldTab[j]) != null) { // 桶中有数据oldTab[j] = null; // 清空旧桶(帮助GC)// ----------- 3.1 处理单节点 -----------if (e.next == null) newTab[e.hash & (newCap - 1)] = e; // 直接计算新位置// ----------- 3.2 处理红黑树节点 -----------else if (e instanceof TreeNode)((TreeNode<K,V>)e).split(this, newTab, j, oldCap);// ----------- 3.3 处理链表节点(关键优化) -----------else { Node<K,V> loHead = null, loTail = null; // 低位链表(原索引)Node<K,V> hiHead = null, hiTail = null; // 高位链表(原索引+oldCap)do {Node<K,V> next = e.next;// 判断是否需要移动位置if ((e.hash & oldCap) == 0) { if (loTail == null)loHead = e;elseloTail.next = e;loTail = e;}else { if (hiTail == null)hiHead = e;elsehiTail.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
判断节点位置,时间复杂度从 O(n) 降为 O(1) - 高低位链表拆分
- 低位链表:保持原索引位置
j
- 高位链表:新索引位置为
j + oldCap
- 顺序保留:JDK8 使用尾插法保持链表原始顺序,避免循环链表问题(对比 JDK7 头插法)
- 低位链表:保持原索引位置
- 红黑树退化机制
当红黑树节点数 ≤6 时退化为链表,避免空间浪费 - 延迟初始化
首次调用put()
时才会初始化数组(懒加载)
Q1:为什么HashMap扩容是2倍?
- 保证容量始终为2的幂,使得
(n - 1) & hash
等效于取模运算,且位运算效率更高 - 扩容时可通过位运算快速确定新位置(无需重新计算哈希)
Q2:JDK8扩容如何避免死循环?
- JDK7 使用头插法,多线程扩容可能导致循环链表
- JDK8 改为尾插法,并保持链表原始顺序,彻底解决该问题
Q3:链表拆分的数学原理是什么?
假设旧容量为 oldCap = 16
(二进制 10000
):
- 若
hash & oldCap == 0
→ 节点新位置 = 原位置 - 若
hash & oldCap != 0
→ 节点新位置 = 原位置 + oldCap
本质是通过哈希值的第log2(oldCap)
位是否为1决定位置
Q4:多线程环境下resize()的风险
- 数据丢失:两个线程同时触发扩容可能导致部分节点丢失
- 链表断裂:并发修改可能导致链表结构损坏
- 解决方案:使用
ConcurrentHashMap
四、关键代码
1.哈希扰动函数
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 设计目的:将高16位与低16位异或,减少低位相同导致的哈希碰撞
- 示例:若
hashCode()
为0x12345678
,扰动后为0x12345678 ^ 0x00001234 = 0x1234444C
2.树化逻辑
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);}
}
- 树化条件:链表长度 ≥8 且 桶数量 ≥64(
MIN_TREEIFY_CAPACITY
) - 设计考量:避免在小表时过早树化,空间利用率更重要
3.红黑树插入
在 putTreeVal()
方法中:
- 从根节点开始查找插入位置
- 比较哈希值和键对象
- 插入后通过
balanceInsertion()
进行红黑树平衡操作
4.核心设计亮点
- 尾插法 vs 头插法:
- JDK7 使用头插法,多线程扩容可能产生循环链表
- JDK8 改为尾插法,解决循环链表问题,保证插入顺序
- 延迟树化策略:
- 优先扩容而不是立即树化(当桶数量 <64 时)
- 树节点占用空间是普通节点的2倍,权衡空间与时间
- 高低位链表拆分:
- 扩容时通过
(e.hash & oldCap) == 0
判断节点位置 - 示例:原容量16(二进制
10000
),哈希值为10101
的节点在扩容后会分配到新位置10101 & 31(11111) = 21
- 扩容时通过
面试回答话术模板
面试官:请说明HashMap的实现原理
回答结构:
- 总体结构:“HashMap采用数组+链表+红黑树的存储结构,当链表长度超过8且桶数量达到64时,链表会转换为红黑树优化查询效率”
- 哈希计算:“通过二次扰动函数处理键的hashCode,使哈希分布更均匀”
- put流程:
- 计算桶下标 → 处理空桶/冲突 → 链表/树操作 → 扩容检测
- 强调尾插法、树化阈值、modCount记录
- 扩容机制:
- 2倍扩容保证容量始终为2的幂
- 数据迁移时的高低位链表拆分算法
- 线程安全:“HashMap非线程安全,多线程环境下建议使用ConcurrentHashMap”,原因是:1.两个线程同时put时可能会覆盖彼此的修改;2.jdk7的头插法扩容时产生循环链表,发生死循环;
- 延伸补充(可选):
- 与HashTable的区别(null键支持、同步机制)
- 负载因子取值依据(0.75的时间空间平衡点)
加分技巧:
- 手绘数据迁移示意图(高低位链表拆分)
- 举例说明哈希碰撞场景
- 对比JDK7的头插法实现差异(循环链表问题)
- 分析树化阈值的统计学依据(泊松分布)
示例回答:
“HashMap通过数组存储Node节点解决快速定位问题,使用链表和红黑树处理哈希冲突。当执行put操作时,首先计算键的扰动哈希值确定桶位置。若发生哈希冲突,JDK8采用尾插法维护链表,当链表长度达到树化阈值时会转换结构提升查询效率。扩容时采用2倍扩容策略,通过位运算快速重新分配节点位置,这个设计避免了重新计算哈希的开销。需要注意的是,HashMap不是线程安全的,多线程put可能导致数据丢失或死循环(JDK7版本)。”
建议结合手写伪代码/流程图进行说明,展现对底层实现的深入理解。同时可准备时间/空间复杂度分析(理想情况下O(1)操作)作为补充。