真实面经题目 · 原创解析

ConcurrentHashMap为什么性能比较好?

ConcurrentHashMap 性能好,本质上不是因为完全没有锁,而是把锁的范围、锁的频率和锁竞争都压到了更低水平。它通过读操作无锁、写操作尽量 CAS、冲突时只锁单个桶、扩容时多线程协作迁移、计数时分散热点等方式,避免了 Hashtable 或直接给 HashMap 加 synchronized 那种全表串行化的瓶颈。回答时要区分 JDK7 的 Segment 分段锁模型和 JDK8 的 Node 数组加 CAS、synchronized 桶级锁模型。

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

60 秒回答模板

ConcurrentHashMap 性能较好,核心原因是它把并发控制做得更细。Hashtable 以及外层加锁的 HashMap 通常是全表锁,读写之间、写写之间都会互相阻塞,并发度很低;而 ConcurrentHashMap 的设计目标是让大多数读操作不加锁,让写操作只影响局部桶。JDK7 里它采用 Segment 分段锁,一个 Segment 内部类似一个小 HashMap,不同 Segment 可以并发写,因此并发度由分段数量决定。JDK8 取消 Segment,改成 Node 数组结构:插入空桶时用 CAS,发生哈希冲突时才对桶头节点加 synchronized,只锁一个桶,不锁整张表。读操作依赖 volatile、CAS 和链表或红黑树结构的可见性设计,通常不需要加锁。扩容时也不是单线程独占完成,而是通过 ForwardingNode、transferIndex 等机制让多个线程协作迁移数据。size 统计也避免单点竞争,使用 baseCount 和 CounterCell 类似 LongAdder 的分散计数方式。因此它相比 Hashtable 的全表同步,在读多写少和多核并发场景下性能明显更好。

考点 总体思路
主线 JDK7 分段锁
易错点 只说 ConcurrentHashMap 使用分段锁,…

深入解析

01

总体思路

ConcurrentHashMap 的优化目标不是消灭所有同步,而是降低同步粒度并减少共享热点。Hashtable 的大部分公开方法都使用 synchronized 修饰,锁对象是整张表,所以一个线程读或写时,其他线程往往都要等待。ConcurrentHashMap 则把并发控制放到数组桶、节点、计数单元和扩容任务这些更小的范围里,使不相关的 key 操作可以并行执行。

02

JDK7 分段锁

JDK7 的 ConcurrentHashMap 使用 Segment 数组,每个 Segment 继承自 ReentrantLock,内部维护一段 HashEntry 数组。写入某个 key 时,只需要锁住它所在的 Segment,而不是锁住整个 Map;不同 Segment 上的写操作可以并发进行。它的并发度主要受 Segment 数量限制,默认并发级别通常是 16,因此相比 Hashtable 已经能显著减少锁竞争。

03

JDK8 桶级并发

JDK8 取消了 Segment,结构变成普通的 Node 数组加链表或红黑树。写入空桶时优先使用 CAS 直接放入节点,避免加锁;如果桶里已经有节点,才对桶头节点使用 synchronized,锁住这个桶的链表或树。这样锁粒度比 JDK7 的 Segment 更细,理论上不同桶上的写操作都可以并行,冲突越少,并发性能越好。

04

读操作无锁

ConcurrentHashMap 的 get 操作通常不加锁,它根据 hash 定位桶,然后遍历链表或树查找节点。节点数组、节点 value、next 等关键字段配合 volatile、CAS 发布和 happens-before 关系保证可见性。读线程可能与写线程并发执行,但不会破坏结构一致性;它最多看到某个时刻的结果,而不需要像 Hashtable 那样连读取都进入全表互斥区。

05

扩容协作

ConcurrentHashMap 的扩容不是简单地让一个线程独占迁移整张表。JDK8 中扩容会使用 ForwardingNode 标记已经迁移的桶,并用 transferIndex 分配迁移区间,多个触发写入或发现扩容的线程可以一起帮助 transfer。这样扩容成本被分摊到多个线程上,减少单线程长时间阻塞,也避免扩容期间所有访问完全停摆。

