松林AI说头像
AI 模拟面试实战:ConcurrentHashMap JDK 8 CAS + Synchronized 锁细化与扩容协助封面图

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

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 具有三大决定性优势:

  1. 极致的内存空间节省:
    在 JDK 7 中,每个 Segment 对象都需要额外开辟内存;若为每个桶都 new 一个 ReentrantLock,每个锁对象在堆中都要占几十个字节,上万个桶会浪费巨大的堆内存。
    而 synchronized 是 JVM 原生内置的监视器锁(ObjectMonitor),可以直接复用哈希桶的头节点(Node)自身的 Mark Word 对象头,零额外内存分配!
  2. JVM 运行时深度的编译与硬件优化:
    synchronized 是 JVM 的亲儿子,享有偏向锁、轻量级锁、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)以及自适应自旋(Adaptive Spinning)等多层次底层优化;
  3. 细化到单桶后冲突概率极低:
    在哈希桶数量极大(如 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,按四步走:

  1. 锁粒度:CAS + synchronized 桶头锁,兼顾内存开销与编译优化;
  2. 无锁读:volatile val/next 确保读操作 100% 并发无锁;
  3. 协作扩容:ForwardingNode 引导读转发与多线程协同协助扩容;
  4. 并发计数:CounterCell 分段分散热点,消除总线风暴。
    四位一体,源码细节深入骨髓,答辩必获满堂彩。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/cannonjinx/article/details/165126990

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--