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;
}
核心策略:
- 桶为空:使用 CAS 无锁插入
- 桶不为空:使用 synchronized 锁住桶的头节点(锁粒度比 JDK 7 更细)
- 扩容时:多线程协助迁移(helpTransfer),加速扩容
JDK 7 vs JDK 8 对比
| 维度 | JDK 7 | JDK 8 |
|---|---|---|
| 结构 | Segment + HashEntry + 链表 | Node 数组 + 链表 + 红黑树 |
| 锁粒度 | Segment(一段数组) | 桶头节点(单个桶) |
| 锁实现 | ReentrantLock | CAS + 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?
答案:
- JVM 优化:JDK 6+ 对
synchronized做了大量优化(偏向锁、轻量级锁、锁粗化、锁消除),性能已接近 ReentrantLock - 内存开销:
synchronized锁信息存在对象头中,不需要额外创建锁对象。如果用 ReentrantLock,每个桶都需要一个锁对象 - 锁升级:
synchronized可以从偏向锁 → 轻量级锁 → 重量级锁自动升级,低竞争时性能更好
Q4: ConcurrentHashMap 的扩容过程是怎样的?
答案:
JDK 8 的 ConcurrentHashMap 支持多线程协助扩容:
- 触发扩容后,将数组分成多个区间,每个线程负责迁移一段
- 已迁移完的桶放置
ForwardingNode(hash = MOVED),新的读写请求会被转发到新数组 - 其他线程遇到 ForwardingNode 时,会调用
helpTransfer()加入迁移 - 所有桶迁移完成后,替换为新数组
Q5: 读操作需要加锁吗?
答案:
不需要。Node 的 val 和 next 字段用 volatile 修饰,保证可见性。读操作直接读取,没有同步开销。这也是 ConcurrentHashMap 高性能的关键之一。
Q6: ConcurrentHashMap 和 Collections.synchronizedMap() 的区别?
答案:
| 维度 | ConcurrentHashMap | synchronizedMap |
|---|---|---|
| 锁粒度 | 桶级别 | 整个 Map |
| 读操作 | 无锁 | 加锁 |
| 并发性能 | 高 | 低 |
| 迭代器 | 弱一致性(不抛异常) | 必须手动同步 |
| null 支持 | 不允许 | 取决于底层 Map |