面试记录 2026-10-09
题目:ConcurrentHashMap 是如何保证线程安全的?
难度:high
标签:java/collections, general/knowledge
评分:⭐⭐⭐☆☆(3/5)
我的回答
ConcurrentHashMap 保证线程安全还是使用的锁,synchronized 的关键字。在 JDK1.8 之前,它使用的是从区域分段锁。它的结构在 1.8 之前是用的分段,然后分桶,然后链表的模式。它锁一次是锁一个段的内容,主要还是为了控制锁的力度。在 JDK1.8 之后,把 synchronized 的移动到桶上了,也就是说只有出现哈希冲突,往对应的桶中添加元素的时候才会上锁,这时候它的力度更低。它在添加元素的时候如果出现哈西冲突,才走相关于锁的那一套东西。核心还是通过锁去保证线程安全的,只不过它的力度对比 HashMap 要小得多。HashMap 直接在 put 方法上加锁,而 ConcurrentHashMap 是在添加元素对应的桶上加锁,只有当冲突出现的时候才会有锁竞争、锁的问题、线程安全,否则是不会触发对应的锁的。
点评
优点:
- 抓准主线:JDK7 分段锁 → JDK8 桶级 synchronized
- 说对了 JDK7 结构(Segment + 桶 + 链表)
- 说对了 JDK8 核心变化:锁粒度从 Segment 级细化到桶级
- 提到"只有冲突才加锁"
不足:
- 完全没提 CAS:JDK8 桶为空时用 Unsafe.putOrderedObject(CAS)直接写入,无需加锁——这是"读多写少"高并发的核心机制
- 完全没提 volatile:桶数组和 size 相关字段都是 volatile,保证内存可见性
- 完全没提 size 统计机制:baseCount + counterCells[] 分片计数(类似 LongAdder)
- 对比错了:说 "HashMap 直接在 put 方法上加锁"——HashMap 根本没有加锁!加锁的是 Hashtable(方法级 synchronized)和 Collections.synchronizedMap(外层全表锁)
- 未提协助扩容:helpTransfer 机制,多线程协助迁移桶
- 未提红黑树:JDK8 桶也支持红黑树化(TREEBIN)
- 未提弱一致迭代器:ConcurrentHashMap 迭代器基于快照,不抛 CME
- 未提读操作几乎无锁:get 走 volatile 读桶头
补充(JDK8 ConcurrentHashMap 完整机制):
- putVal 流程:hash 定位 → 桶为空则 CAS 写入;桶非空则 synchronized 桶头节点,判断 key 相同覆盖 value、TREEBIN 树内 put、链表尾插;count++ 检查扩容阈值
- size 统计:baseCount CAS 累加;冲突时降级到 counterCells[] 数组分片累加(LongAdder 思路)
- 协助扩容:transferIndex 记录迁移进度,每个线程迁移 16 个桶(STRIDE),已迁移桶用 FORWARD_NODE 标记,读操作遇到迁移中桶会 helpTransfer 或走 find 兜底
- 读多写少友好:读走 volatile 无锁、写冲突只锁一个桶、空桶 CAS 完全无锁、扩容多线程协助
完整讲解
第一部分:知识重点剖析
核心考点定位:验证对 ConcurrentHashMap JDK8 完整实现的理解,尤其是 CAS + synchronized 混用、协助扩容、分片计数三大机制。
知识点拆解:
- 底层原理:ConcurrentHashMap 是"数组 + 链表 + 红黑树"结构,JDK8 把 JDK7 的 Segment 分段锁拆掉,改用桶头节点的 synchronized + CAS 混用。桶为空用 CAS 无锁写入;桶非空 synchronized 桶头节点,锁粒度到单桶。
- putVal 完整流程:①hash 定位 index;②table[index]==null → CAS 写入空桶(无锁);③桶头是 FORWARD_NODE → 迁移中,调 helpTransfer 或等迁移;④桶头 key 相同 → synchronized 覆盖 value;⑤TREEBIN → synchronized(treebin) 走树内 put;⑥链表尾插 → synchronized(node) 尾插;⑦count++ 检查扩容阈值;⑧触发 resize 时可能协作扩容。
- size 统计(LongAdder 思路):baseCount 单变量 CAS 累加,竞争激烈时降级到 counterCells[] 数组分片累加;size() 遍历累加返回值是"某个瞬间的近似值",不保证强一致。
- 协助扩容 helpTransfer:JDK8 用 transferIndex 变量记录迁移进度(从右向左),每个线程迁移 16 个桶(STRIDE 步长);已迁移桶用 FORWARD_NODE 标记;其他线程遇到迁移中的桶会主动 helpTransfer,读操作可 helpTransfer 或走 find 兜底。
- 弱一致迭代器:迭代器基于桶数组快照,遍历时允许修改,不抛 CME;可能读到修改前后的任意快照——非常适合读多写少的高并发统计场景。
- 设计权衡:synchronized 替代 ReentrantLock 的原因——JDK6+ 优化后性能相当;粒度小更容易在 JIT 层优化;语义简单。CAS 用于空桶快速写入,避免不必要的锁开销。
按「底层原理 → 设计权衡 → 工程实践」递进排列。
第二部分:考察方向与延伸递进
考察方向:
- 深度:能否画出 putVal 完整流程图,说清每个分支的锁/CAS 策略
- 广度:能否对比
LongAdder、AtomicLong、ReentrantLock 在类似场景的选型
- 应用:能否根据业务 QPS/读写比/内存占用给出选型建议
延伸递进面试题:
- 为什么 JDK8 取消 Segment 改用桶级 synchronized?
- 解答要点:Segment 默认 16 个,写入冲突上限 16;桶级锁粒度可以做到 table.length 个(默认 16 但可扩到千万级),写冲突概率大幅下降。JDK6+ synchronized 已优化(偏向锁→轻量级锁→重量级锁),性能和 ReentrantLock 相当,且 JVM 更好优化。
- ConcurrentHashMap 的 size() 为什么可能不准?
- 解答要点:baseCount + counterCells[] 分片累加,size() 遍历各分片时其他线程可能正在更新;返回值是"某个瞬间的近似值",不保证强一致。如果需要强一致,用
getMap().values() 之类的全量快照,但性能差。
computeIfAbsent 在 ConcurrentHashMap 上有什么特殊行为?
- 解答要点:桶为空 CAS 写入;桶非空锁住桶头,计算+插入在同一锁内完成。注意:在 computeIfAbsent 的函数中不能再调用同一个 map 的其他方法(会死锁),也不能返回 null。
- JDK8 扩容时多线程如何协作?
- 解答要点:transferIndex 从 table.length-1 递减,每次减 STRIDE=16;每个线程从 transferIndex 处开始迁移 16 个桶到 transferIndex-16;已迁移桶用 FORWARD_NODE(hash 特殊值)标记;其他线程调用 put 时如果发现 FORWARD_NODE,调 helpTransfer 协助迁移或 find 兜底返回。
第三部分:面试者参考回答(300-600 字口语化)
面试官您好,ConcurrentHashMap 的线程安全机制在 JDK7 和 JDK8 差异很大。
JDK7 用分段锁。整个 Map 分成 16 个 Segment,每个 Segment 是一个独立的锁对象(ReentrantLock)+ 一个小 HashEntry 数组。加锁只锁一个 Segment,所以并发度上限是 16——16 个线程可以同时写不同的 Segment。
JDK8 大改,去掉了 Segment,把锁直接下放到桶头节点。整体结构变成"数组 + 链表 + 红黑树"。put 的时候分几种情况:如果目标桶为空,用 Unsafe 的 CAS 操作直接写入,完全不需要加锁;如果桶非空,synchronized 锁住桶头节点,然后在链表或红黑树里做操作。这样锁的粒度就是单个桶,表多大并发度就多大。
读操作几乎完全无锁。桶头节点用 volatile 修饰,读线程直接 volatile 读就能看到最新值,不需要加锁。所以读多写少的场景性能特别好。
size 统计用了类似 LongAdder 的分片思路。有个 baseCount 用 CAS 累加,竞争激烈的时候降级到 counterCells 数组分片累加,避免单点 CAS 竞争。size() 方法遍历累加返回值是"某个瞬间的近似值",不保证强一致。
扩容有个特别巧妙的设计——协助扩容。用一个 transferIndex 变量记录迁移进度(从右向左),每个线程迁移 16 个桶;已迁移的桶用 FORWARD_NODE 特殊标记。其他线程调用 put 时如果发现 FORWARD_NODE,会主动调 helpTransfer 协助迁移;如果只想读,会走 find 兜底查新旧两个位置。
迭代器是弱一致的,基于桶数组快照,遍历时允许修改,不抛 ConcurrentModificationException,可能读到修改前后的任意快照。
对比起来,JDK7 靠"分段锁 + ReentrantLock",并发度受 Segment 数限制;JDK8 靠"桶级 synchronized + CAS 混合",空桶写入走 CAS 完全无锁,非空桶走 synchronized 锁桶头,读走 volatile 无锁,并发度可以无限扩展。JDK6 之后 synchronized 经过锁升级优化,性能和 ReentrantLock 相当,所以 JDK8 干脆用回 synchronized,语义更简洁。