# 问题
21. ConcurrentHashMap 为什么适合高并发?它如何避免数据竞争?
# 标准答案
ConcurrentHashMap 采用 CAS(Compare-And-Swap)+ 自旋锁 + 分段锁 + 非阻塞算法 结合 红黑树优化,保证高并发场景下的高效读写。
- 读操作(get)无锁:利用
volatile
关键字保证可见性,并发读不会阻塞,提高吞吐量。 - 写操作(put/remove)局部加锁:1.7 采用 Segment 分段锁,1.8 采用 Node + CAS + synchronized 细粒度锁,减少锁竞争。
- 避免扩容竞争:扩容采用 多线程渐进式扩容,避免全局停顿。
因此,ConcurrentHashMap 在高并发场景下相比 HashTable(全表锁)和 SynchronizedMap(全局锁),具备 更高的吞吐量和更低的锁冲突。
# 答案解析
# 1. ConcurrentHashMap 的核心优化点
ConcurrentHashMap 从 JDK 1.7 到 1.8 进行了重大优化:
版本 | 主要结构 | 读写加锁方式 | 并发粒度 | 主要缺点 |
---|---|---|---|---|
JDK 1.7 | Segment + HashEntry | ReentrantLock 分段锁 | Segment 级别(默认 16 段) | 空间浪费,扩容时仍有锁竞争 |
JDK 1.8 | Node + CAS + synchronized | CAS + synchronized 细粒度锁 | Bucket 级别 | 链表转红黑树时可能有阻塞 |
# JDK 1.7 实现:Segment 分段锁
ConcurrentHashMap 1.7 采用 分段锁(Segment + HashEntry),默认分成 16 个 Segment,每个 Segment 内部维护一组 HashEntry:
static final class Segment<K,V> extends ReentrantLock {
transient volatile HashEntry<K,V>[] table;
}
2
3
get()
只需 无锁读,直接访问volatile
数组,提高性能。put()
/remove()
需要 对某个 Segment 加锁,不同 Segment 可以并发操作,但同一 Segment 仍然会锁竞争。- 扩容仍然需要锁住整个 Segment,并迁移所有元素,影响吞吐量。
# JDK 1.8 重大优化:取消 Segment,改用 CAS + synchronized
为了进一步减少锁竞争,1.8 版本移除了 Segment,直接使用 Node[] table
+ CAS + synchronized:
static class Node<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
}
2
3
4
5
6
get()
操作:- 无锁读取,依赖
volatile
保证可见性,多个线程可以并发读取,提高吞吐量。
- 无锁读取,依赖
put()
操作:- 先用 CAS(乐观锁)尝试插入,避免不必要的锁竞争。
- CAS 失败时,才 使用 synchronized 只锁定单个桶,而不是整个 Map,提高并发度。
- 扩容时的优化:
- 分布式迁移,不同线程同时帮助扩容,避免全局锁阻塞。
# 2. ConcurrentHashMap 如何避免数据竞争?
ConcurrentHashMap 采用 多个机制结合,保证高并发下的安全性:
✅ 1. 读操作完全无锁(volatile
保证可见性)
✅ 2. 写入采用 CAS + synchronized 细粒度锁,减少竞争
✅ 3. 扩容采用渐进式复制,避免全表锁
✅ 4. 采用红黑树优化长链查找,避免链表过长导致的退化
# (1)CAS + 自旋锁
CAS(Compare-And-Swap)是一种无锁操作,它可以 原子性更新变量:
if (UNSAFE.compareAndSwapObject(tab, i, null, new Node<K,V>(hash, key, value, null))) {
return null; // 插入成功
}
2
3
- 如果该槽位为空,则 CAS 原子性插入,无锁写入。
- 如果失败(说明有冲突),才会退化为 synchronized 锁住单个桶,减少锁冲突。
# (2)红黑树优化
如果同一哈希槽的链表过长(默认阈值 8
),会自动转换为 红黑树,加速查询:
if (binCount >= TREEIFY_THRESHOLD) {
tab[i] = new TreeNode<>(key, value, hash);
}
2
3
- 链表查询 O(n),红黑树查询 O(log n),极大提升性能。
- 但
put()
和remove()
时,可能需要重新平衡红黑树,开销稍高。
# (3)渐进式扩容
扩容时,ConcurrentHashMap 采用 多线程并发迁移,避免 HashMap 那样的全局锁停顿:
Node<K,V> f;
if ((f = tab[i]) != null) {
transfer(tab, newTab);
}
2
3
4
- 多个线程 同时分批复制不同的槽位,加速扩容过程。
- 旧表保留部分访问能力,不会完全阻塞查询。
# 常见错误
❌ 误区 1:ConcurrentHashMap 允许 key/value 为 null
- 错误示例:
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>(); map.put(null, "value"); // ❌ NullPointerException
1
2- 因为
null
可能引发 空指针异常,影响并发 CAS 逻辑。 - 解决方案:使用
Optional
或其他默认值替代null
。
- 因为
❌ 误区 2:get()
是原子性的
get()
只能保证读取值的 可见性,但并不保证 读取后值仍然有效(可能被其他线程修改)。
# 最佳实践
- 优先使用 ConcurrentHashMap 代替 HashTable 或 SynchronizedMap
- HashTable / SynchronizedMap 使用 全表锁,并发性能低。
- 避免使用
size()
方法size()
需要遍历整个表,在高并发下可能不准确,应避免使用。
- 如果 key 可能有 null,考虑使用
computeIfAbsent()
map.computeIfAbsent("key", k -> new Value());
1- 避免手动检查
null
,提高安全性。
- 避免手动检查
# 深入追问
🔹 1. 为什么 ConcurrentHashMap 不能完全无锁?
- 读操作可以无锁,但写入操作如果完全无锁,无法保证一致性。
- CAS 适用于简单值更新,但 复杂结构(如链表、红黑树)仍需要加锁。
🔹 2. 为什么 ConcurrentHashMap 在高并发下比 HashTable 快?
- HashTable 使用 全表锁,ConcurrentHashMap 局部加锁 + CAS,减少锁冲突。
- 1.8 版本优化了 链表转红黑树、渐进式扩容,进一步提升并发性能。
🔹 3. 在高并发缓存中,ConcurrentHashMap 和 Guava Cache / Caffeine 该如何选择?
- ConcurrentHashMap 适用于简单的 KV 存储,支持高并发但无缓存过期机制。
- Guava Cache / Caffeine 支持 自动过期、LRU 淘汰策略,适用于 Web 缓存。
# 相关面试题
ConcurrentHashMap
和Collections.synchronizedMap()
有什么区别?ConcurrentHashMap
为什么不允许null
?CAS
失败后如何处理?