AI 模拟面试实战:ConcurrentHashMap JDK 8 CAS + Synchronized 锁细化与扩容协助

在 Java 并发容器的考察中,java.util.concurrent.ConcurrentHashMap 是各大厂技术面试中必问的“压轴级并发神器”。
大家都知道:
- “JDK 7 采用的是分段锁(
Segment继承ReentrantLock),默认并发度 16”; - “JDK 8 放弃了分段锁,改为使用 Node 数组 + CAS +
synchronized锁桶头节点,将锁粒度细化到了每一个独立的哈希桶”。
但当大厂面试官深入底层源码追问:
“为什么 JDK 8 偏偏选择了
synchronized而不是ReentrantLock?在多线程并发扩容时,ConcurrentHashMap是如何通过ForwardingNode和helpTransfer让其他写入线程主动**协同协助扩容(Cooperative Resizing)**的?size()统计是如何通过CounterCell消除伪共享(False Sharing)与 CAS 热点自旋的?”
很多同学就会在源码级的设计细节上败下阵来。
今天我们通过 AI 模拟面试官的深度推演,把 ConcurrentHashMap 在 JDK 8 中的并发设计艺术彻底讲透。
核心考点一:为什么 JDK 8 放弃了 ReentrantLock 改用 synchronized?
这是面试中区分技术认知深度的绝佳考点。很多人以为 ReentrantLock 比 synchronized 更高级。
但在 JDK 8 的底层实现中,选择 synchronized 具有三大决定性优势:
- 极致的内存空间节省:
在 JDK 7 中,每个Segment对象都需要额外开辟内存;若为每个桶都 new 一个ReentrantLock,每个锁对象在堆中都要占几十个字节,上万个桶会浪费巨大的堆内存。
而synchronized是 JVM 原生内置的监视器锁(ObjectMonitor),可以直接复用哈希桶的头节点(Node)自身的 Mark Word 对象头,零额外内存分配! - JVM 运行时深度的编译与硬件优化:
synchronized是 JVM 的亲儿子,享有偏向锁、轻量级锁、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)以及自适应自旋(Adaptive Spinning)等多层次底层优化; - 细化到单桶后冲突概率极低:
在哈希桶数量极大(如 1024 个桶)时,两个线程同时写同一个桶的概率极低。绝大部分写操作仅需一次 CAS,只有在发生哈希冲突时才需要锁头节点。
graph TD
A[线程执行 putVal 操作] --> B{当前桶下标 tab[i] 是否为空?}
B -->|是| C[调用 CAS 无锁原子插入: casTabAt(tab, i, null, node)]
B -->|否: 桶内已有元素| D[对该桶的头节点加锁: synchronized(f)]
D --> E[遍历链表或红黑树完成节点更新/插入]
D --> F[释放桶头锁]
C & F --> G[调用 addCount 累加容量, 检查是否需要扩容]
核心考点二:多线程协同协助扩容(helpTransfer & ForwardingNode)
这是 ConcurrentHashMap 源码中最震撼的并发设计亮点之一:化竞争为协作!
1. 什么是 ForwardingNode(转移转发节点)?
当某个桶内的数据全部被迁移到新数组(nextTable)后,系统会在旧数组的该桶位置放置一个特殊的 ForwardingNode(其 hash 值为固定的负数 -1,内部包含指向新数组的指针)。
2. 其他线程读到 ForwardingNode 怎么办?
读操作(get)遇到 hash == -1 的 ForwardingNode 时,完全不需要阻塞加锁,直接顺着该节点内部的 nextTable 指针去新数组中无锁读取数据!
3. 其他线程写到 ForwardingNode 怎么办?
当线程 A 正在扩容,线程 B 尝试向旧数组的某个桶 put 数据时,发现该位置是一个 ForwardingNode:
- 线程 B 不会傻傻挂起等待;
- 线程 B 立即调用
helpTransfer(),主动加入扩容大军,协助将其他尚未迁移的桶并发搬运到新数组中! - 所有人齐心协力把整个数组快速搬迁完毕后,线程 B 再去新数组中执行自己的
put操作!
graph LR
A[线程 A 负责迁移第 1~16 桶] --> M[新数组 nextTable]
B[线程 B 尝试写入, 发现 ForwardingNode] -->|主动加入协助扩容| C[线程 B 领任务负责迁移第 17~32 桶]
C --> M
Note[极速并发迁移, 扩容耗时缩短 N 倍!]
核心考点三:size() 统计与 LongAdder 思想(消除 CAS 伪共享)
在并发环境下,如果有 100 个线程同时 put 成功,如何统计当前的元素总数?
如果用单个 AtomicLong.incrementAndGet(),100 个线程会在同一个内存地址上疯狂 CAS 自旋,引发严峻的 CPU 总线风暴(Bus Locking)。
ConcurrentHashMap 的破局解法:分段累加(CounterCell 数组):
- 维护一个基础值
baseCount和一个分段计数数组CounterCell[] counterCells; - 没有并发冲突时,直接 CAS 累加
baseCount; - 一旦发生 CAS 竞争失败,每个线程根据自身线程哈希值(
ThreadLocalRandom.getProbe())散列到不同的CounterCell槽位上独立累加; - 计算
size()时,只需将baseCount与所有CounterCell的值做一次简单的求和汇总:
$$\mathbf{\text{size} = \text{baseCount} + \sum \text{CounterCell}[i].\text{value}}$$
核心考点四:get 操作为什么能做到 100% 全程无锁(Lock-Free)?
查看 Node 节点的定义源码:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val; // 使用 volatile 修饰保证可见性
volatile Node<K,V> next; // 使用 volatile 修饰保证可见性
// ...
}
val和next指针全部被volatile关键字修饰,保证任何线程对其修改后,其他读线程能够瞬间从主内存中看到最新值;- 结合底层
Unsafe.getObjectVolatile原子读,使得get操作全程无需任何同步互斥锁,读性能达到极致!
模拟面试复盘
回答 ConcurrentHashMap,按四步走:
- 锁粒度:CAS +
synchronized桶头锁,兼顾内存开销与编译优化; - 无锁读:
volatile val/next确保读操作 100% 并发无锁; - 协作扩容:
ForwardingNode引导读转发与多线程协同协助扩容; - 并发计数:
CounterCell分段分散热点,消除总线风暴。
四位一体,源码细节深入骨髓,答辩必获满堂彩。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/cannonjinx/article/details/165126990




