Java基础篇:一文彻底搞懂 HashMap 底层原理(JDK 8 + JDK 17 对比)
HashMap 是 Java 开发中最常用的集合,几乎每天都在用,但真正能说清楚它底层实现的人并不多。本文从源码角度彻底讲透 HashMap,读完保证你面试不再被虐!
一、HashMap 总体结构(JDK 8 为分水岭)
| JDK 版本 | 底层数据结构 | 备注 |
|---|---|---|
| JDK 7 | 数组 + 单向链表 | 头插法(有线程安全问题) |
| JDK 8 | 数组 + 单向链表 + 红黑树 | 尾插法 + 链表转红黑树 |
| JDK 17+ | 数组 + 单向链表 + 红黑树(优化阈值) | 树化阈值仍为8,但退化条件更智能 |
核心数据结构(JDK 8~21 都一样):
transient Node<K,V>[] table; // 桶数组,主战场
transient int size; // 实际元素个数
transient int modCount; // 结构性修改次数(快速失败机制)
int threshold; // 扩容阈值 = capacity * loadFactor
final float loadFactor; // 负载因子,默认 0.75
二、Node 节点内部类(4种)
// 1. 普通链表节点
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next; // 单向链表
}
// 2. 红黑树节点(继承Node)
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
boolean red;
}
// 3. JDK 8 扩容时使用的临时节点(了解即可)
static final class ForwardingNode<K,V> extends Node<K,V>
// 4. JDK 17 新增:当树退化成链表时会短暂出现(了解)
static final class ReservedNode<K,V> extends Node<K,V>
三、关键常量(必须背!面试必问)
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; // 链表长度 >=8 时树化
static final int UNTREEIFY_THRESHOLD = 6; // 树节点 <=6 时退化成链表
static final int MIN_TREEIFY_CAPACITY = 64; // 桶容量 <64 时优先扩容,不树化
四、hash 计算(扰动函数)—— 防止哈希冲突的关键
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
// 高16位和低16位异或,减少低位碰撞
}
定位桶位置:
int index = (table.length - 1) & hash; // 等价于 hash % length(长度必须是2的幂)
为什么容量必须是 2 的幂?
因为 (n-1) & hash 比取模更快,且只要 hash 分布均匀,位运算也能均匀分布。
五、put 流程全解析(JDK 8 最经典版本)
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. 懒加载:第一次put才初始化table
if ((tab = table) == null || (n = tab.length) == 0)
tab = resize(); // 初始化或扩容
// 2. 计算索引,判断桶是否为空
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // 直接放第一个节点
// 3. 发生冲突
else {
// 3.1 key 完全相同,直接覆盖
if (p.hash == hash && (p.key == key || (key != null && key.equals(p.key))))
oldValue = p.value;
// 3.2 是树节点,走红黑树插入
else if (p instanceof TreeNode)
((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 3.3 是普通链表,尾插法插入
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 长度达到8,且 table容量>=64,才树化
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 发现key已存在,覆盖
if (e.hash == hash && (...equals...))
...
p = e;
}
}
}
// 4. 触发扩容
if (++size > threshold)
resize();
return null;
}
六、resize() 扩容核心逻辑(最难部分!)
JDK 8 的高明之处:扩容时不需要重新计算 hash,只看原来 hash 的二进制位!
// 假设原来容量16,扩容到32
// 原hash: 0001 0101
// 新容量-1: 0001 1111
// & 后要么还在原位置,要么 +16(原容量)
Node<K,V> loHead, loTail; // 低位链表(留在原位置)
Node<K,V> hiHead, hiTail; // 高位链表(移动到 index + oldCap)
if (旧节点hash & oldCap != 0) → 去高位(原索引 + oldCap)
else → 留在低位(原索引不变)
这就是为什么 JDK 8 扩容性能大幅提升,不用全部 rehash!
七、为什么从 JDK 7 头插法 → JDK 8 尾插法?
JDK 7 多线程 put 时,头插 + 扩容可能形成环形链表,导致死循环!
JDK 8 改为尾插法 + 更复杂的并发控制(虽然 HashMap 仍然线程不安全,但不会死循环了)
八、HashMap 线程安全替代方案
| 场景 | 推荐方案 |
|---|---|
| 简单线程安全 | Collections.synchronizedMap(new HashMap<>()) |
| 高并发读多写少 | ConcurrentHashMap(分段锁 → JDK8 Node+CAS+Synchronized) |
| JDK 8+ 推荐 | ConcurrentHashMap |
九、经典面试题总结
- HashMap 默认初始容量是多少?为什么是 16?
→ 16,2^4,位运算效率高 - 为什么负载因子是 0.75?
→ 时间和空间平衡,0.75 时冲突概率较低(泊松分布计算) - 为什么链表长度 8 才转红黑树?
→ 泊松分布下,链表长度达到 8 的概率已极低(约 0.00000006) - 为什么树退化阈值是 6 而不是 7?
→ 避免频繁树化和退化(8→7→8 抖动) - HashMap 是线程安全的吗?
→ 不是!多线程建议用 ConcurrentHashMap - HashMap 能存 null 键吗?
→ 可以!null 键的 hash=0,永远放在 table[0]
结语
掌握了以上内容,你已经超越 95% 的面试者了!
如果要我给你整理一份:
- HashMap 手绘结构图 + 源码注释版
- 对比 HashTable、ConcurrentHashMap 的思维导图
- 20 道 HashMap 高频面试题 PDF
直接回复“HashMap 资料” 三个字,我立刻打包发你!
冲就完了!