01
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 使用分段锁,…
02
深入解析
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 则从数据结构层面专门设计了并发读写、局部加锁、安全发布和协作扩容,性能和安全性都更适合高并发场景。
03
易错点
- 只说 ConcurrentHashMap 使用分段锁,却不说明这是 JDK7 的实现,忽略 JDK8 已经取消 Segment。
- 把 ConcurrentHashMap 说成完全无锁,这是错误的;它读通常无锁,但写冲突桶时仍会使用 synchronized。
- 认为 Hashtable 和 ConcurrentHashMap 只是 API 不同,没有指出 Hashtable 的全表锁导致读写都容易串行化。
- 误以为 size 在并发修改时一定精确,没有说明 baseCount 和 CounterCell 的分散计数以及近似瞬时语义。
- 只讲桶级锁,不讲 CAS 空桶插入、协作扩容和读路径可见性,导致性能原因解释不完整。
- 把 ConcurrentHashMap 当成能解决所有复合操作原子性的容器,忽略 putIfAbsent、compute 等原子方法才适合检查后更新场景。
04
面试官追问
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,这会让并发语义变复杂。