Java 王者修炼手册【集合篇 -ConcurrentHashMap】 :从分段锁到桶级锁的锁系进化

大家好,我是程序员强子。
又来刷英雄熟练度咯~今天专攻 ConcurrentHashMap,必须上强度!
我们来看一下,今晚我们准备练习哪些内容:
并发控制核心机制:
JDK7 与 JDK8 保证线程安全的实现差异;分段锁被废弃的原因;
JDK8 的桶级锁粒度,以及 CAS 与 synchronized 的协同机制;
数据操作与扩容逻辑
put 方法的完整流程
哈希冲突的处理方式;
触发扩容的条件及流程;
多个线程同时扩容时的协同机制(如何分工转移数据)。
关键节点做并发控制策略
多线程同时初始化桶数组容易出现并发情况
有可能多线程同时 put 数据,容易冲突
还没扩容完成时就同时有多个线程发起扩容请求
多个线程需要实时得到 size
红黑树操作时容易出现并发情况
与 HashMap 的设计差异
链表转红黑树的条件与 HashMap 的区别及原因
不允许 null 作为 key/value 的设计考量
get () 返回 null 时,如何区分 key 不存在与 value 为 null
迭代器****与安全机制
ConcurrentHashMap 如何保证 fail-safe;
迭代器是弱一致性还是快速失败及原因;
迭代中修改集合是否会抛 ConcurrentModificationException;
安全失败与快速失败的异同
COW 机制
什么是 Copy-On-Write(COW);
COW 如何通过 “写时复制” 保证线程安全
并发控制核心机制
JDK7 与 JDK8 线程安全实现差异
JDK7:基于分段锁(Segment) 实现
整个哈希表被拆分为多个 Segment(每个 Segment 本质是一个 ReentrantLock + 哈希表)
线程操作仅锁定目标 Segment,并发度由 Segment 数量(默认 16)决定。
JDK8:废弃分段锁,改用桶级锁 + CAS 协同。
对哈希桶(链表 / 红黑树的头节点)加 synchronized 锁(粒度更细),
配合 CAS 操作(无锁化处理初始化、头节点插入等场景),
并发度理论上等于桶数量,性能更优。
JDK8 为什么要废弃分段锁?
粒度较粗:Segment 数量固定(默认 16),高并发下仍可能出现分片内竞争;
内存开销大:每个 Segment 包含独立哈希表,冗余存储;
JDK8 中 synchronized 经优化(偏向锁、轻量级锁)后性能提升,桶级锁(更细粒度)性价比更高。
TIP
JDK1.7 分段锁的原理是怎么样的?
将哈希表分片,每个分片独立加锁,避免全表锁竞争。优于 HashTable(锁全局)
什么是桶级锁?
在 ConcurrentHashMap(JDK8 及之后)的设计中,桶级锁(Bucket-level Lock) 是指将锁的粒度精确到哈希表中的单个 哈希桶(Bucket),多个 key 计算出的哈希值相同时,这些 key 会被放入同一个桶中,以链表或红黑树的形式存储;
锁定单个哈希桶的头节点(链表头或红黑树根),仅影响当前桶的操作,其他桶可并发访问
数据操作与扩容逻辑
ConcurrentHashMap 中关于在高并发情况下,put 流程、哈希冲突处理、扩容机制及多线程协同的底层原理,非常值得总结和复盘
就像一局非常好的 BP,从己方每个队员的英雄池,每条路的英雄的克制关系,队员的熟练度等等,到开局的站位战术等,每个思路值得深入研究
put 方法完整流程:分阶段处理(哈希计算→初始化→扩容协同→桶内操作→计数更新),那我们分点总结~
put 完整流程是如何的?
计算 key 的哈希值(二次哈希减少冲突);
检查桶数组是否初始化,未初始化则** CAS 初始化**;
定位目标桶:
若桶为空,CAS 插入头节点(成功则返回,失败则重试);
若桶正在扩容(sizeCtl 为负数),当前线程协助扩容;
若桶非空,加锁(synchronized)处理:
链表:遍历链表,存在 key 则更新 value,否则尾插;
红黑树:按树结构插入或更新;
插入后检查链表长度,若 >=8 且桶数组容量 < 64 则触发扩容,否则转红黑树;
解锁后更新元素计数(CAS 更新 baseCount 或 Cell 数组),并检查是否需要扩容(总元素数 > 阈值)。
put 方法完整流程哪些有值得的借鉴场景?
例如设计本地线程安全缓存,需处理高频 put/get 操作,借鉴 put 场景的分场景同步:
简单插入(空桶)用 CAS 无锁化处理,减少锁开销;无锁化会使得性能高很多,毕竟冲突只会集中在部分的数据,大部分还是简单插入,所以这步优化非常必要
复杂操作(已有节点更新、链表 / 树结构修改)用细粒度锁(如锁定缓存项所在的哈希桶);避免全局锁阻塞;
元素计数, 分 baseCount 全局变量 与 CounterCell 数组 ,返回 baseCount + 所有 CounterCell 的值之和
哈希冲突处理方式有哪些值得的借鉴场景?
需处理大量哈希冲突的情况,如 实现分布式 ID 生成器的本地映射表(ID→业务信息),数据库分表路由的本地缓存 等等
当冲突元素少(链表长度 < 8,个数依照业务情况而定)时用链表存储,节省内存
冲突元素增多(≥8)且容器容量足够时,转为红黑树或跳表(类似逻辑)
触发扩容的条件及流程有哪些借鉴的场景?
例如设计高并发下的无锁队列
设定扩容阈值(如 0.75),避免容器满后阻塞
当队列中某一分片(类似 桶)的元素堆积过多(类似 链表过长),触发分片扩容,而非全量扩容
多线程同时扩容时的协同机制有哪些值得的借鉴场景?
如数据迁移,Elasticsearch 分片迁移、Redis 集群槽位迁移时等等
状态标记:用类似 sizeCtl 的变量标记迁移状态(如 **待迁移 迁移中 已完成 ,**并且设计的非常巧妙),避免节点重复处理;
任务分片:将待迁移的数据按范围分片(如按哈希值区间,划分清楚任务避免重复执行),每个节点认领一段分片(类似 从尾部认领桶),并行迁移;避免重复执行是能并行执行的前提
迁移标记:迁移完成的分片用类似 ForwardingNode 的标记,告知其他节点 数据已迁移至新位置,避免读写路由错误。
关键节点做并发控制策略
桶数组初始化
桶数组(table)是延迟初始化的(首次 put 时触发),若多个线程同时调用 put,可能导致重复初始化,浪费资源或引发数据不一致;
解决方案
CAS + 状态标记(sizeCtl)
流程
线程检查 table 是否为 null,若未初始化,尝试通过 CAS 将 sizeCtl 从初始容量**(正数)改为 - 1**(标记 “正在初始化”)
成功获取初始化权限的线程,执行 initTable
初始化 table 数组,容量为 sizeCtl 的初始值,默认 16;
其他线程发现 sizeCtl 为 - 1 时,进入 Thread.yield()等待,避免自旋浪费 CPU,直到初始化完成
TIP
sizeCtl 变量 起到哪些重要作用?
sizeCtl(volatile 修饰),兼具 **初始化标记****扩容阈值 扩容状态 ** 三重作用
正数:表示未初始化,值为初始化容量或扩容阈值
-1:表示正在初始化(仅一个线程在执行)
负数(<-1):表示正在扩容,值为-(1 + 扩容线程数)
如 - 2 表示 1 个线程在扩容,-3 表示 2 个线程在扩容
该方案可以用于平常高并发场景中,适用于 单例资源初始化、 延迟加载共享资源 等场景,核心是通过 CAS 抢占 + 状态标记实现无锁化的互斥,比 synchronized 更轻量(避免锁升级开销)
put 操作中的桶锁定
多个线程同时对同一个哈希桶执行 put(插入 / 更新 / 删除),可能导致链表断裂、红黑树结构异常(如节点丢失、循环引用)
解决方案
桶级锁(synchronized 锁定头节点)+ CAS 预检查
流程
线程通过哈希值定位目标桶(table[i])
若桶为空,先尝试用 CAS 插入头节点(无锁快速路径),失败则说明有并发修改,进入加锁流程
若桶非空,对桶的头节点加 synchronized 锁(链表头或红黑树根),确保同一时间只有一个线程操作该桶
加锁后再次检查桶状态(防止加锁前已被修改),执行插入 / 更新(链表尾插、红黑树旋转等)
操作完成后释放锁,其他线程可竞争该桶的锁
扩容时的多线程协同
当元素数量超过阈值时,需要将 table 容量翻倍(扩容),若单个线程处理可能耗时过长(尤其数据量大时),且多个线程可能同时触发扩容,导致重复扩容或数据迁移冲突
解决方案
状态标记(sizeCtl)+ 任务分片(桶认领)+ 迁移标记(ForwardingNode)
核心思想
将扩容任务拆分为 单个桶的迁移,多线程通过认领桶并行处理,用状态标记避免冲突
流程
触发扩容:线程执行 put 后发现元素数超过阈值,尝试通过 CAS 将 sizeCtl 从阈值**(正数)改为 - 1**(标记 准备扩容)
初始化新桶:成功触发的线程初始化新桶数组(nextTable,容量为原 table 的 2 倍),并将 sizeCtl 更新为-(1 + 1)(表示 1 个线程在扩容)
多线程协同:其他线程执行 put 时发现 sizeCtl 为负数(正在扩容),自动加入扩容
线程通过 CAS 将 sizeCtl 减 1(增加扩容线程数),认领任务
从旧桶数组(table)尾部向前通过 i--认领未迁移的桶(避免竞争,每个桶仅被一个线程处理)
桶迁移:线程对认领的桶加锁,将元素按新哈希规则迁移到 nextTable 的高低位桶(原哈希值新增一位用于拆分),迁移完成后将旧桶标记为 ForwardingNode(指向 nextTable,告诉其他线程 该桶已迁移);
扩容完成:所有桶迁移后,table 替换为 nextTable,sizeCtl 更新为新阈值(newCap * 0.75)
该方案适用于 大型任务并行拆解 场景(如 MapReduce 的分片计算、分布式存储的扩容迁移),核心是通过状态标记协同 + 任务分片并行,将单线程耗时操作转为多线程并行,提升效率。
元素计数(size 计算)
多线程同时执行 put/remove 时,需要实时更新元素总数,若用全局变量直接累加,会因 CAS 竞争激烈导致性能下降,若用锁则会阻塞所有操作
解决方案
分段计数(baseCount + CounterCell 数组)
核心逻辑
无竞争时用全局基数计数,有竞争时分散到多个分段计数器,减少 CAS 冲突。
细节
baseCount:volatile 变量,用于无竞争场景(单线程或低并发),直接通过 CAS 更新;
CounterCell 数组:当 baseCount 的 CAS 更新失败(检测到竞争),线程会通过哈希(线程 ID 的哈希)定位到数组中的一个 CounterCell,对其进行 CAS 更新(类似 分段锁 的计数分散)
size()方法:返回 baseCount + 所有 CounterCell 的值之和(弱一致性,无需加锁,可能包含未合并的中间值)
该方案适用于 高并发累加 / 递减 场景(如接口 QPS 统计、分布式计数器),通过哈希分片分散竞争,用空间换时间(额外的 CounterCell 数组),避免全局竞争瓶颈。
红黑树操作
红黑树是复杂的平衡树结构(插入 / 删除需旋转调整),若多线程并发操作,可能导致树的平衡被破坏(如左右子树高度差超限),甚至出现死循环。
解决方案
锁定根节点 + 操作前校验
核心逻辑
红黑树的所有修改操作(插入、删除、旋转)必须在锁定状态下执行,且操作前校验树结构的有效性。
与 HashMap 的设计差异
链表转红黑树的条件差异?
HashMap:链表长度 >=8 且桶数组容量 >=64 时转树(否则扩容);
ConcurrentHashMap:条件相同,但额外增加并发安全处理(转树时需锁定桶,避免并发修改导致树结构异常)
原因:ConcurrentHashMap 需在转树过程中保证线程安全,而 HashMap 无需考虑并发,仅关注单线程效率。
不允许 null 作为 key/value ?
核心原因:避免歧义。在并发场景下,get (key) 返回 null 时,无法区分 **key 不存在 **和 value 为 null;
HashMap 允许 null 是因为其非线程安全,用户需自行保证逻辑正确性;
而 ConcurrentHashMap 存在并发情况,需禁止 k/v 为 null 情况,减少并发场景下的错误
get () 返回 null 的区分方式 ?
ConcurrentHashMap:因禁止 value 为 null,故 get 返回 null 一定是 key 不存在;
HashMap:需通过 containsKey (key) 二次检查(存在 key 但 value 为 null 时,containsKey 返回 true)
安全机制
首先 ConcurrentHashMap 保证的是 fail-safe
安全失败(fail-safe)与快速失败(fail-fast)的异同是什么?
安全失败(如 ConcurrentHashMap):基于数据快照,迭代中修改不影响迭代,不抛异常,但可能读取到旧数据,且快照机制有一定内存开销
快速失败(如 HashMap):基于 modCount,迭代中检测到结构修改(add/remove)直接抛异常,迭代基于原集合,无额外内存开销
ConcurrentHashMap 如何保证 fail-safe?
迭代器基于当前桶数组的快照(遍历开始时获取桶数组的 volatile 引用)
后续修改操作在新桶数组(扩容时)或原桶中进行,迭代器不感知新修改
无需像 HashMap 那样依赖 modCount 检查
也不会抛出 ConcurrentModificationException
COW(Copy-On-Write)机制
什么是 Copy-On-Write ?
一种优化策略。核心是读操作无锁,写操作复制副本。
当修改数据时,不直接修改原数据,而是复制一份新数据进行修改,修改完成后用新数据替换原数据,保证读操作始终访问稳定的旧数据。
COW 如何保证线程安全?
读操作:
直接访问原数据(无锁,性能极高),无需同步;
读操作看到的是修改前的旧数组或修改后的新数组(volatile 保证可见性)
不存在中间状态,因此无需锁即可保证线程安全。
根据 volatile 的内存语义,这个更新会立即被其他线程可见
后续的读操作会获取新数组,读到最新的完整数据。
写操作
复制原数组生成新数组;
在新数组中执行修改(add/remove);
通过 volatile 引用原子性替换原数组为新数组;
核心解决思路:通过 快照隔离 与 原子引用更新 保证安全
COW 有哪些局限性?
这种机制的代价是写操作期间读不到最新数据(存在短暂的一致性延迟)
适用场景:读多写少(如配置缓存、白名单列表),且对数据实时性要求不高(允许读操作短暂看到旧数据)。
不适用场景:写操作频繁(复制数组开销大)或要求强一致性(读必须立即看到最新写)的场景。
总结
今天的 ConcurrentHashMap 算是彻底摸清了!
JDK7 和 JDK8 的并发控制差别、put 方法解析、多线程扩容多线程怎么配合,还有和 HashMap 的区别、迭代器安全机制、COW 写时复制那套逻辑,全扒得透透的~
下一场该补并发的入门基本功了!线程状态怎么转、wait 和 sleep 到底不一样在哪、上下文切换为啥费资源……
这些都是打底的知识点,必须练扎实。
熟练度刷不停,知识点吃透稳,下期接着练~
版权声明: 本文为 InfoQ 作者【DonaldCen】的原创文章。
原文链接:【http://xie.infoq.cn/article/1d0530c6c7fdbbcd4abaf6189】。文章转载请联系作者。







评论