06

size 统计优化

在并发容器中精确统计 size 很容易成为性能热点,因为每次增删都更新同一个计数器会导致严重 CAS 竞争。JDK8 ConcurrentHashMap 使用 baseCount 加 CounterCell 的方式分散计数,低竞争时更新 baseCount,高竞争时把增量分摊到多个 CounterCell。调用 size 时再汇总这些值,因此性能更好,但在并发修改中统计结果只能代表近似瞬时状态。

07

与加锁 HashMap 对比

给 HashMap 外面套 synchronized 或使用 Collections.synchronizedMap,虽然能避免并发修改导致的数据结构损坏,但它把所有操作串行化了。并且 HashMap 本身没有为并发访问设计,扩容、链表修改和可见性都依赖外部锁完整保护。ConcurrentHashMap 则从数据结构层面专门设计了并发读写、局部加锁、安全发布和协作扩容,性能和安全性都更适合高并发场景。

易错点

  • 只说 ConcurrentHashMap 使用分段锁,却不说明这是 JDK7 的实现,忽略 JDK8 已经取消 Segment。
  • 把 ConcurrentHashMap 说成完全无锁,这是错误的;它读通常无锁,但写冲突桶时仍会使用 synchronized。
  • 认为 Hashtable 和 ConcurrentHashMap 只是 API 不同,没有指出 Hashtable 的全表锁导致读写都容易串行化。
  • 误以为 size 在并发修改时一定精确,没有说明 baseCount 和 CounterCell 的分散计数以及近似瞬时语义。
  • 只讲桶级锁,不讲 CAS 空桶插入、协作扩容和读路径可见性,导致性能原因解释不完整。
  • 把 ConcurrentHashMap 当成能解决所有复合操作原子性的容器,忽略 putIfAbsent、compute 等原子方法才适合检查后更新场景。

面试官追问

JDK7 和 JDK8 的 ConcurrentHashMap 最大区别是什么?

JDK7 的核心是 Segment 分段锁,每个 Segment 是一个独立锁保护的小哈希表,并发度受 Segment 数量限制。JDK8 取消 Segment,直接使用 Node 数组,空桶插入走 CAS,冲突桶修改才锁桶头节点,并结合红黑树、ForwardingNode 和协作扩容,锁粒度更细。

ConcurrentHashMap 的 get 为什么可以不加锁?

get 只做定位和遍历,不修改结构。ConcurrentHashMap 通过 volatile 字段、CAS 发布节点以及节点引用的可见性设计,让读线程能看到一个结构上安全的状态。它不保证与并发写入形成全局强一致快照,但能保证不会读坏结构,也不需要全表互斥。

ConcurrentHashMap 的 size 一定准确吗?

在没有并发修改时,size 结果可以认为是准确的;但如果统计过程中有其他线程同时增删元素,它只能反映一个近似瞬时值。原因是 JDK8 为了降低计数竞争,把更新分散到 baseCount 和多个 CounterCell 上,汇总时无法阻止并发变化。

JDK8 为什么还使用 synchronized,不是性能退化吗?

JDK8 使用 synchronized 的范围非常小,只在桶发生冲突、需要修改链表或树时锁住桶头节点,不是锁整张表。现代 JVM 对 synchronized 做了大量优化,而且桶级锁对象数量分散,竞争概率低,因此比 Hashtable 的方法级全表锁轻得多。

ConcurrentHashMap 能不能允许 null key 或 null value?

不能。ConcurrentHashMap 不允许 null key 和 null value,一个重要原因是并发场景下需要区分 key 不存在和 value 本身为 null。如果允许 null,get 返回 null 时就很难判断是没有映射,还是被映射到了 null,这会让并发语义变复杂。