ConcurrentHashMap 1.7 vs 1.8 演进
为什么需要 ConcurrentHashMap?
HashMap 在并发场景下会出三个问题:
- 死循环(JDK 1.7 头插法导致扩容时环形链表)—— 线上真实烧过 CPU,后面细说
- 数据丢失(并发 put 覆盖)
- 数据不一致(get 读到中间状态)
Hashtable 用 synchronized 锁住整个方法,并发直接退化到串行。我见过一个老项目,线程池 200 个线程,Hashtable 一压,CPU 跑不满,TPS 只有 200/s,因为 199 个线程在等锁。
ConcurrentHashMap 填补了这段空白:高并发 + 高性能 + 线程安全。
JDK 1.7 实现:Segment 分段锁
整体结构
ConcurrentHashMap
├── Segment[] ← 默认 16 个,继承 ReentrantLock
│ ├── HashEntry[] ← 每个 Segment 内部维护一个哈希表
│ │ ├── HashEntry(key, value, hash, next)
│ │ ├── HashEntry(...)
│ │ └── ...
│ ├── HashEntry[]
│ │ └── ...
│ └── ...关键点:
Segment继承ReentrantLock,写操作先获取 Segment 的锁- 默认 16 个 Segment,理论最大并发度 = 16(实际受哈希分布影响,远低于 16)
HashEntry的value和next用volatile修饰,保证读的可见性
put 流程
// JDK 1.7 ConcurrentHashMap.put()
public V put(K key, V value) {
Segment<K,V> s = segmentForHash(hash(key)); // 定位到哪个 Segment
return s.put(key, hash, value, false);
}
// Segment.put()
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 先尝试获取锁,自旋 + 阻塞
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
try {
// 已获取锁,操作 Segment 内部的 HashEntry 数组
int index = hash & (table.length - 1);
HashEntry<K,V> first = table[index];
// 遍历链表,找到则替换,否则插入
// ...
} finally {
unlock();
}
}scanAndLockForPut 是 1.7 的优化:先自旋尝试获取锁,如果自旋次数超过阈值(MAX_SCAN_RETRIES,单核 1 次/多核 64 次),才进入阻塞等待。这种自旋 + 阻塞的锁策略在低竞争时性能更好。
get 流程
public V get(Object key) {
int hash = hash(key);
Segment<K,V> s = segmentForHash(hash);
HashEntry<K,V>[] tab = s.table;
int index = hash & (tab.length - 1);
HashEntry<K,V> e = tab[index];
while (e != null) {
if (e.hash == hash && key.equals(e.key)) {
return e.value;
}
e = e.next;
}
return null;
}get 全程无锁。依靠 HashEntry.value 和 HashEntry.next 的 volatile 语义保证可见性。
扩容
// Segment.rehash() — 每个 Segment 独立扩容
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
int newCapacity = oldCapacity << 1; // 翻倍
// 重建新数组,重新散列所有元素
HashEntry<K,V>[] newTable = HashEntry.newArray(newCapacity);
// ...
}1.7 的扩容是每个 Segment 独立进行,不会影响其他 Segment 的读写。但扩容时该 Segment 被锁住,其他线程对该 Segment 的写操作会阻塞。
1.7 的问题
- 并发度上限固定为 16(Segment 数组长度)
- 小 Segment 内哈希冲突严重时,链表遍历 O(n)
- 锁力度还是偏粗,一个 Segment 下所有桶共享一把锁
- 扩容时该 Segment 不可写,如果某个热点 key 正好在那个 Segment 里,写入直接卡住
JDK 1.8 实现:Node + CAS + synchronized
整体结构
ConcurrentHashMap
├── Node[] ← 直接数组,不再有 Segment
│ ├── Node(key, value, hash, next) ← 链表节点
│ ├── TreeNode(key, ..., parent, left, right)
│ │ └── TreeBin(root, waiter) ← 红黑树封装
│ └── ForwardingNode ← 扩容时的转发节点(hash == MOVED)1.8 直接使用 Node 数组,锁粒度从 Segment 降到单个桶。
putVal 流程
final V putVal(K key, V value, boolean onlyIfAbsent) {
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
// ① 懒初始化:CAS 设置 sizeCtl,避免锁
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// ② 桶为空:CAS 直接写入,无锁!
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}
else if ((fh = f.hash) == MOVED)
// ③ 正在扩容:帮助迁移,不等待
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// ④ 桶不为空:synchronized 锁住头节点
synchronized (f) {
if (tabAt(tab, i) == f) { // double-check
if (fh >= 0) {
// 链表遍历
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
// ...
}
} else if (f instanceof TreeBin) {
// 红黑树插入
// ...
}
}
}
}
}
// ⑤ 计数:用 CounterCell 热点分离
addCount(1L, binCount);
return null;
}putVal 时序图(正常写入 + 扩容协助两种场景):
场景一:空桶 CAS 写入
┌─────────┐ ┌──────────────┐
│ Thread A │ │ ConcurrentHashMap │
├─────────┤ ├──────────────┤
│ put(K,V) │ │ │
│ spread hash ├─────►│ tabAt 读桶 │
│ │◄─────────│ 桶 == null │
│ ├─────►───│ casTabAt 写入 │
│ │◄─────────│ CAS 成功 ✅ │
│ │ │ │
│ break │ │ │
└─────────┘ └──────────────┘
场景二:扩容中,当前线程协助
┌─────────┐ ┌──────────────┐
│ Thread B │ │ ConcurrentHashMap │
├─────────┤ ├──────────────┤
│ put(K,V) │ │ │
│ ├─────►───────│ tabAt 读桶 │
│ │ │◄─────────│ ForwardingNode │
│ │ │ │ (hash == MOVED)│
│ │ ├─────►───│ helpTransfer │
│ │ │ │ 协助迁移 │
│ │ │ │ 同步等待 │
│ │ │◄─────────│ 迁移完成 │
│ │ │ │ 继续 put │
│ │ retry│ │ │
│ │ for │ │ │
│ │ loop │ │ │
└─────────┘ └──────────────┘四个关键设计:
① 懒初始化:initTable() 通过 CAS 竞争 sizeCtl 变量,只有一个线程能初始化数组,其余线程 yield() 让出 CPU。
② CAS 写空桶:如果桶为空,直接 compareAndSwap 写入,完全无锁。这是最频繁的路径,也是 1.8 性能提升的核心。实测空桶写入占比约 80%+。
③ 协助扩容:如果检测到 ForwardingNode(hash == MOVED),当前线程不等待,而是调用 helpTransfer 参与数据迁移。变被动等待为主动协助,大幅降低扩容时的阻塞时间。
④ synchronized 锁头节点:锁的对象是桶的第一个节点,而非整个 Segment。不同桶的写操作互不干扰。JDK 1.6 优化后的 synchronized 性能已经接近甚至优于 ReentrantLock,且 JVM 能自动进行锁消除、锁粗化等优化。
⑤ CounterCell 计数:addCount 采用 LongAdder 思想,将计数分散到多个 CounterCell 中,避免所有线程争抢同一个 baseCount。调用 size() 时累加所有 CounterCell 的值,结果是估算值。
get 流程
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
else if (eh < 0)
// 红黑树查找
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}get 同样全程无锁,Node.val 和 Node.next 用 volatile 保证可见性。
红黑树优化
当链表长度 ≥ 8 时,转化为红黑树(TreeBin),查找复杂度从 O(n) 降到 O(log n)。
TreeBin 读写分离:
- 写操作:互斥锁(
lockRoot/unlockRoot) - 读操作:无锁,通过 volatile 保证可见性,同时检查树结构和当前节点是否被修改
1.8 扩容:多线程协助
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 每个线程处理的步长,最小 16
stride = (NCPU > 1) ? (n >>> 3) / NCPU : n;
if (stride < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE;
// 初始化 nextTable
if (nextTab == null) { /* ... */ }
// 用 ForwardingNode 标记已迁移的桶
for (int i = 0; i < n; ++i) {
// 迁移节点到新数组
// 设置 ForwardingNode
}
}多线程扩容时序图:
Time Thread A (首次触发扩容) Thread B (put 时发现扩容) Thread C (put 时发现扩容)
│ put → addCount put → addCount put → addCount
│ 发现 sizeCtl < 0 sizeCtl < 0 sizeCtl < 0
│ CAS 设置 sizeCtl transferIndex 分配 stride transferIndex 分配 stride
│ 成功 ✅ 成功 ✅ 成功 ✅
│ new 2倍数组 stride=16 stride=16
│ transfer()
│ ├─迁移 0-15 桶 ├─迁移 16-31 桶 ├─迁移 32-47 桶
│ ├─设 ForwardingNode ├─设 ForwardingNode ├─设 ForwardingNode
│ ├─迁移 48-63 桶 ├─迁移 64-79 桶 ├─迁移 80-95 桶
│ │ ... │ ... │ ...
│ └─迁移完,替换 table └─迁移完,退出 └─迁移完,退出
│
▼ sizeCtl 恢复为 0.75*newCap多线程扩容的核心机制:
sizeCtl记录扩容状态,通过 CAS 分配任务- 每个线程迁移一个
stride范围的桶 - 迁移完的桶设置
ForwardingNode,后续线程可以直接协助 - 写线程进来发现
ForwardingNode,调用helpTransfer一起搬
1.7 vs 1.8 对比总结
| 维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 数据结构 | Segment[] + HashEntry[] | Node[] + 链表/红黑树 |
| 并发策略 | 分段锁(ReentrantLock) | CAS + synchronized |
| 锁粒度 | Segment(默认 16 个) | 单个桶(链表头/树根) |
| 最大并发度 | 16 | 数组长度(理论上万) |
| 哈希冲突优化 | 链表 O(n) | 链表 O(n) 转红黑树 O(log n) |
| 扩容 | 单线程、Segment 独立 | 多线程协助 |
| 计数 | 单变量 + 锁 | CounterCell 热点分离 |
| get 无锁 | ✅ volatile | ✅ volatile |
| 初始化 | 构造时分配 | 懒初始化 CAS |
| 键值允许 null | ❌ | ❌ |
线上真实踩坑案例
踩坑 1:size() 不准导致监控误报
某次线上监控告警:缓存命中率突降。查日志发现监控系统每隔 30s 调用 ConcurrentHashMap.size() 统计节点数。但 size() 内部是遍历 CounterCell 累加,在百万级并发写入时,size() 持续波动,最小值只有实际值的 10%。
根因:size() 返回的是快照值,不是精确值。mappingCount() 返回 long,同样不精确。
修复:改用维护独立 AtomicLong 计数器,只在查询时同步一次 CHM 的实际大小做校准。
踩坑 2:1.8 扩容时 CPU 飙到 100%
某次灰度发布后,一个 16 核机器上部署的缓存服务 CPU 从 30% 飙到 100%。jstack 发现大量线程卡在 helpTransfer 和 ConcurrentHashMap.putVal 的循环里。
根因:初始容量设太小(new ConcurrentHashMap<>(16)),服务启动后大量写入触发了连续扩容。每次扩容所有写线程都参与 transfer,sizeCtl 的 CAS 竞争激烈,CPU 全耗在自旋上。
修复:new ConcurrentHashMap<>(4096) 预分配足够容量,避免运行时扩容。
踩坑 3:HashMap 死循环线上事故
一个老服务(JDK 1.7 + HashMap 做本地缓存),半夜触发扩容,链表头插法形成环形链表。get 操作陷入死循环,CPU 打满 100%,响应超时从 50ms 膨胀到 30s。
复盘:排查时一台一台重启,每台重启后 10 分钟又烧。最后定位到 HashMap 是单例,重启后触发的仍然是同一个 HashMap 的同一个 bug。
教训:并发本地缓存必须用 ConcurrentHashMap。JDK 1.8 已经修了扩容死循环(改为尾插法),但数据丢失问题还在,所以仍然不能用 HashMap。
面试常见追问
1. 1.8 的 size() 为什么是估算值?
sumCount() 遍历所有 CounterCell 累加,中间可能有其他线程还在写,所以不是精确值。JDK 8 的 mappingCount() 返回 long 类型,比 size() 更推荐。但两者都是近似值,不是精确值。
面试官追问:那我需要精确值怎么办?
- 场景允许:用
synchronized包一层size()调用,牺牲精度换准确 - 场景不允许:不要依赖 CHM 的 size 做精确判断,改用独立计数器
2. 为什么链表转红黑树的阈值是 8?
泊松分布计算:当负载因子 0.75 时,链表长度超过 8 的概率极低(约 0.0000006),说明哈希函数已经严重失效,此时用红黑树优化是合理的。
面试官追问:退化为链表的阈值为什么是 6? 答:保留 2 的余量,避免红黑树和链表频繁切换。如果阈值设为 7/8,一个插入/删除操作就会导致频繁转换,性能损耗大。
3. 1.8 为什么用 synchronized 而不用 ReentrantLock?
JDK 1.6 优化后,synchronized 的性能已接近 ReentrantLock。且 synchronized 无需手动释放锁,JVM 可以做锁消除、锁粗化、偏向锁等优化,代码更简洁。
面试官追问:偏向锁在 JDK 15 默认关闭了,这个选择还有意义吗? 答:偏向锁确实在 JDK 15 之后默认关闭,但 synchronized 的轻量级锁(CAS 自旋)和重量级锁(阻塞)机制仍然比 ReentrantLock 少一层内存屏障。而且 CHM 的场景是锁粒度极细(单个桶),synchronized 的锁膨胀策略在这个场景下效果很好。
4. 1.8 的 key 和 value 为什么不能为 null?
ConcurrentHashMap 的 get 方法无锁,无法区分 key 不存在和 value 为 null。如果允许 null,调用 get(key) 返回 null 时,调用方无法判断是 key 不存在还是 value 就是 null。HashMap 允许 null 是因为它是单线程的,可以 containsKey 再判断。
面试官追问:为什么 ConcurrentHashMap 不在 get 后加一个 containsKey 检查? 答:因为无锁环境下,containsKey 和 get 之间可能有其他线程插入/删除,两个操作不是原子性的。如果先做 containsKey 再 get,中间可能已经被删掉了,结果还是 null。
5. 1.8 扩容时为什么其他线程要协助而不是等待?
答:如果扩容时其他线程阻塞等待,那么整个 CHM 的写入能力会骤降——所有写线程被一个扩容线程卡住。协助扩容的设计让写线程把等待时间转化成有用的迁移工作,总吞吐量更高。这是典型的协作式并发思想。
总结
ConcurrentHashMap 1.8 的演进,本质上是锁粒度不断细化的过程:从 Hashtable 的类级别锁,到 1.7 的 Segment 分段锁,再到 1.8 的单桶 CAS+synchronized。每一次细化都意味着更高的并发度和更低的竞争概率。
1.8 的设计思想值得反复品味:
- CAS 替代锁:空桶写入、计数、初始化,能用 CAS 就用 CAS
- 细粒度锁:锁定范围越小,并发度越高
- 读写分离:TreeBin 的读不阻塞,写阻塞
- 协作而非等待:扩容时让写线程协助迁移
- 热点分离:CounterCell 把单点计数打散
这些思想不仅适用于 ConcurrentHashMap,在 Netty、Disruptor、Kafka 等高性能组件中都能看到影子。
给祥哥的面试建议:问 CHM 的面试,不只问"1.7 和 1.8 区别",更爱问"线上遇到过什么坑"。把上面三个踩坑案例吃透,比背 API 有说服力。