# 问题

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;
}
1
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;
}
1
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; // 插入成功
}
1
2
3
  • 如果该槽位为空,则 CAS 原子性插入,无锁写入。
  • 如果失败(说明有冲突),才会退化为 synchronized 锁住单个桶,减少锁冲突。
# (2)红黑树优化

如果同一哈希槽的链表过长(默认阈值 8),会自动转换为 红黑树,加速查询:

if (binCount >= TREEIFY_THRESHOLD) {
    tab[i] = new TreeNode<>(key, value, hash);
}
1
2
3
  • 链表查询 O(n),红黑树查询 O(log n),极大提升性能。
  • put()remove() 时,可能需要重新平衡红黑树,开销稍高。
# (3)渐进式扩容

扩容时,ConcurrentHashMap 采用 多线程并发迁移,避免 HashMap 那样的全局锁停顿:

Node<K,V> f;
if ((f = tab[i]) != null) {
    transfer(tab, newTab);
}
1
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() 只能保证读取值的 可见性,但并不保证 读取后值仍然有效(可能被其他线程修改)。

# 最佳实践

  1. 优先使用 ConcurrentHashMap 代替 HashTable 或 SynchronizedMap
    • HashTable / SynchronizedMap 使用 全表锁,并发性能低。
  2. 避免使用 size() 方法
    • size() 需要遍历整个表,在高并发下可能不准确,应避免使用。
  3. 如果 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 缓存。

# 相关面试题

  • ConcurrentHashMapCollections.synchronizedMap() 有什么区别?
  • ConcurrentHashMap 为什么不允许 null
  • CAS 失败后如何处理?