写点什么

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

作者:DonaldCen
  • 2025-12-05
    广东
  • 本文字数:5309 字

    阅读完需:约 17 分钟

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)


流程


  1. 线程检查 table 是否为 null,若未初始化,尝试通过 CAS sizeCtl 从初始容量**(正数)改为 - 1**(标记 “正在初始化”)

  2. 成功获取初始化权限的线程,执行 initTable

  3. 初始化 table 数组,容量为 sizeCtl 的初始值,默认 16;

  4. 其他线程发现 sizeCtl 为 - 1 时,进入 Thread.yield()等待,避免自旋浪费 CPU,直到初始化完成


TIP


sizeCtl 变量 起到哪些重要作用?


sizeCtl(volatile 修饰),兼具 **初始化标记****扩容阈值 扩容状态 ** 三重作用


  • 正数:表示未初始化,值为初始化容量或扩容阈值

  • -1:表示正在初始化(仅一个线程在执行)

  • 负数(<-1):表示正在扩容,值为-(1 + 扩容线程数)

  • 如 - 2 表示 1 个线程在扩容,-3 表示 2 个线程在扩容


该方案可以用于平常高并发场景中,适用于 单例资源初始化、 延迟加载共享资源 等场景,核心是通过 CAS 抢占 + 状态标记实现无锁化的互斥,比 synchronized 更轻量(避免锁升级开销)

put 操作中的桶锁定

多个线程同时对同一个哈希桶执行 put(插入 / 更新 / 删除),可能导致链表断裂、红黑树结构异常(如节点丢失、循环引用)


解决方案


桶级锁(synchronized 锁定头节点)+ CAS 预检查


流程


  1. 线程通过哈希值定位目标桶(table[i])

  2. 若桶为空,先尝试用 CAS 插入头节点(无锁快速路径),失败则说明有并发修改,进入加锁流程

  3. 若桶非空,对桶的头节点加 synchronized 锁(链表头或红黑树根),确保同一时间只有一个线程操作该桶

  4. 加锁后再次检查桶状态(防止加锁前已被修改),执行插入 / 更新(链表尾插、红黑树旋转等)

  5. 操作完成后释放锁,其他线程可竞争该桶的锁

扩容时的多线程协同

当元素数量超过阈值时,需要将 table 容量翻倍(扩容),若单个线程处理可能耗时过长(尤其数据量大时),且多个线程可能同时触发扩容,导致重复扩容或数据迁移冲突


解决方案


状态标记(sizeCtl)+ 任务分片(桶认领)+ 迁移标记(ForwardingNode)


核心思想


将扩容任务拆分为 单个桶的迁移,多线程通过认领桶并行处理,用状态标记避免冲突


流程


  1. 触发扩容:线程执行 put 后发现元素数超过阈值,尝试通过 CAS 将 sizeCtl 从阈值**(正数)改为 - 1**(标记 准备扩容

  2. 初始化新桶:成功触发的线程初始化新桶数组(nextTable,容量为原 table 的 2 倍),并将 sizeCtl 更新为-(1 + 1)(表示 1 个线程在扩容)

  3. 多线程协同:其他线程执行 put 时发现 sizeCtl 为负数(正在扩容),自动加入扩容

  4. 线程通过 CAS 将 sizeCtl 减 1(增加扩容线程数),认领任务

  5. 从旧桶数组(table)尾部向前通过 i--认领未迁移的桶(避免竞争,每个桶仅被一个线程处理)

  6. 桶迁移:线程对认领的桶加锁,将元素按新哈希规则迁移到 nextTable 的高低位桶(原哈希值新增一位用于拆分),迁移完成后将旧桶标记为 ForwardingNode(指向 nextTable,告诉其他线程 该桶已迁移);

  7. 扩容完成:所有桶迁移后,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 到底不一样在哪、上下文切换为啥费资源……


这些都是打底的知识点,必须练扎实。


熟练度刷不停,知识点吃透稳,下期接着练~

发布于: 刚刚阅读数: 4
用户头像

DonaldCen

关注

有个性,没签名 2019-01-13 加入

跟我在峡谷学Java 公众号:程序员悟空的宝藏乐园

评论

发布
暂无评论
Java 王者修炼手册【集合篇-ConcurrentHashMap】 :从分段锁到桶级锁的锁系进化_ConcurrentHashMap_DonaldCen_InfoQ写作社区