concurrent hashmap原理,扩容,扩容时怎么保证线程安全?
2026/5/11 19:18:43
网站建设
项目流程
面试官问题结构化回答:ConcurrentHashMap原理、扩容及扩容时的线程安全
核心总览
ConcurrentHashMap(CHM)是JUC包下为解决「HashMap线程不安全、Hashtable全表锁效率低」设计的并发安全哈希表,核心目标是「高并发下的线程安全 + 尽可能少的锁竞争」。其核心演进分为两个版本:
- JDK1.7:基于「分段锁(Segment)」实现,锁粒度为「Segment数组的一个元素」,不同Segment的操作可并发;
- JDK1.8:抛弃分段锁,采用「Node数组 + CAS + 局部synchronized(桶级锁) + 红黑树」,锁粒度细化到「单个哈希桶」,并通过CAS减少锁使用,性能远超1.7;
扩容是CHM的核心难点,1.8的扩容设计为「多线程协助扩容」(避免单线程扩容阻塞),线程安全依赖「扩容标识(sizeCtl)、CAS、synchronized、ForwardingNode占位」四层保障。
一、ConcurrentHashMap核心原理(1.7 vs 1.8)
1. JDK1.7 核心设计:分段锁(Segment + HashEntry)
| 结构层级 | 作用 | 线程安全保障 |
|---|
| Segment数组(ReentrantLock子类) | 核心锁载体,CHM的外层数组,默认16个Segment | 每个Segment独立加锁,不同Segment的操作可并发(如操作Segment[0]和Segment[1]无锁竞争) |
| HashEntry数组(每个Segment内) | 存储实际键值对,结构同HashMap的Entry(key/value/next/hash) | 仅在操作同一Segment内的HashEntry时,需获取该Segment的锁 |
- 核心逻辑:通过「两次哈希」定位元素——先哈希到Segment,再哈希到HashEntry;
- 优点:锁粒度比Hashtable(全表锁)细,并发度=Segment数量(默认16);
- 缺点:Segment数量固定,无法动态扩容;极端情况下(所有key哈希到同一Segment),退化为单锁,性能下降。
2. JDK1.8 核心设计:Node数组 + CAS + synchronized + 红黑树
彻底抛弃Segment,直接复用HashMap的「数组+链表+红黑树」结构,线程安全通过以下手段实现:
| 核心手段 | 作用场景 |
|---|
| CAS(无锁操作) | 初始化桶、插入第一个节点、更新size计数等无竞争场景,避免加锁 |
| synchronized(桶级锁) | 桶内已有节点时,锁定「桶的头节点」(仅锁当前桶,其他桶可并发操作) |
| 红黑树 | 桶内链表长度≥8时转为红黑树,提升查询效率(O(n)→O(logn)) |
| Node不可变 | 普通Node的value被final修饰,仅能通过替换节点修改值,避免并发修改 |
| 扩容标识(sizeCtl) | 标记数组的状态(未初始化/正常/扩容中),避免多线程重复扩容 |
- 核心逻辑:哈希直接定位到Node数组的桶,无分段锁;仅当操作「同一桶」时,才会加synchronized锁,锁粒度极致细化;
- 核心改进:并发度不再受限(理论上=桶数量),且CAS减少了无竞争场景的锁开销,性能比1.7提升数倍。
二、ConcurrentHashMap扩容机制(1.7 vs 1.8,重点1.8)
扩容的核心目标是「扩大数组容量,减少哈希冲突」,CHM的扩容触发条件和流程随版本大幅优化:
1. JDK1.7 扩容逻辑(Segment内局部扩容)
- 触发条件:单个Segment内的HashEntry数组容量达到「阈值(Segment容量×负载因子,默认0.75)」;
- 扩容流程:
- 获取当前Segment的锁(ReentrantLock),阻塞该Segment的所有操作;
- 将该Segment内的HashEntry数组扩容为2倍,重新哈希所有节点到新数组;
- 替换旧数组,释放锁;
- 特点:仅扩容单个Segment,不影响其他Segment,但单Segment扩容时该Segment完全阻塞。
2. JDK1.8 扩容逻辑(全局数组扩容,多线程协助)
1.8的扩容是「全局扩容」(整个Node数组),设计为「多线程协助扩容」(避免单线程扩容耗时过长),核心流程如下:
(1)扩容触发条件
- 「新增节点后」:putVal时,若当前桶的链表长度≥8且数组容量<64,先扩容数组(而非转红黑树);
- 「容量达标后」:数组元素个数(size)≥「阈值(数组容量×负载因子,默认0.75)」,触发扩容;
- 主动触发:调用
putAll()、resize()等方法时,直接触发扩容。
(2)扩容核心流程(分3阶段)
| 阶段 | 操作细节 | 核心控制(sizeCtl) |
|---|
| 准备阶段 | 1. 检查sizeCtl:若为负数,说明已有线程在扩容,当前线程协助扩容; 2. CAS将sizeCtl从「阈值」改为「-1」(标记扩容开始); 3. 创建新数组(容量=旧数组×2); 4. 将sizeCtl设置为「-(扩容线程数+1)」(默认-2,标识扩容中); | sizeCtl含义: - 正数:下次扩容阈值; - 0:初始状态; - -1:正在初始化数组; - 负数(≠-1):-(扩容线程数+1),如-2表示1个线程在扩容; |
| 迁移阶段 | 1. 遍历旧数组的每个桶(从后往前),分配给不同线程迁移; 2. 锁定当前桶(synchronized),避免迁移时并发修改; 3. 将桶内节点重新哈希到新数组(高位哈希判断归属); 4. 迁移完成后,在旧桶中放入「ForwardingNode(转发节点)」,标记该桶已迁移; | ForwardingNode的作用:引导后续读写操作直接访问新数组,避免操作旧桶; |
| 完成阶段 | 1. 所有桶迁移完成后,将新数组替换旧数组; 2. CAS将sizeCtl设置为「新数组容量×0.75」(新的扩容阈值); 3. 扩容结束,恢复正常状态; | sizeCtl从负数恢复为正数(新阈值); |
(3)多线程协助扩容的逻辑
- 每个线程负责迁移「一段连续的桶」(如线程1迁移015号桶,线程2迁移1631号桶);
- 若线程A迁移到某桶时,发现该桶已被线程B迁移(有ForwardingNode),则跳过,继续迁移下一个桶;
- 迁移过程中,新的读写请求会「先协助扩容,再执行自身逻辑」(如put时发现桶是ForwardingNode,先迁移一个桶,再插入数据)。
三、扩容时的线程安全保障(核心重点)
CHM扩容时的线程安全是面试高频考点,1.7和1.8的保障手段差异显著,重点讲1.8:
1. JDK1.7 扩容的线程安全:分段锁独占
- 扩容前先获取Segment的ReentrantLock锁,该Segment的所有读写操作(put/get/remove)都会被阻塞,直到扩容完成释放锁;
- 优点:简单粗暴,完全避免并发修改;缺点:单Segment扩容时,该Segment的操作全部阻塞,并发度低。
2. JDK1.8 扩容的线程安全:四层保障(核心)
1.8通过「无锁+锁+占位+协作」四层机制,既保证线程安全,又不影响并发性能:
(1)扩容标识(sizeCtl):避免重复扩容
- sizeCtl是volatile变量,保证多线程可见性;
- 扩容前先CAS修改sizeCtl为负数(标记扩容中),其他线程看到负数后,不会重复触发扩容,而是协助扩容;
- 扩容过程中,sizeCtl的数值(如-2、-3)标识当前扩容线程数,避免线程冲突。
(2)CAS:无竞争场景的原子性
- 初始化数组、修改sizeCtl、插入第一个节点等操作,通过CAS保证原子性(如CAS修改sizeCtl为-1,避免多线程同时初始化扩容);
- 无锁操作减少了锁竞争,提升扩容效率。
(3)synchronized:桶级别的排他锁
- 迁移某桶时,先锁定该桶的头节点(synchronized (f),f为桶的头节点),确保同一时间只有一个线程迁移该桶;
- 锁粒度仅为「单个桶」,其他桶的迁移/读写操作可正常并发,不会阻塞。
(4)ForwardingNode:迁移完成的占位与引导
- 某桶迁移完成后,旧桶中放入ForwardingNode(特殊节点,hash值为-1),标记该桶已迁移;
- 后续读写操作遇到ForwardingNode时,会执行两个逻辑:
- 读操作:直接到新数组中查询,避免读取旧桶的无效数据;
- 写操作(put/remove):先协助扩容(迁移一个未处理的桶),再执行自身逻辑,加速扩容完成;
- ForwardingNode避免了「旧桶数据被修改」和「读写操作访问无效数据」的问题。
(5)节点操作的原子性
- 普通Node的value被final修饰,仅能通过「替换节点」(如CAS替换头节点、synchronized替换链表节点)修改值,避免扩容时并发修改节点内容;
- 迁移节点时,通过「高位哈希(hash & newCap)」判断节点归属新数组的哪个桶,保证节点迁移的正确性。
四、总结(面试收尾金句)
- CHM的核心演进:1.7分段锁(Segment)→1.8 CAS+桶级synchronized,锁粒度更细,并发性能更高;
- 1.8扩容的核心设计:「多线程协助扩容」替代单线程扩容,通过sizeCtl控状态、ForwardingNode引导读写、synchronized锁桶,兼顾效率与安全;
- 扩容时的线程安全核心:1.7靠Segment独占锁,1.8靠「sizeCtl标识+CAS+桶级synchronized+ForwardingNode」,既避免重复扩容,又保证迁移过程中数据不被并发修改。
面试追问应对
- 问:“CHM 1.8扩容时,get操作能正常执行吗?”
答:可以。get操作是无锁的:若桶未迁移,直接读旧桶;若桶已迁移(有ForwardingNode),则到新数组读;若桶正在迁移(被synchronized锁定),get会自旋等待锁释放,不会阻塞(保证读操作的高并发)。 - 问:“CHM 1.8为什么抛弃分段锁?”
答:分段锁的并发度受限于Segment数量(默认16),且Segment是重量级锁(ReentrantLock);1.8的桶级synchronized(轻量级锁,偏向锁/自旋锁优化)+CAS,锁粒度更细,并发度理论上无上限,且无锁操作更多,性能更高。 - 问:“CHM扩容时,put操作会阻塞吗?”
答:不会完全阻塞。put操作遇到未迁移的桶,会锁定该桶执行插入;遇到ForwardingNode,会先协助扩容(迁移一个桶),再执行插入;仅当操作正在迁移的桶时,会短暂等待synchronized锁释放,整体仍保持高并发。