ConcurrentHashMap 底层原理:面试必问的并发安全容器
我秋招面试字节的时候,被问到这么一道题:
“HashMap 线程不安全,那你在多线程场景下用什么?”
我脱口而出:“ConcurrentHashMap。”
面试官追问:“那你说说它是怎么保证线程安全的?”
我卡壳了。
说出来不怕你笑话,我当时只会背"分段锁"三个字,但具体怎么实现的、为什么 JDK 1.8 要改成 CAS + synchronized、一致性是怎么保证的——全是一团浆糊。
这篇文章,就是我踩过坑之后写给自己的复盘笔记。
一、为什么 HashMap 不安全?
在说 ConcurrentHashMap 之前,先搞清楚 HashMap 为什么不安全。
三个字:并发修改。
两个线程同时往 HashMap 里 put 数据,可能发生:
- 数据覆盖:线程 A 和 B 同时算出了相同的 index,A 写入后 B 直接覆盖
- 死循环:JDK 1.7 的扩容 rehash,链表可能形成环,导致死循环(这个坑我在线上踩过)
- 数组越界:扩容时数组引用不一致,导致空指针
所以多线程下用 HashMap,约等于给自己埋雷。
二、JDK 1.7:分段锁 SegmentedMap
怎么实现的?
// JDK 1.7 的结构
Segment[] segments; // 每个 Segment 是一把锁
// 一次 put
public V put(K key, V value) {
int hash = hash(key);
int segIndex = hash % segments.length;
synchronized (segments[segIndex]) { // 只锁一个 Segment
// 在 Segment 内部操作 HashMap
}
}
核心思想:把整个 map 分成 N 个段(默认 16),每个段独立加锁。
优点
- 多个线程可以并发操作不同的段,吞吐量提升
缺点
- 锁的粒度还是太粗(锁的是整个 Segment)
- 并发度受 Segment 数量限制,16 就是天花板
三、JDK 1.8:CAS + Synchronized
为什么改?
JDK 1.7 的分段锁听起来不错,但问题来了:
- Segment 数量固定,并发度上不去
- 实现复杂,代码维护成本高
于是 JDK 1.8 直接抄了 HashMap 的数据结构,改用桶级锁 + CAS。
核心机制
// JDK 1.8 的结构
Node[] table; // 和 HashMap 一样,数组 + 链表/红黑树
// 一次 put 的大致逻辑
1. 计算 hash,找到数组位置
2. if (该位置为空) {
// 用 CAS 写入,可能失败重试
CAS + 自旋写入
} else {
// 该位置有数据,加锁
synchronized (该位置的 Node) {
// 链表/红黑树插入
}
}
两个核心武器:
- CAS:乐观锁,适合低并发场景,线程不阻塞
- synchronized:悲观锁,适合高并发写入,锁单个桶
什么时候用哪个?
| 场景 | 用什么 | 原因 |
|---|---|---|
| 数组位置为空 | CAS | 无竞争,直接写入 |
| 链表/红黑树操作 | synchronized | 需要独占修改 |
四、面试高频问题
Q1:ConcurrentHashMap 怎么保证线程安全的?
三板斧:
- CAS 保证原子性:空位置写入
- synchronized 保证可见性:链表/红黑树操作
- volatile 保证可见性:Node 的 value 和 next 指针
Q2:和 Hashtable 有什么区别?
| 对比项 | Hashtable | ConcurrentHashMap |
|---|---|---|
| 锁粒度 | 全局一把锁 | 桶级锁(JDK 1.8) |
| 并发度 | 低(等于1) | 高(N 个桶) |
| 实现复杂度 | 简单 | 复杂 |
| 推荐 | 不推荐 | 推荐 |
我的踩坑:以前觉得 Hashtable 更"安全",结果生产环境用了之后 QPS 腰斩。换成 ConcurrentHashMap 之后,吞吐量直接翻倍。
Q3:JDK 1.8 为什么用 synchronized 而不是 ReentrantLock?
官方的解释:
- JVM 层面优化:synchronized 在 JVM 层面做了很多优化(偏向锁、轻量级锁、自旋锁)
- API 简化:不需要手动释放锁,不容易出错
- 性能足够:在 JVM 的优化下,synchronized 性能已经不输 ReentrantLock
Q4:size() 怎么保证准确的?
不能保证准确。
ConcurrentHashMap 的 size() 是一个历史遗留问题:
- JDK 1.7:每个 Segment 独立计数,累加时需要加锁
- JDK 1.8:用一个 volatile 的 baseCount + CounterCell 数组,不保证完全准确
为什么不用锁保护? 因为要保证性能,牺牲了一点准确性。
五、我踩过的坑
坑1:以为 size() 是准确的
// 错误用法
if (map.size() > 0) {
// 以为一定不为空,结果翻车
}
// 正确用法
if (!map.isEmpty()) {
// 用 isEmpty() 判断,更可靠
}
坑2:putIfAbsent 的误用
// 场景:实现分布式锁
map.putIfAbsent(key, value);
// 以为这样就能保证只有一个线程成功
// 实际上:需要检查返回值判断是否成功
String oldValue = map.putIfAbsent(key, value);
if (oldValue != null) {
// 说明已经有值了,获取锁失败
}
坑3:遍历时修改
// 错误:ConcurrentHashMap 的迭代器是弱一致性的
for (String key : map.keySet()) {
if (需要删除) {
map.remove(key); // 可能抛异常
}
}
// 正确:用迭代器的 remove
Iterator<String> iter = map.keySet().iterator();
while (iter.hasNext()) {
String key = iter.next();
if (需要删除) {
iter.remove(); // 安全删除
}
}
六、记忆口诀
ConcurrentHashMap 三层保障:
1. CAS 写入(空位置,原子操作)
2. synchronized 修改(链表/红黑树,独占锁)
3. volatile 可见(Node.value 和 next)
对比 Hashtable:
- Hashtable = 全局锁 = 串行
- CHM = 桶锁 = 并行
写在最后
面试问到 ConcurrentHashMap,其实是在考你对并发的理解。
不要只背"分段锁"、"CAS + synchronized"这几个词。要能说出来:
- 什么场景用 CAS,什么场景用 synchronized
- 为什么这样设计
- 有什么取舍
我被问住那次,就是因为只背了概念,没理解为什么。
后来我花了一整晚看源码,把 JDK 1.7 和 1.8 的区别、put/get/remove 的流程都手画了一遍。
技术这东西,真的不能只背,要理解本质。
更多推荐


所有评论(0)