TL;DR(30 秒扫完)
- put 四步:①
hash(key)=h^(h>>>16)高位异或;②(n-1)&hash定位桶;③空桶直放/非空遍历 equals 覆盖或尾插;④链长>8 且容量≥64 转树,size>threshold 扩容 - resize 位运算:
e.hash & oldCap判断——0 留原位,非 0 搬到index+oldCap - JDK7→8 七改动:头插→尾插、引入红黑树、hash 算法改造、resize 位运算、TreeNode 拆分、转树双门槛、新增函数式 API
- 并发问题六类:size 错乱、put 覆盖、get null、迭代 CME、check-then-act、可见性缺失
- 替代首选:
ConcurrentHashMap(详见hash-map-concurrent.md)
关键结论
结论 AHashMap 数据结构 = 数组 + 链表 + 红黑树(JDK8 新增树化,解决极端哈希冲突)
结论 B
hash(key) = key.hashCode() ^ (key.hashCode() >>> 16)——高位异或低位参与运算,减少碰撞结论 CJDK8 resize 用
e.hash & oldCap 位运算优化,避免全部重新 hash,只判断一个 bit 就决定新位置结论 D即使 JDK8 修了头插法环形链表 bug,HashMap 仍非线程安全,并发场景必须用
ConcurrentHashMap完整讲解(费曼四步)
STEP 1 · 概念
HashMap 是"数组 + 链表 + 红黑树"的复合结构:数组是桶,桶内是链表(冲突时挂接),链表过长转红黑树降低查找成本。核心动作是 put(放入)和 get(取出),围绕它们展开的还有扩容 resize 和树化转换。JDK7 到 JDK8 有 7 处关键改动,主要解决长链表查找 O(n)、并发扩容环形链表、扩容效率等问题。STEP 2 · 大白话
类比:HashMap 就像一个多层储物柜。最外层是"柜号"数组(桶),每个柜子里挂着"袋子"(链表),袋子里有多个"钥匙-物品对"。取东西时先按 key 的 hash 值定位到哪个柜号(桶),再从柜子的袋子里翻钥匙找物品。如果某个柜子挂的袋子太长(哈希冲突严重),就把袋子升级成"书架"(红黑树),查找更快。
JDK8 的改进:原来 JDK7 的柜子挂新袋子时用"头插法"(新袋子插到最前面),但两个工人同时扩容时会把链表搅成环——现在改成了"尾插法"(新袋子挂到最后),避免环形;扩容时用位运算判断袋子该留原位还是搬到柜子右边(index+oldCap),不用重新算 hash。
STEP 3 · 底层
put 完整流程(JDK8)
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;
// ① 空表初始化(容量取 initialCapacity 的 2 的幂)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// ② 桶定位:(n-1) & hash,因为容量是 2 的幂,等价于取模
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // 空桶:新建 Node 直接放
else {
// ③ 桶非空:遍历
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p; // 头节点命中:覆盖 value
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, 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) // 链长 ≥ 8
treeifyBin(tab, hash); // 且容量 ≥ 64 才转树
break;
}
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break; // 遍历中命中:准备覆盖
}
}
if (e != null) { // 命中覆盖
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
return oldValue;
}
}
++modCount;
if (++size > threshold) // ④ 扩容检查
resize();
afterNodeInsertion(evict);
return null;
}
关键常量:
| 常量 | 值 | 含义 |
|---|---|---|
DEFAULT_INITIAL_CAPACITY | 16 | 默认初始容量 |
DEFAULT_LOAD_FACTOR | 0.75f | 默认负载因子 |
MAXIMUM_CAPACITY | 1 << 30 | 最大容量 |
TREEIFY_THRESHOLD | 8 | 链长转树阈值 |
UNTREEIFY_THRESHOLD | 6 | 树退化回链表阈值 |
MIN_TREEIFY_CAPACITY | 64 | 转树所需最小桶数 |
get 完整流程
public V get(Object key) {
return getNode(hash(key), key);
}
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); // ② 树形查找 O(log n)
while ((e = e.next) != null) { // ③ 链表遍历
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
}
}
return null; // ④ 未命中
}
hash 算法(JDK8)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
为什么这么做:把 hashCode 的高 16 位异或到低 16 位,让高位也参与桶下标的计算。因为 (n-1) & hash 只用低位(n 是 2 的幂时 n-1 是低位全 1),如果没有扰动,高位信息就浪费了,容易碰撞。
桶定位公式:index = (n - 1) & hash
- 容量 n 是 2 的幂时,
n-1的二进制是低 n 位全 1(如 16 = 10000, 15 = 01111) & 01111等价于"取低 4 位",也等价于hash % 16- 但按位与比取模运算快得多
- equals 相等 → hashCode 必相等(正契约)
- hashCode 相等 → equals 不一定相等(哈希冲突,反契约)
- 打破后果:put 时 A/B 的 hash 不同,equals 无法触发覆盖 → 两个 equals 相等的 key 各占一个桶;get 时可能找不到刚 put 的值
resize 扩容(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;
// ① 首次扩容
if (oldCap == 0) {
newCap = (oldThr > 0) ? oldThr : DEFAULT_INITIAL_CAPACITY; // 16
newThr = (int)(newCap * DEFAULT_LOAD_FACTOR); // 12
}
// ② 达 MAX_CAP 不再扩容量,只增 threshold
else if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// ③ 常规扩容:容量翻倍
else if ((newCap = oldCap << 1) > MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else {
newThr = oldThr << 1;
}
@SuppressWarnings({"rawtypes","unchecked"})
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 = oldTab[j];
if (e == null) continue;
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); // 树桶:拆分,size<6 退化
}
else {
// 关键:链表按 e.hash & oldCap 拆成"原位"和"远端"两串
Node<K,V> loHead = null, loTail = null; // e.hash & oldCap == 0 的
Node<K,V> hiHead = null, hiTail = null; // e.hash & oldCap != 0 的
do {
if ((e.hash & oldCap) == 0) {
loTail = (loTail == null) ? loHead = e : loTail.next = e;
} else {
hiTail = (hiTail == null) ? hiHead = e : hiTail.next = e;
}
} while ((e = e.next) != null);
if (loHead != null) newTab[j] = loHead; // 原位
if (hiHead != null) newTab[j + oldCap] = hiHead; // 远端 index+oldCap
}
}
}
threshold = newThr;
return table;
}
为什么只判断 e.hash & oldCap 一个 bit:- 容量从 oldCap 变成 newCap=oldCap×2,newCap-1 比 oldCap-1 多了一位 1(高位)
- 原 index = hash & (oldCap-1),只用了低 n 位
- 新 index = hash & (newCap-1),多了第 n 位
- 所以新旧 index 差在第 n 位:
e.hash & oldCap == 0(第 n 位为 0)→ 新 index = 旧 index(原位)e.hash & oldCap != 0(第 n 位为 1)→ 新 index = 旧 index + oldCap(远端)- 性能收益:不用重新 hash,只判断一个 bit
JDK7 vs JDK8 演进对比
| 维度 | JDK7 | JDK8 | 改动原因 |
|---|---|---|---|
| 数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 | 长链表 O(n) → O(log n) |
| 头/尾插法 | 头插(e.next = table[i]; table[i] = e) | 尾插 | 头插法并发扩容形成环形链表 |
| 树桶 | 无 | TreeNode(继承 LinkedHashMap.Entry) | 双向链表 + 树指针 |
| hash 算法 | hash(indexFor(hash, n)) 简单取模 | h ^ (h >>> 16) 高位异或 | 高位参与运算减少碰撞 |
| resize 新 index | 重新 indexFor(e.hash, newCap) | e.hash & oldCap == 0 ? i : i + oldCap | 位运算比重新 hash 快 |
| 转树 | 无 | 链长>8 且容量≥64 | 泊松分布下的合理阈值 |
| 树退化 | 无 | 扩容后树桶 size<6 | 扩容分散后冲突降低 |
| 并发安全 | 头插法有环形链表风险 | 尾插法安全(但整体仍非线程安全) | 修特定 bug |
| 函数式 API | 无 | compute/computeIfAbsent/computeIfPresent/merge | 配合 lambda |
转树与退化的双门槛
为什么链长阈值 8:HashMap 容量是 2 的幂,负载因子 0.75,链长分布近似泊松分布。泊松分布下链长到 8 的概率约6 × 10⁻⁸——极端小,说明链长到 8 大概率是哈希冲突而非随机分布,此时红黑树 O(log n) 优势才体现。
为什么容量阈值 64:桶数太少时冲突是正常现象,转树成本大于扩容;容量到 64 之前应该优先扩容解决冲突,而不是转树。
并发问题六大分类
| # | 问题 | 表现 | 根因 |
|---|---|---|---|
| 1 | size 计数错乱 | size() 返回小于实际 | ++size 非原子(read-modify-write) |
| 2 | put 覆盖丢失 | 两个线程 put 同一 key,只留一个 | 无锁检查-写入不原子 |
| 3 | get 返回 null | 命中正在迁移的桶读到空 | resize 期间新旧桶切换非原子 |
| 4 | 迭代 CME | ConcurrentModificationException | fail-fast modCount 检测 |
| 5 | check-then-act 竞态 | if (!containsKey(k)) put(k, v) 之间被插入 | 复合操作无原子保证 |
| 6 | 内存可见性缺失 | 其他线程看不到最新值 | 普通变量无 volatile |
替代方案对比
| 方案 | 锁粒度 | 读写并发 | 迭代 | 适用 |
|---|---|---|---|---|
ConcurrentHashMap | 桶级 synchronized + CAS | 读几乎无锁 | 弱一致 | 默认首选 |
Collections.synchronizedMap | 全表 synchronized | 串行 | 迭代需外层加锁 | 低并发简单场景 |
ConcurrentSkipListMap | 桶级(跳表) | 读几乎无锁 | 弱一致 | 需要有序 |
Collections.unmodifiableMap | 无 | 只读 | 迭代安全 | 只读场景 |
| 自加锁 | 用户自定义 | 用户控制 | 用户控制 | 复合操作 |
- 默认用
ConcurrentHashMap - 复合操作(如"不存在则创建")用
computeIfAbsent保证原子 - 不可变场景用
unmodifiableMap - 共享计数器用
LongAdder/AtomicLong,不要用 Map 存计数
STEP 4 · 简化
一句话总结:HashMap 是"数组+链表+红黑树"三层结构,JDK8 用hash 高位异或+尾插法+resize 位运算+链长>8 转树四大优化解决查找、扩容、并发三个痛点,但并发正确性仍需ConcurrentHashMap。
延伸追问
为什么 HashMap 容量必须为 2 的幂?
(n-1) & hash 等价于 % n 只在 n 是 2 的幂时成立;扩容时 e.hash & oldCap 判断一个 bit 就能决定新位置;低 16 位散列均匀hashCode 和 equals 契约是什么?破坏会怎样?契约:"equals 相等则 hashCode 必相等"(反过来不成立);破坏后果:put 时 A/B hash 不同 → equals 无法触发覆盖 → 两个 equals 相等的 key 各占一个桶;get 时可能找不到刚 put 的值
e.hash & oldCap 位运算优化的数学原理?容量翻倍后 newCap-1 相当于 oldCap-1 前面多了一位 1(第 n 位);e.hash 的第 n 位为 0 → 新 index = 旧 index;为 1 → 新 index = 旧 index + oldCap
JDK7 头插法为什么会 CPU 100%?
并发扩容时两线程同时迁移链表,A 遍历 e→f,B 遍历 f→e,A 完成后 e→f→e 环;get 命中该桶死循环
HashMap 为什么默认容量 16、负载因子 0.75?
16 是内存开销和扩容频率的折中;0.75 是泊松分布下平均链长期望约 0.5 的经验值
computeIfAbsent 在 HashMap 和 ConcurrentHashMap 上有什么差异?HashMap 上无原子性保证,需要外层同步;ConcurrentHashMap 上桶级 synchronized 保证 check-then-act 原子
HashMap 大量扩容时怎么优化?
预估容量
new HashMap<>(expectedSize / 0.75f + 1);使用 computeIfAbsent 减少重哈希;批量导入用 Collectors.toMap 后合并速查表
数据结构:数组 + 链表 + 红黑树(JDK8+)
put 四步:
① hash(key) = key.hashCode() ^ (key.hashCode() >>> 16)
② index = (n - 1) & hash
③ 桶空 → 直放;桶非空 → 遍历 equals 覆盖或尾插
④ 链长>8 且容量≥64 转树;size>threshold 扩容
resize 五步(JDK8):
① 首次:newCap=16,threshold=12
② 常规:newCap=oldCap<<1,threshold=oldThr<<1
③ 达 MAX_CAP(1<<30):只增 threshold
④ 遍历旧桶,按 e.hash & oldCap 拆两串
⑤ 树桶 split,size<6 退化回链表
JDK7→JDK8 七改动:
头插→尾插 / 引入红黑树 / hash 位运算 / resize 位运算 /
TreeNode 拆分 / 转树双门槛 / 新增函数式 API
并发问题六类:
size 错乱 / put 覆盖 / get null / 迭代 CME / check-then-act / 可见性缺失
替代方案:ConcurrentHashMap 首选
关键常量:
TREEIFY_THRESHOLD=8, UNTREEIFY_THRESHOLD=6, MIN_TREEIFY_CAPACITY=64
DEFAULT_INITIAL_CAPACITY=16, DEFAULT_LOAD_FACTOR=0.75
关联题目
- [ ] 《HashMap在get和put时经过哪些步骤?》— 2026-10-09 Round 1 Q4, ⭐⭐⭐(漏 hash 位运算、get 树形分支)
- [ ] 《HashMap是如何扩容的?》— 2026-10-09 Round 1 Q5, ⭐⭐(只答触发条件,无扩容过程)
- [ ] 《JDK1.8中HashMap有哪些改变?》— 2026-10-09 Round 1 Q6, ⭐⭐⭐(漏 hash 算法、Node 拆分、树退化)
- [ ] 《HashMap用在并发场景中有什么问题?》— 2026-10-09 Round 1 Q7, ⭐⭐⭐(漏 get 中间态、CME、check-then-act)
- [ ] 《HashMap、Hashtable和ConcurrentHashMap的区别?》— 2026-10-09 Round 1 Q2, ⭐⭐⭐(主线对,广度不足)
关联知识
HashMap=数组+链表+红黑树;JDK8 用 hash 位运算 `(h ^ (h>>>16))` + 尾插法 + resize 位运算 `e.hash & oldCap` + 链长>8 转树四大优化;JDK7→8 有 7 项演进;即使修了头插环形链表 bug 仍非线程安全
多层储物柜:桶=柜号,链表=袋子,红黑树=书架;JDK8 改造让扩容时袋子不用重新算 key 就知道该留原位还是搬到右边
✦ 记 忆 口 诀 ✦
hash = h^(h>>>16);(n-1)&hash 定位;e.hash & oldCap 判断原位/远端;链长>8 且容量≥64 转树
关键可视化
HashMap put 完整流程(JDK8)
flowchart TB
START["put(key, value)"] --> H1["① hash(key)<br/>= key.hashCode() ^<br/> (key.hashCode() >>> 16)"]
H1 --> H2["② index = (n-1) & hash<br/>容量 n 是 2 的幂时<br/>等价于取模"]
H2 --> H3{"桶为空?"}
H3 -- 是 --> P1["newNode 直接放"]
H3 -- 否 --> P2{"遍历链表"}
P2 -- key.equals 命中 --> P3["覆盖 value"]
P2 -- 未命中尾插 --> P4["尾插 newNode<br/>(JDK8 改进)"]
P4 --> P5{"链长≥8 且<br/>容量≥64?"}
P5 -- 是 --> P6["treeifyBin<br/>转红黑树"]
P5 -- 否 --> P7["继续"]
P1 --> SIZE["size++"]
P3 --> SIZE
P6 --> SIZE
P7 --> SIZE
SIZE --> RESIZE{"size>threshold?"}
RESIZE -- 是 --> RS["resize 扩容"]HashMap resize 完整流程(JDK8)
flowchart TB
START["resize()"] --> C1{"首次扩容<br/>table==null?"}
C1 -- 是 --> N1["newCap=16<br/>threshold=12"]
C1 -- 否 --> C2{"达 MAX_CAP<br/>(1<<30)?"}
C2 -- 是 --> N2["只增大 threshold<br/>=Integer.MAX_VALUE"]
C2 -- 否 --> N3["newCap=oldCap<<1<br/>threshold=oldThr<<1"]
N1 --> MIG["遍历旧表迁移"]
N3 --> MIG
MIG --> SPLIT["对每个节点:<br/>判断 e.hash & oldCap"]
SPLIT --> R1{"第 n 位=0?"}
R1 -- 是 --> R2["原位迁移<br/>newTab[j] = 该串"]
R1 -- 否 --> R3["远端迁移<br/>newTab[j+oldCap] = 该串"]
R2 --> TREE{"树桶?"}
R3 --> TREE
TREE -- 是 --> T1["split 拆树<br/>size<6 退化回链表"]JDK7 vs JDK8 HashMap 七大改动
flowchart LR
subgraph JDK7["JDK7"]
J1["数组+链表"]
J2["头插法<br/>(并发→环形链表)"]
J3["indexFor 简单取模"]
J4["扩容重算 hash"]
J5["无树桶"]
J6["无函数式 API"]
end
subgraph JDK8["JDK8"]
J81["数组+链表+红黑树<br/>引入 TreeNode"]
J82["尾插法<br/>安全"]
J83["h^(h>>>16) 高位异或"]
J84["e.hash & oldCap 位运算"]
J85["链长>8 且容量≥64 转树<br/>扩容后 size<6 退化"]
J86["compute/computeIfAbsent<br/>/merge"]
end
JDK7 -.7 项演进.-> JDK8HashMap 并发问题六大分类
flowchart TB ISSUES["HashMap 并发六大问题"] --> I1["① size 计数错乱<br/>++size 非原子"] ISSUES --> I2["② put 覆盖丢失<br/>无锁检查-写入不原子"] ISSUES --> I3["③ get 返回 null<br/>resize 中间状态"] ISSUES --> I4["④ 迭代 CME<br/>modCount 检测"] ISSUES --> I5["⑤ check-then-act 竞态<br/>复合操作无原子"] ISSUES --> I6["⑥ 内存可见性缺失<br/>无 volatile"] I1 --> FIX["替代:ConcurrentHashMap<br/>默认首选"] I2 --> FIX I3 --> FIX I4 --> FIX I5 --> FIX I6 --> FIX
JDK8 hash 算法:高位异或
flowchart LR A["key.hashCode()"] --> B["h"] B --> SHIFT["h >>> 16<br/>高 16 位移到低位"] SHIFT --> XOR["h ^ (h>>>16)"] XOR --> C["hash(key)"] C --> D["(n-1) & hash<br/>定位桶下标"] D --> E["为什么这样改?<br/>n-1 低位全 1<br/>只用低位<br/>高位信息浪费了<br/>异或后高位参与运算<br/>减少碰撞"]
知识关系
🔄 延伸(Extends)
string-design-patterns
🎯 概念
📏 规则
⚠️ 误区
🔍 追问
✨ 口诀
共 0 张卡,点击翻面