ConcurrentHashMap在扩容的过程中又有新的数据写入是怎么处理的
ConcurrentHashMap
在扩容过程中处理新数据写入的机制是其高并发性能的关键设计之一。它采用了一套精巧的策略,确保在扩容(rehashing)这个重量级操作进行时,读写操作依然可以高效、安全地并发执行。
核心机制可以概括为:多线程协同扩容 + 数据迁移惰性化 + 节点类型标识。
1. 扩容触发与 sizeCtl
控制
- 当
ConcurrentHashMap
的元素数量超过阈值(容量 × 负载因子)时,会触发扩容。 - 扩容过程由一个核心变量
sizeCtl
控制。它不仅控制初始化和扩容状态,还记录了参与扩容的线程数。
2. 核心机制:多线程协同扩容 (Helping During Resize)
这是 ConcurrentHashMap
(JDK 1.8+)最核心的设计。扩容不是由单个线程完成的,而是由发起扩容的线程和后续的读写线程共同协作完成的。
具体流程:
-
初始化扩容任务:
- 当一个线程(比如线程A)发现需要扩容时,它会创建一个新的、容量为原容量两倍的
Node
数组(nextTable
)。 - 它将
sizeCtl
设置为一个负数,这个负数的绝对值表示参与扩容的线程数量阈值(通常与CPU核心数相关,但有上限)。 - 这个
nextTable
就是扩容的目标数组。
- 当一个线程(比如线程A)发现需要扩容时,它会创建一个新的、容量为原容量两倍的
-
划分迁移任务:
- 扩容的本质是将旧数组(
table
)中的每个bin
(桶,即链表或红黑树)里的Node
迁移到新数组(nextTable
)的对应位置。 - 整个旧数组被划分为多个迁移任务段。每个任务段通常包含一个或多个连续的
bin
。
- 扩容的本质是将旧数组(
-
迁移过程与
ForwardingNode
:- 参与扩容的线程(包括发起者和后来帮忙的线程)会从任务段分配器那里领取一个任务段(例如,处理从索引
i
开始的一段bin
)。 - 线程开始处理这个任务段:
- 遍历该段内的每个
bin
。 - 对
bin
中的每个Node
,根据其hash
值重新计算在新数组中的位置(因为容量翻倍,位置可能是i
或i + oldCap
)。 - 将这些
Node
按照新的位置规则,分别插入到nextTable
的两个新bin
中(形成两个新的链表或树)。
- 遍历该段内的每个
- 关键一步:当一个
bin
的所有Node
都成功迁移到nextTable
后,在旧数组table
的这个bin
位置上,放置一个特殊的节点ForwardingNode
。
- 参与扩容的线程(包括发起者和后来帮忙的线程)会从任务段分配器那里领取一个任务段(例如,处理从索引
-
ForwardingNode
的作用:ForwardingNode
是一种特殊的Node
子类。- 它的
hash
值被设置为一个常量MOVED
(值为 -1)。 - 它的
val
和next
字段为null
。 - 它的存在就是告诉所有后续访问这个
bin
的线程:“这个桶的数据已经迁移到新数组了,你应该去新数组nextTable
里找!”
3. 新数据写入的处理:动态路由
现在回答核心问题:当扩容正在进行,又有新的 put
操作(写入)发生时,ConcurrentHashMap
如何处理?
-
定位
bin
:- 新线程(线程B)执行
put(key, value)
。 - 它首先根据
key
的hash
值计算出在旧数组table
中的索引i
。
- 新线程(线程B)执行
-
检查
ForwardingNode
:- 线程B尝试获取
table[i]
的节点。 - 关键判断:如果它发现
table[i]
是一个ForwardingNode
(即tab[i].hash == MOVED
):- 这意味着
bin i
的数据已经或者正在被迁移到新数组。 - 线程B不会在旧数组上执行插入。
- 线程B会直接转向新数组
nextTable
。 - 它根据
key
的hash
值计算出在nextTable
中的正确位置j
。 - 然后在
nextTable[j]
上执行正常的put
操作(加锁、插入、可能树化等)。 - 结果:新数据直接写入了新数组
nextTable
,完美避开了旧数组的迁移过程。
- 这意味着
- 线程B尝试获取
-
未遇到
ForwardingNode
:- 如果线程B发现
table[i]
不是ForwardingNode
(比如是一个正常的Node
或null
),说明这个bin
尚未开始迁移。 - 线程B会在旧数组的
table[i]
上执行正常的put
操作(加锁、插入等)。 - 后续处理:当扩容线程后来处理到这个
bin i
时,它会将线程B刚刚插入的这个新Node
也一并迁移到nextTable
中。这保证了数据的完整性。
- 如果线程B发现
-
主动参与扩容:
- 在 JDK 1.8 的
ConcurrentHashMap
中,执行put
操作的线程,在成功插入数据后,会检查整个扩容是否接近完成。 - 如果发现还有未迁移的
bin
,并且当前参与扩容的线程数未达到sizeCtl
的阈值,这个put
线程可能会主动领取一个迁移任务段,帮助进行数据迁移,然后再返回。这极大地加速了扩容过程。
- 在 JDK 1.8 的
总结:如何处理新数据写入
- 动态路由:通过
ForwardingNode
作为“路标”,新写入的数据会被自动引导到新的nextTable
中,确保新数据直接进入新结构。 - 旧数据兜底:如果写入时目标
bin
尚未被标记为迁移,数据会先写入旧数组,等待后续的迁移线程将其搬走。 - 协同工作:执行写入操作的线程不仅是“消费者”,还可能成为“生产者”(帮助迁移),共同推进扩容任务。
- 无锁读取:
get
操作同样会检查ForwardingNode
,如果是,则去nextTable
查找,保证了读操作在扩容期间也能正确返回结果,且通常不需要加锁。
最终结果:ConcurrentHashMap
在扩容期间,依然能高效地处理新的读写请求,实现了“扩容对业务操作透明”的目标,这是其高并发性能的基石。