真实面经题目 · 原创解析

ConcurrentHashMap 为什么能保证线程安全?

ConcurrentHashMap 的线程安全不是靠一个全局大锁实现的,而是把可见性、原子性和局部互斥组合起来:数组槽位和节点关键字段用 volatile 保证可见性,空桶插入、初始化和扩容状态切换用 CAS 保证原子更新,非空桶写入时只锁住单个桶的头节点,读操作基本无锁;再配合扩容协作、红黑树化和分散计数,既保证并发访问下结构不被破坏,又尽量降低锁竞争。

出现于:阿里巴巴 · 后端开发

60 秒回答模板

JDK 8 的 ConcurrentHashMap 能保证线程安全,核心是分层控制并发冲突,而不是像 Hashtable 那样锁住整张表。它底层仍然是数组加链表,链表过长且数组足够大时会转成红黑树。读操作通过 volatile 读数组槽位、节点 value 和 next,通常不加锁也能看到一致可达的结构;写操作如果目标桶为空,就用 CAS 把新节点放进去,避免多个线程同时插入覆盖;如果桶不为空,则只对该桶头节点 synchronized,加锁范围缩小到单个 bin。扩容时通过 ForwardingNode 标记迁移中的桶,其他线程遇到后会帮助迁移,避免一个线程独占扩容成本。size 统计用 baseCount 和 CounterCell 分散累加,降低热点竞争。因此它的线程安全来自 volatile 的可见性、CAS 的乐观原子更新、桶级 synchronized 的互斥修改,以及扩容和计数机制对并发状态的协调。

考点 总体思路
主线 数据结构
易错点 误以为 ConcurrentHashMap 只靠 sy…

深入解析

01

总体思路

JDK 8 的 ConcurrentHashMap 没有继续使用早期 Segment 分段锁作为主要并发模型,而是围绕哈希桶做更细粒度的控制。底层结构是 Node 数组,每个数组槽位是一个桶,桶内可能是链表,也可能在冲突严重时变成红黑树。线程安全并不是单一机制完成的,而是读路径依赖 volatile 可见性,空桶写入依赖 CAS,非空桶写入依赖桶头节点锁,扩容依赖迁移标记和多线程协作,计数依赖分散累加。

02

数据结构

JDK 8 中表数组 table 是 volatile 引用,数组中的节点通过 volatile 方式读取和写入;Node 的 key 和 hash 是 final,val 和 next 是 volatile。final 字段保证节点构造完成后的安全发布语义,volatile 字段保证 value 更新、链表追加、迁移标记等变化能被其他线程及时观察到。桶为空时可以直接 CAS 设置数组槽位,桶不为空时会在桶内部串行化修改。

03

get 读路径

get 操作通常不加锁,它先根据 key 的 hash 定位数组下标,然后以 volatile 语义读取对应桶的头节点。如果头节点命中就直接返回 value;如果是链表就沿着 next 遍历;如果是树桶就按树结构查找。因为节点的 val 和 next 是 volatile,读线程能看到写线程已经发布的节点和值,不会因为无锁读取就读到完全不可达或被破坏的结构。这里的安全不是强一致快照,而是并发语义下的安全可达读取。

04

put 空桶

put 时如果发现目标桶为空,ConcurrentHashMap 不会先加锁,而是使用 CAS 尝试把新 Node 放到数组槽位中。CAS 能保证只有一个线程把 null 改成自己的新节点成功,其他线程如果失败,会重新读取当前位置并进入后续逻辑。这个设计避免了大量不同 key 首次落入空桶时的锁开销,也避免两个线程同时写同一个槽位导致覆盖。空桶插入成功后,再通过 addCount 更新元素数量并判断是否需要触发扩容。

05

put 非空桶

如果目标桶已经有节点,说明可能要更新已有 key、追加链表节点、或者操作树桶。JDK 8 的做法是 synchronized 锁住当前桶的头节点,也就是只让同一个桶内的写操作互斥,不影响其他桶并发写入。加锁后还会重新检查数组当前位置是否仍然是这个头节点,防止扩容或其他并发修改使锁对象失效。链表场景下会遍历节点,key 相等就更新 volatile value,不存在就追加到尾部。

06

红黑树化

ConcurrentHashMap 的桶内结构开始是链表,链表长度达到 TREEIFY_THRESHOLD 时并不一定马上树化。如果数组长度还小于 MIN_TREEIFY_CAPACITY,会优先扩容,因为很多冲突可能只是容量不足造成的,扩容后元素会重新分布到更多桶里。只有当数组已经足够大且单桶仍然过长时,才把链表转换为 TreeBin 包装的红黑树。树化能提升极端哈希冲突下的稳定性。

07

扩容协作

扩容是并发容器最容易出问题的地方,JDK 8 通过 sizeCtl、transferIndex 和 ForwardingNode 协调。某个线程触发扩容后,会创建新数组,并把旧桶逐步迁移过去;迁移完成的旧桶会放置 ForwardingNode,表示这个位置已经移动或正在移动。其他线程 put 或 get 遇到 ForwardingNode 时,会去新表查找,写线程还可能加入 helpTransfer 帮助迁移,避免一个线程独占扩容成本。

