← 所有文章

NEKOBLANK / NOTES

ConcurrentHashMap 面试十八问

约 3757 字 约 13 分钟阅读

写在前面 (Introduction)

本文按 18 道面试题整理 ConcurrentHashMap 的常见问答。每题保留原来的「人话」、示意图和「一句话」,只调整了标题层级、表格和提示框。

代码编辑器


题目目录

  1. ConcurrentHashMap 和 HashMap 有什么区别?
  2. ConcurrentHashMap 和 Hashtable 有什么区别?
  3. JDK7 和 JDK8 的 ConcurrentHashMap 有什么区别?
  4. ConcurrentHashMap 的 put 流程是什么?
  5. get 为什么不需要加锁?
  6. 扩容是怎么做的?
  7. size 为什么是弱一致的?
  8. 为什么不允许 null key 和 null value?
  9. 为什么非空桶用 synchronized,不用 ReentrantLock?
  10. 链表什么时候转红黑树?
  11. 并发度是多少?
  12. 多线程协助扩容是怎么实现的?
  13. CAS、synchronized、volatile 各自的作用是什么?
  14. 迭代器弱一致是什么意思?
  15. get 遇到 ForwardingNode 为什么要去新表查?
  16. key 和 value 能为 null 吗?
  17. 请完整说一下 ConcurrentHashMap 的原理。
  18. 补充:CAS 是什么?在 ConcurrentHashMap 中怎么起作用?

1. ConcurrentHashMap 和 HashMap 有什么区别?

人话

HashMap:单线程用的,快,但多线程写会出问题。
ConcurrentHashMap:多线程用的,安全,稍微慢一点。
HashMapConcurrentHashMap
无锁CAS + 锁桶
允许 null不允许 null
线程不安全线程安全
size 精确size 弱一致
fail-fast弱一致迭代器

一句话

单线程用 HashMap,多线程用 ConcurrentHashMap。

2. ConcurrentHashMap 和 Hashtable 有什么区别?

人话

Hashtable:整个表一把大锁,一个人写,所有人等。
ConcurrentHashMap:每个桶一把小锁,不同桶可以同时写。
Hashtable:
┌─────────────────────────┐
│      一把大锁 🔒         │
│  线程A写,B/C/D全等着    │
└─────────────────────────┘

ConcurrentHashMap:
┌──────┬──────┬──────┬──────┐
│ 桶0🔒│ 桶1  │ 桶2🔒│ 桶3  │
│ 线程A│      │ 线程B│      │
└──────┴──────┴──────┴──────┘
不同桶互不影响

一句话

Hashtable 锁全表,ConcurrentHashMap 锁单桶,后者并发度高得多。

3. JDK7 和 JDK8 的 ConcurrentHashMap 有什么区别?

人话

JDK7:分段锁,把表分成 16 段,每段一把锁。
JDK8:锁到每个桶,粒度更细,还加了 CAS 和红黑树。
JDK7:
┌──── Segment[0] 🔒 ────┬──── Segment[1] 🔒 ────┐
│  HashEntry[]          │  HashEntry[]          │
└───────────────────────┴───────────────────────┘
锁粒度:Segment

JDK8:
table: Node[]
┌──────┬──────┬──────┬──────┐
│ 桶0🔒│ 桶1  │ 桶2🔒│ 桶3  │
└──────┴──────┴──────┴──────┘
锁粒度:桶头节点

一句话

JDK7 锁段,JDK8 锁桶;JDK8 并发度更高,还支持多线程协助扩容。

4. ConcurrentHashMap 的 put 流程是什么?

人话

  1. 算出 key 放哪个桶。
  2. 桶是空的 → CAS 直接放,不加锁。
  3. 桶正在扩容 → 帮忙搬。
  4. 桶正常 → 锁住桶头,链表/树插入。
  5. 插完更新 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() 的时候把格子加起来。
加的过程中别人还在改,所以只能算个大概。
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 有两种可能:

  1. 这个 key 不存在。
  2. 这个 key 存在,但值是 null。

    单线程可以用 containsKey 再确认一次。
    但并发下,你确认的时候别人可能已经改了。
    所以干脆不允许 null,避免歧义。
get(key) 返回 null
   │
   ├── 单线程:再 containsKey 确认
   │
   └── 并发:确认的瞬间可能被改,不可靠

一句话

并发下 null 无法区分“不存在”和“值为 null”,所以禁止。

