NEKOBLANK / NOTES
ConcurrentHashMap 面试十八问
写在前面 (Introduction)
本文按 18 道面试题整理 ConcurrentHashMap 的常见问答。每题保留原来的「人话」、示意图和「一句话」,只调整了标题层级、表格和提示框。
题目目录
- ConcurrentHashMap 和 HashMap 有什么区别?
- ConcurrentHashMap 和 Hashtable 有什么区别?
- JDK7 和 JDK8 的 ConcurrentHashMap 有什么区别?
- ConcurrentHashMap 的 put 流程是什么?
- get 为什么不需要加锁?
- 扩容是怎么做的?
- size 为什么是弱一致的?
- 为什么不允许 null key 和 null value?
- 为什么非空桶用 synchronized,不用 ReentrantLock?
- 链表什么时候转红黑树?
- 并发度是多少?
- 多线程协助扩容是怎么实现的?
- CAS、synchronized、volatile 各自的作用是什么?
- 迭代器弱一致是什么意思?
- get 遇到 ForwardingNode 为什么要去新表查?
- key 和 value 能为 null 吗?
- 请完整说一下 ConcurrentHashMap 的原理。
- 补充:CAS 是什么?在 ConcurrentHashMap 中怎么起作用?
1. ConcurrentHashMap 和 HashMap 有什么区别?
人话
HashMap:单线程用的,快,但多线程写会出问题。
ConcurrentHashMap:多线程用的,安全,稍微慢一点。
ConcurrentHashMap:多线程用的,安全,稍微慢一点。
| HashMap | ConcurrentHashMap |
|---|---|
| 无锁 | CAS + 锁桶 |
| 允许 null | 不允许 null |
| 线程不安全 | 线程安全 |
| size 精确 | size 弱一致 |
| fail-fast | 弱一致迭代器 |
一句话
单线程用 HashMap,多线程用 ConcurrentHashMap。
2. ConcurrentHashMap 和 Hashtable 有什么区别?
人话
Hashtable:整个表一把大锁,一个人写,所有人等。
ConcurrentHashMap:每个桶一把小锁,不同桶可以同时写。
ConcurrentHashMap:每个桶一把小锁,不同桶可以同时写。
Hashtable:
┌─────────────────────────┐
│ 一把大锁 🔒 │
│ 线程A写,B/C/D全等着 │
└─────────────────────────┘
ConcurrentHashMap:
┌──────┬──────┬──────┬──────┐
│ 桶0🔒│ 桶1 │ 桶2🔒│ 桶3 │
│ 线程A│ │ 线程B│ │
└──────┴──────┴──────┴──────┘
不同桶互不影响
一句话
Hashtable 锁全表,ConcurrentHashMap 锁单桶,后者并发度高得多。
3. JDK7 和 JDK8 的 ConcurrentHashMap 有什么区别?
人话
JDK7:分段锁,把表分成 16 段,每段一把锁。
JDK8:锁到每个桶,粒度更细,还加了 CAS 和红黑树。
JDK8:锁到每个桶,粒度更细,还加了 CAS 和红黑树。
JDK7:
┌──── Segment[0] 🔒 ────┬──── Segment[1] 🔒 ────┐
│ HashEntry[] │ HashEntry[] │
└───────────────────────┴───────────────────────┘
锁粒度:Segment
JDK8:
table: Node[]
┌──────┬──────┬──────┬──────┐
│ 桶0🔒│ 桶1 │ 桶2🔒│ 桶3 │
└──────┴──────┴──────┴──────┘
锁粒度:桶头节点
一句话
JDK7 锁段,JDK8 锁桶;JDK8 并发度更高,还支持多线程协助扩容。
4. ConcurrentHashMap 的 put 流程是什么?
人话
- 算出 key 放哪个桶。
- 桶是空的 → CAS 直接放,不加锁。
- 桶正在扩容 → 帮忙搬。
- 桶正常 → 锁住桶头,链表/树插入。
- 插完更新 size,可能触发扩容。
put(key, value)
│
▼
算 hash,定位桶
│
▼
桶是空的?
├── 是 → CAS 插入
└── 否 → 桶正在扩容?
├── 是 → 帮忙搬
└── 否 → synchronized 锁桶头
│
▼
链表/红黑树插入
│
▼
addCount 更新 size
一句话
空桶 CAS,非空桶加锁,扩容就帮忙,最后更新 size。
5. get 为什么不需要加锁?
人话
因为存的数据本身是 volatile 的。
一个线程改了,另一个线程马上能看见。
读的时候不需要锁,直接读就行。
一个线程改了,另一个线程马上能看见。
读的时候不需要锁,直接读就行。
volatile 保证:
┌──────────────┐
│ 写线程改完 │
│ ↓ │
│ 立刻刷到主存 │
│ ↓ │
│ 读线程能看到 │
└──────────────┘
get 流程:
get(key)
│
▼
定位桶
├── 空 → 返回 null
├── 头节点匹配 → 返回
├── 链表/树 → 遍历
└── ForwardingNode → 去新表查
一句话
get 靠 volatile 保可见性,无锁读,遇到扩容标记就去新表查。
6. 扩容是怎么做的?
人话
旧表容量不够了,要搬到新表。
不是一个人搬,而是多个线程一起搬。
每个线程领取一段桶,搬完插个牌子。
别人看到牌子,要么去新表读,要么一起帮忙搬。
不是一个人搬,而是多个线程一起搬。
每个线程领取一段桶,搬完插个牌子。
别人看到牌子,要么去新表读,要么一起帮忙搬。
旧表 16 个桶,扩到 32
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ ...
└───┴───┴───┴───┴───┴───┴───┴───┘
▲ ▲
│ │
线程C搬 线程A搬
[0,4) [4,8)
搬完的桶放 ForwardingNode(牌子)
一句话
多线程分段搬,搬完插 ForwardingNode,别人看到就去新表或帮忙。
7. size 为什么是弱一致的?
人话
因为 size 不是一个变量,而是一堆格子。
每个线程往不同格子加,避免抢。
size() 的时候把格子加起来。
加的过程中别人还在改,所以只能算个大概。
每个线程往不同格子加,避免抢。
size() 的时候把格子加起来。
加的过程中别人还在改,所以只能算个大概。
baseCount = 5
CounterCell[0] = 3
CounterCell[1] = 7
CounterCell[2] = 2
size ≈ 5 + 3 + 7 + 2 = 17
一句话
size 分散统计避免争抢,求和时没锁,所以是近似值。
8. 为什么不允许 null key 和 null value?
人话
并发下,get 返回 null 有两种可能:
- 这个 key 不存在。
- 这个 key 存在,但值是 null。
单线程可以用 containsKey 再确认一次。
但并发下,你确认的时候别人可能已经改了。
所以干脆不允许 null,避免歧义。
get(key) 返回 null
│
├── 单线程:再 containsKey 确认
│
└── 并发:确认的瞬间可能被改,不可靠
一句话
并发下 null 无法区分“不存在”和“值为 null”,所以禁止。
9. 为什么非空桶用 synchronized,不用 ReentrantLock?
人话
- synchronized 优化后性能不差。
- 锁的是桶头,冲突概率低。
- 代码简单,内存开销小。
- JVM 还能做偏向锁、轻量级锁优化。
synchronized:
├── 偏向锁 → 轻量级锁 → 重量级锁
└── JVM 自动优化
ReentrantLock:
├── 需要手动 lock/unlock
└── 每个锁对象占内存
一句话
synchronized 够用、简单、JVM 会优化,所以选它。
10. 链表什么时候转红黑树?
人话
两个条件同时满足才转:
- 链表长度 >= 8。
- 数组容量 >= 64。
如果链表到 8 了,但容量不到 64,
先扩容,而不是转树。
链表长度 >= 8?
├── 否 → 继续链表
└── 是 → 容量 >= 64?
├── 是 → 转红黑树
└── 否 → 先扩容
为什么是 8?
哈希分布均匀时,链表到 8 的概率极低。
红黑树节点更占空间,没必要过早转。
一句话
长度 8 且容量 64 才转树,否则先扩容。
11. 并发度是多少?
人话
JDK7:默认 16 段,并发度 16。
JDK8:没有固定并发度,锁的是桶。
理论上不同桶可以同时写。
并发度 = 桶的数量。
JDK8:没有固定并发度,锁的是桶。
理论上不同桶可以同时写。
并发度 = 桶的数量。
JDK7:16 个 Segment,最多 16 个线程同时写。
JDK8:16 个桶,最多 16 个线程同时写不同桶。
桶越多,并发度越高。
一句话
JDK7 并发度等于段数,JDK8 等于桶数,粒度更细。
12. 多线程协助扩容是怎么实现的?
人话
用一个变量 transferIndex 记录搬到哪了。
每个线程用 CAS 抢一段任务。
抢到就搬,搬完再抢下一段。
直到搬完。
每个线程用 CAS 抢一段任务。
抢到就搬,搬完再抢下一段。
直到搬完。
transferIndex = 16
线程A:CAS 抢到 [12,16),搬
线程B:CAS 抢到 [8,12),搬
线程C:CAS 抢到 [4,8),搬
线程D:CAS 抢到 [0,4),搬
一句话
CAS 抢任务段,多线程分段搬,transferIndex 记录进度。
13. CAS、synchronized、volatile 各自的作用是什么?
人话
CAS:负责空桶插入、初始化、size 统计、抢扩容任务。
synchronized:负责非空桶的链表/树插入。
volatile:负责保证读的可见性,让 get 无锁。
synchronized:负责非空桶的链表/树插入。
volatile:负责保证读的可见性,让 get 无锁。
| 机制 | 用在哪 |
|---|---|
| CAS | 空桶插入、初始化、size、扩容任务 |
| synchronized | 非空桶链表/树插入 |
| volatile | table、val、next、sizeCtl |
一句话
CAS 管轻量原子,synchronized 管复杂操作,volatile 管读可见。
14. 迭代器弱一致是什么意思?
人话
你在遍历的时候,别人可以同时改。
你看到的是遍历开始那一刻的大致快照。
不会抛 ConcurrentModificationException。
但也不保证看到最新数据。
你看到的是遍历开始那一刻的大致快照。
不会抛 ConcurrentModificationException。
但也不保证看到最新数据。
遍历开始 → 看到一部分数据
│
├── 别人插入 → 你可能看到,也可能看不到
├── 别人删除 → 你可能还看到旧数据
└── 但不会报错
一句话
弱一致 = 不报错,但不保证最新,适合并发场景。
15. get 遇到 ForwardingNode 为什么要去新表查?
人话
ForwardingNode 是一个牌子。
意思是:这个桶已经搬到新表了。
你在这里找不到,就去新表找。
意思是:这个桶已经搬到新表了。
你在这里找不到,就去新表找。
旧表桶:
┌──────────────┐
│ ForwardingNode│ → 指向新表
└──────────────┘
│
▼
去新表对应位置继续查
一句话
ForwardingNode 是扩容路标,看到它就去新表查。
16. key 和 value 能为 null 吗?
人话
不能。
key 不能为 null,value 也不能为 null。
原因就是前面说的:并发下 null 有歧义。
key 不能为 null,value 也不能为 null。
原因就是前面说的:并发下 null 有歧义。
| 集合 | 规则 |
|---|---|
| HashMap | key 和 value 都可以为 null |
| Hashtable | key 和 value 都不能为 null |
| ConcurrentHashMap | key 和 value 都不能为 null |
一句话
ConcurrentHashMap 禁止 null key 和 null value。
17. 请完整说一下 ConcurrentHashMap 的原理。
标准回答
ConcurrentHashMap 是线程安全的 HashMap,核心思路是锁得更细。
它底层是数组 + 链表 + 红黑树,和 HashMap 类似。
线程安全靠三样东西:
CAS、synchronized、volatile。
put 的时候:
- 桶是空的,用 CAS 直接插入,不加锁。
- 桶不是空的,用 synchronized 锁住桶头,再链表或红黑树插入。
- 遇到扩容,就帮忙一起搬。
get 不加锁,靠 volatile 保证可见性,遇到 ForwardingNode 去新表查。
size 用 baseCount + CounterCell 分散统计,所以是弱一致。
扩容时多线程分段协助,用 transferIndex 分配任务。
它不允许 null key 和 null value。
和 Hashtable 比,它锁粒度更细,并发度更高。
一句话
空桶 CAS 抢,非空桶加锁写,扩容大家一起搬,读靠 volatile,size 大概准。
18. 补充:CAS 是什么?在 ConcurrentHashMap 中怎么起作用?
人话
CAS = Compare And Swap,比较并交换。
它有三个值:
V:内存位置
A:我期待的旧值
B:我要写的新值
如果 V 当前值 == A,就把 V 改成 B,成功。
否则什么都不做,失败。
它有三个值:
V:内存位置
A:我期待的旧值
B:我要写的新值
如果 V 当前值 == A,就把 V 改成 B,成功。
否则什么都不做,失败。
CAS(V, A, B)
│
▼
V 当前值 == A ?
├── 是 → V = B,返回成功
└── 否 → 不写,返回失败
在 ConcurrentHashMap 中:
- 初始化 table:CAS 把 sizeCtl 从 0 改成 -1。
- 空桶插入:CAS 把 null 替换成新 Node。
- size 统计:CAS 更新 baseCount 或 CounterCell。
- 协助扩容:CAS 抢 transferIndex 任务段。