08

size 统计

ConcurrentHashMap 的 size 不能简单用一个普通 int 自增,因为高并发下会有丢失更新;也不能所有写线程都竞争同一个锁,否则会形成热点。JDK 8 使用 baseCount 加 CounterCell 的方式,低竞争时 CAS 更新 baseCount,高竞争时把计数分散到多个 CounterCell 中,最后统计时把它们累加。并发修改时 size 更适合观察规模,不适合作为严格并发控制条件。

09

迭代语义

ConcurrentHashMap 的迭代器是弱一致的,它不会像 fail-fast 集合那样在并发修改时抛出 ConcurrentModificationException。迭代期间可以看到创建迭代器之后的部分更新,也可能看不到某些并发变化,但它会保证遍历过程不因结构并发修改而崩溃。这个语义符合并发容器的定位:读多写多场景下优先保证可用性和安全遍历,而不是提供全局一致快照。

10

Map 对比

HashMap 本身没有任何并发保护,并发 put 可能导致数据覆盖、链表或树结构异常、读到不一致状态等问题。Hashtable 的方法大多使用 synchronized,线程安全但锁粒度是整张表,吞吐量较差。Collections.synchronizedMap 也是通过外层互斥锁包装普通 Map,复合操作和迭代还需要调用方手动同步。ConcurrentHashMap 则把锁拆到桶级别,并让读操作大多无锁,适合高并发读写。

易错点

  • 误以为 ConcurrentHashMap 只靠 synchronized 保证安全,忽略 CAS 和 volatile 的作用。
  • 把 JDK 8 仍然说成主要依赖 Segment 分段锁,这是典型版本混淆。
  • 认为 get 完全强一致并且一定返回最新值,混淆了线程安全和强一致快照。
  • 说 put 会锁整张表,忽略了空桶 CAS 和非空桶只锁单个 bin 的设计。
  • 忽略扩容协作机制,只说重新哈希,无法解释并发扩容时为什么不会丢数据。
  • 把 size 当成并发控制的精确依据,没有说明 baseCount 和 CounterCell 的统计语义。
  • 认为链表达到阈值一定立即树化,忘记数组容量不足时会优先扩容。
  • 把弱一致迭代说成 fail-fast,混淆了 ConcurrentHashMap 和普通集合迭代器行为。

面试官追问

JDK 8 的 ConcurrentHashMap 和 JDK 7 的主要区别是什么?

JDK 7 主要基于 Segment 分段锁,每个 Segment 内部类似一个小 HashMap,写入时锁 Segment。JDK 8 取消了固定 Segment 并发模型,改为数组加链表或红黑树,空桶 CAS,非空桶锁桶头节点,粒度更细,并且通过协作扩容提升整体并发能力。

为什么 get 不加锁也能保证线程安全?

因为它依赖的是安全发布和可见性,而不是互斥。table、数组槽位访问、Node 的 val 和 next 都具备 volatile 语义,读线程能看到写线程发布后的结构。get 不保证读到全局最新快照,但能保证遍历到的节点结构是可达且不会被并发修改破坏的。

为什么 put 有时用 CAS,有时用 synchronized?

空桶插入只需要把某个数组槽位从 null 改成新节点,CAS 正好能用低成本保证只有一个线程成功。非空桶涉及遍历、更新、追加、树化等多步结构修改,单次 CAS 难以覆盖整个临界区,所以用 synchronized 锁住桶头节点来保证桶内修改的互斥。

ConcurrentHashMap 的 synchronized 会不会导致性能很差?

不会简单等同于 Hashtable 的性能问题,因为这里锁住的是单个桶头节点,而不是整张表。只有哈希到同一桶的写操作才互相阻塞,不同桶之间可以并发执行。加上空桶 CAS 和读操作无锁,大多数场景下竞争范围明显更小。

扩容时正在读写的线程会发生什么?

扩容时部分桶会被迁移到新数组,旧桶位置会放置 ForwardingNode 表示迁移状态。读线程遇到它会转到新表继续查找,写线程可能会帮助完成迁移。这样既避免访问旧位置丢数据,也避免扩容完全由一个线程承担导致长时间停顿。

为什么 ConcurrentHashMap 不允许 null key 和 null value?

并发场景下如果允许 value 为 null,get 返回 null 就无法区分是 key 不存在,还是 key 存在但值就是 null。由于查找结果可能和并发删除、插入交错,这种歧义会放大使用风险,所以它直接禁止 null key 和 null value。

ConcurrentHashMap 的 size 一定准确吗?

在没有并发修改时,统计结果可以认为是准确的;但在并发 put、remove 同时发生时,size 反映的是统计过程中的一个瞬时结果,可能和调用返回后的真实数量不同。它适合观察规模,不适合作为严格并发条件判断。