9. 为什么非空桶用 synchronized,不用 ReentrantLock?

人话

  1. synchronized 优化后性能不差。
  2. 锁的是桶头,冲突概率低。
  3. 代码简单,内存开销小。
  4. JVM 还能做偏向锁、轻量级锁优化。
synchronized:
   ├── 偏向锁 → 轻量级锁 → 重量级锁
   └── JVM 自动优化

ReentrantLock:
   ├── 需要手动 lock/unlock
   └── 每个锁对象占内存

一句话

synchronized 够用、简单、JVM 会优化,所以选它。

10. 链表什么时候转红黑树?

人话

两个条件同时满足才转:

  1. 链表长度 >= 8。
  2. 数组容量 >= 64。

    如果链表到 8 了,但容量不到 64,
    先扩容,而不是转树。
链表长度 >= 8?
   ├── 否 → 继续链表
   └── 是 → 容量 >= 64?
              ├── 是 → 转红黑树
              └── 否 → 先扩容

为什么是 8?

哈希分布均匀时,链表到 8 的概率极低。

红黑树节点更占空间,没必要过早转。

一句话

长度 8 且容量 64 才转树,否则先扩容。

11. 并发度是多少?

人话

JDK7:默认 16 段,并发度 16。
JDK8:没有固定并发度,锁的是桶。
理论上不同桶可以同时写。
并发度 = 桶的数量。
JDK7:16 个 Segment,最多 16 个线程同时写。
JDK8:16 个桶,最多 16 个线程同时写不同桶。
      桶越多,并发度越高。

一句话

JDK7 并发度等于段数,JDK8 等于桶数,粒度更细。

12. 多线程协助扩容是怎么实现的?

人话

用一个变量 transferIndex 记录搬到哪了。
每个线程用 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 无锁。
机制用在哪
CAS空桶插入、初始化、size、扩容任务
synchronized非空桶链表/树插入
volatiletable、val、next、sizeCtl

一句话

CAS 管轻量原子,synchronized 管复杂操作,volatile 管读可见。

14. 迭代器弱一致是什么意思?

人话

你在遍历的时候,别人可以同时改。
你看到的是遍历开始那一刻的大致快照。
不会抛 ConcurrentModificationException。
但也不保证看到最新数据。
遍历开始 → 看到一部分数据
   │
   ├── 别人插入 → 你可能看到,也可能看不到
   ├── 别人删除 → 你可能还看到旧数据
   └── 但不会报错

一句话

弱一致 = 不报错,但不保证最新,适合并发场景。

15. get 遇到 ForwardingNode 为什么要去新表查?

人话

ForwardingNode 是一个牌子。
意思是:这个桶已经搬到新表了。
你在这里找不到,就去新表找。
旧表桶:
┌──────────────┐
│ ForwardingNode│ → 指向新表
└──────────────┘
   │
   ▼
去新表对应位置继续查

一句话

ForwardingNode 是扩容路标,看到它就去新表查。

16. key 和 value 能为 null 吗?

人话

不能。
key 不能为 null,value 也不能为 null。
原因就是前面说的:并发下 null 有歧义。
集合规则
HashMapkey 和 value 都可以为 null
Hashtablekey 和 value 都不能为 null
ConcurrentHashMapkey 和 value 都不能为 null

一句话

ConcurrentHashMap 禁止 null key 和 null value。

17. 请完整说一下 ConcurrentHashMap 的原理。

标准回答

ConcurrentHashMap 是线程安全的 HashMap,核心思路是锁得更细。

它底层是数组 + 链表 + 红黑树,和 HashMap 类似。

线程安全靠三样东西:

CAS、synchronized、volatile。

put 的时候:

  1. 桶是空的,用 CAS 直接插入,不加锁。
  2. 桶不是空的,用 synchronized 锁住桶头,再链表或红黑树插入。
  3. 遇到扩容,就帮忙一起搬。

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,成功。
否则什么都不做,失败。
CAS(V, A, B)
   │
   ▼
V 当前值 == A ?
   ├── 是 → V = B,返回成功
   └── 否 → 不写,返回失败

在 ConcurrentHashMap 中:

  1. 初始化 table:CAS 把 sizeCtl 从 0 改成 -1。
  2. 空桶插入:CAS 把 null 替换成新 Node。
  3. size 统计:CAS 更新 baseCount 或 CounterCell。
  4. 协助扩容:CAS 抢 transferIndex 任务段。