大家好,我是程序员强子。
又来刷英雄熟练度咯~今天专攻 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 经优化(偏向锁、轻量级锁)后性能提升,桶级锁(更细粒度)性价比更高。
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 到底不一样在哪、上下文切换为啥费资源…… 这些都是打底的知识点,必须练扎实。
熟练度刷不停,知识点吃透稳,下期接着练~