跳到主要内容

ConcurrentHashMap

问题

ConcurrentHashMap 是怎么保证线程安全的?JDK 7 和 JDK 8 的实现有什么区别?

答案

JDK 7:Segment 分段锁

JDK 7 的 ConcurrentHashMap 采用 Segment 数组 + HashEntry 数组 + 链表 结构:

  • 将数据分成多个 Segment(默认 16 个),每个 Segment 是一个独立的哈希表
  • 每个 Segment 继承 ReentrantLock,操作只锁定对应的 Segment
  • 并发度 = Segment 数量(默认 16 个线程可同时写入不同 Segment)

JDK 8:CAS + synchronized

JDK 8 放弃了 Segment,改为 Node 数组 + 链表/红黑树,与 HashMap 结构类似:

ConcurrentHashMap.putVal()(JDK 8 简化)
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 不允许 null key 和 null value
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());

for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;

if (tab == null || (n = tab.length) == 0)
tab = initTable(); // CAS 初始化

else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 桶为空:CAS 插入,无需加锁
if (casTabAt(tab, i, null, new Node<>(hash, key, value, null)))
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 协助扩容

else {
// 桶不为空:synchronized 锁住头节点
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
// 链表遍历插入(尾插法)
// ...
} else if (f instanceof TreeBin) {
// 红黑树插入
// ...
}
}
}
}
}
addCount(1L, binCount); // CAS 更新 size
return null;
}

核心策略

  1. 桶为空:使用 CAS 无锁插入
  2. 桶不为空:使用 synchronized 锁住桶的头节点(锁粒度比 JDK 7 更细)
  3. 扩容时:多线程协助迁移(helpTransfer),加速扩容

JDK 7 vs JDK 8 对比

维度JDK 7JDK 8
结构Segment + HashEntry + 链表Node 数组 + 链表 + 红黑树
锁粒度Segment(一段数组)桶头节点(单个桶)
锁实现ReentrantLockCAS + synchronized
并发度固定(Segment 数量)与桶数量正相关
空桶插入加锁CAS 无锁
扩容Segment 内独立扩容多线程协助迁移
链表优化转红黑树(> 8)

size() 的实现

JDK 8 使用 baseCount + CounterCell[] 分散计数(类似 LongAdder):

// 写入时:CAS 更新 baseCount,失败则用 CounterCell 分散
// 读取时:baseCount + 所有 CounterCell.value 之和
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}

final long sumCount() {
CounterCell[] cs = counterCells;
long sum = baseCount;
if (cs != null) {
for (CounterCell c : cs)
if (c != null) sum += c.value;
}
return sum;
}

常见面试问题

Q1: ConcurrentHashMap 为什么不允许 null key/value?

答案

在并发环境下,null 值会导致歧义。get(key) 返回 null 时,无法区分是"key 不存在"还是"value 就是 null"。单线程 HashMap 可以用 containsKey() 区分,但并发环境下 containsKey()get() 之间其他线程可能修改了数据,判断不可靠。

Q2: ConcurrentHashMap 能完全替代 Hashtable 吗?

答案

功能上可以完全替代。ConcurrentHashMap 在所有方面都优于 Hashtable:

  • Hashtable 对整个表加锁,并发性能极差
  • ConcurrentHashMap 锁粒度更细,读操作几乎无锁

唯一注意的是 Hashtable 不允许 null,ConcurrentHashMap 也不允许,这点一致。

Q3: JDK 8 中 ConcurrentHashMap 为什么用 synchronized 而不是 ReentrantLock?

答案

  1. JVM 优化:JDK 6+ 对 synchronized 做了大量优化(偏向锁、轻量级锁、锁粗化、锁消除),性能已接近 ReentrantLock
  2. 内存开销synchronized 锁信息存在对象头中,不需要额外创建锁对象。如果用 ReentrantLock,每个桶都需要一个锁对象
  3. 锁升级synchronized 可以从偏向锁 → 轻量级锁 → 重量级锁自动升级,低竞争时性能更好

Q4: ConcurrentHashMap 的扩容过程是怎样的?

答案

JDK 8 的 ConcurrentHashMap 支持多线程协助扩容

  1. 触发扩容后,将数组分成多个区间,每个线程负责迁移一段
  2. 已迁移完的桶放置 ForwardingNode(hash = MOVED),新的读写请求会被转发到新数组
  3. 其他线程遇到 ForwardingNode 时,会调用 helpTransfer() 加入迁移
  4. 所有桶迁移完成后,替换为新数组

Q5: 读操作需要加锁吗?

答案

不需要。Node 的 valnext 字段用 volatile 修饰,保证可见性。读操作直接读取,没有同步开销。这也是 ConcurrentHashMap 高性能的关键之一。

Q6: ConcurrentHashMap 和 Collections.synchronizedMap() 的区别?

答案

维度ConcurrentHashMapsynchronizedMap
锁粒度桶级别整个 Map
读操作无锁加锁
并发性能
迭代器弱一致性(不抛异常)必须手动同步
null 支持不允许取决于底层 Map

相关链接