ConcurrentHashMap 底层原理深度解密:从分段锁到 CAS + 红黑树的演进全解

作为 Java 后端开发者,ConcurrentHashMap 是高并发场景下的标配集合类,也是 Java 面试中 100% 会被深挖的核心考点。它解决了 HashMap 线程不安全、Hashtable 全表锁性能低下的问题,从 JDK1.7 到 JDK1.8 经历了近乎重构的升级,背后是对并发性能的极致追求。

很多开发者知道 ConcurrentHashMap 是线程安全的,却说不清它的线程安全是怎么实现的;知道 1.8 用了红黑树,却讲不清分段锁为什么被淘汰、多线程协助扩容是怎么工作的。

这篇文章,我们就从数据结构演进→核心操作原理→版本差异对比→面试深度问题→生产最佳实践五个维度,彻底搞懂 ConcurrentHashMap 的底层设计。

一、ConcurrentHashMap 核心认知

ConcurrentHashMap 是 Java 并发包下的线程安全哈希表实现,核心目标是在保证线程安全的前提下,尽可能降低锁的粒度,提升并发访问性能。

核心特性

  • 线程安全:多线程环境下可以安全地执行增删改查操作
  • 键值非空:key 和 value 都不允许为 null,这是和 HashMap 最直观的区别之一
  • 弱一致性:迭代器、size () 返回的是近似值,不会抛出并发修改异常
  • 高并发性能:细粒度锁设计,性能远高于 Hashtable 的全表锁

为什么不用 Hashtable?

Hashtable 通过在所有方法上加synchronized实现线程安全,锁的是整个 Hashtable 对象,同一时间只能有一个线程操作,并发度极低。而 ConcurrentHashMap 采用细粒度锁,只锁定部分数据,不同部分的操作可以并行执行,性能提升显著。

二、JDK 1.7:分段锁(Segment)机制

JDK1.7 的 ConcurrentHashMap 核心设计是分段锁,将整个哈希表拆分成多个独立的分段,每个分段有自己的锁,不同分段之间的操作互不干扰。

1. 底层数据结构

整体采用三层结构:Segment数组 + HashEntry数组 + 单向链表

复制代码
ConcurrentHashMap
└── Segment<K,V>[] segments  // 分段锁数组,每个Segment是一把独立的锁
    └── HashEntry<K,V>[] table  // 每个Segment内部的哈希表
        └── HashEntry<K,V> 链表节点  // 哈希冲突时用链表存储
核心组件说明
  1. Segment :继承自ReentrantLock,既是锁对象,又是独立的哈希表容器。每个 Segment 保护自己内部的 HashEntry 数组,修改操作只需要锁当前 Segment,不影响其他分段。
  2. HashEntry :链表节点,keyvaluenext字段都用volatile修饰,保证内存可见性,让 get 操作可以无锁执行。
默认参数
  • 默认总初始容量:16
  • 默认并发级别:16(即 Segment 数组默认长度为 16,理论上最多支持 16 个线程同时写)
  • 默认加载因子:0.75
  • 每个 Segment 的最小哈希表容量:2

按默认参数计算,总容量 16 分给 16 个 Segment,每个 Segment 初始哈希表容量为 2,保证是 2 的幂次。

2. 核心操作流程

put 操作
  1. 对 key 做哈希计算,用 hash 的高位定位到对应的 Segment
  2. 获取该 Segment 的独占锁(ReentrantLock)
  3. 在 Segment 内部的哈希表中执行插入逻辑:计算桶下标,遍历链表,key 存在则覆盖,不存在则头插法插入
  4. 检查 Segment 内元素数是否超过阈值,超过则对该 Segment 单独扩容
  5. 释放锁
get 操作
  1. 计算 hash 定位到 Segment 和对应桶
  2. 遍历链表,通过 equals 匹配 key
  3. 全程不需要加锁:因为 HashEntry 的 value 和 next 都用 volatile 修饰,保证了内存可见性,一个线程的修改对其他线程立即可见
size 操作

size 统计采用「乐观重试 + 悲观加锁」的策略:

  1. 先不加锁,连续统计两次所有 Segment 的元素总数,同时记录 modCount
  2. 如果两次统计的 modCount 一致,说明期间没有修改操作,直接返回统计结果
  3. 如果两次结果不一致,升级为悲观模式:给所有 Segment 依次加锁,重新统计总数
  4. 默认最多重试 2 次,失败则全表加锁

3. JDK 1.7 的局限性

  1. 并发度有上限:并发度由 Segment 数量决定,初始化后固定,无法随数据量增长动态扩展
  2. 锁粒度仍偏粗:同一个 Segment 内的所有操作仍然串行,单个 Segment 数据量大时性能下降
  3. 链表过长性能退化:和 1.7 的 HashMap 一样,只有链表结构,极端哈希冲突下查询退化为 O (n)
  4. size 操作高并发下性能差:冲突严重时需要全表加锁,所有读写都被阻塞
  5. 两次哈希损耗:先算 Segment 下标,再算桶下标,多一次哈希计算

三、JDK 1.8:CAS + synchronized + 红黑树

JDK1.8 对 ConcurrentHashMap 做了彻底重构,完全抛弃了 Segment 分段锁,改用和 HashMap1.8 一致的「数组 + 链表 + 红黑树」结构,锁粒度从 Segment 级别细化到单个桶级别,并发性能大幅提升。

1. 底层数据结构

复制代码
ConcurrentHashMap
└── Node<K,V>[] table  // 主哈希数组
    ├── Node 链表节点  // 冲突元素少时用链表
    └── TreeNode 红黑树节点  // 冲突元素多时转红黑树
核心变化
  • 移除了 Segment 分段锁,直接对每个桶的首节点加锁
  • 引入红黑树,解决链表过长的性能退化问题
  • 大量使用 CAS 无锁操作,进一步降低锁的使用
  • 采用baseCount + CounterCell的分散计数,替代 1.7 的加锁统计

2. 核心同步机制

JDK1.8 采用「CAS 无锁 + synchronized 局部加锁」的混合策略,不同场景使用不同的同步方式:

  1. 数组初始化 :通过 CAS 修改sizeCtl变量,保证只初始化一次
  2. 空桶插入:桶位置为空时,用 CAS 自旋插入新节点,完全不需要加锁
  3. 有元素的桶 :用synchronized锁住该桶的首节点,保证桶内操作串行
  4. 扩容迁移 :多线程协同迁移,用ForwardingNode标记已迁移的桶

3. put 方法完整执行流程

这是面试最核心的流程,一共分为 9 个步骤:

  1. 数组初始化检查:如果 table 为空,通过 CAS 竞争初始化数组,失败的线程自旋等待
  2. 计算桶下标 :对 key 做哈希扰动,通过hash & (n - 1)定位数组下标
  3. 空桶 CAS 插入:如果该位置为 null,用 CAS 尝试插入新节点,成功则直接跳转到计数步骤
  4. 协助扩容检查 :如果该位置是ForwardingNode(hash=-1,MOVED 状态),说明正在扩容,当前线程先参与协助扩容
  5. 加锁桶首节点 :否则用synchronized锁住该桶的头节点
  6. 执行插入逻辑:判断当前是链表还是红黑树,遍历查找 key,存在则覆盖 value,不存在则插入尾部(链表)或对应节点(红黑树)
  7. 检查树化条件 :插入后如果链表长度≥8,调用treeifyBin:若数组长度 < 64 则优先扩容,否则将链表转为红黑树
  8. 元素计数与扩容检查 :调用addCount增加元素计数,检查总数是否超过阈值,超过则触发扩容
  9. 释放锁

4. 扩容机制:多线程协助扩容

JDK1.8 的扩容是最大的亮点之一,支持多线程协同完成数据迁移,大幅提升扩容效率。

扩容触发条件
  • 元素总数超过阈值(数组长度 × 0.75)
  • 链表长度达到 8,但数组长度小于 64,优先扩容而非树化
核心流程
  1. 发起扩容 :第一个触发扩容的线程创建长度为原数组 2 倍的新数组,设置sizeCtl为扩容状态(负数,包含扩容标识和参与线程数)
  2. 任务分配:将老数组的桶按段划分,每个线程领取一段(默认 16 个桶),从后往前迁移
  3. 桶迁移 :迁移完成一个桶,就用 CAS 将老数组该位置替换为ForwardingNode,标记已迁移
  4. 协助扩容 :其他线程执行 put 时遇到ForwardingNode,不会阻塞等待,而是调用helpTransfer加入迁移任务
  5. 收尾替换:最后一个完成迁移的线程检查所有桶是否迁移完毕,完成后将 table 指向新数组,更新扩容阈值

5. size 计数原理

JDK1.8 采用类似LongAdder的分散计数思想,全程无锁,性能极高。

  • baseCount:无竞争时,直接用 CAS 更新 baseCount
  • CounterCell 数组:有竞争时,线程通过哈希映射到不同的 CounterCell 上更新,分散计数热点
  • 统计结果size() = baseCount + 所有CounterCell的值之和

这种设计牺牲了强一致性,换来的是极高的并发性能,最终返回的是弱一致的近似值。

6. get 操作

  1. 计算 hash 定位到对应桶
  2. 首节点匹配则直接返回
  3. 是红黑树则按树查找,是链表则遍历查找
  4. 全程无锁:Node 的 val 和 next 都是 volatile 修饰,保证可见性

四、JDK 1.7 与 JDK 1.8 核心区别

对比维度 JDK 1.7 JDK 1.8
数据结构 Segment 数组 + HashEntry 数组 + 单向链表 Node 数组 + 链表 + 红黑树
锁机制 ReentrantLock 分段锁 CAS + synchronized 桶级锁
锁粒度 Segment 级别(默认 16 段) 单个桶级别(粒度更细)
并发度 固定,由 Segment 数量决定,默认 16 动态,与数组长度正相关,理论上限更高
插入方式 链表头插法 链表尾插法 / 红黑树插入
扩容机制 单个 Segment 独立扩容 全表扩容,支持多线程协助迁移
size 实现 乐观重试 + 全表加锁,强一致 baseCount+CounterCell 分散计数,弱一致
红黑树支持 不支持 支持,解决链表过长性能退化
哈希计算 两次哈希(先定位 Segment,再定位桶) 一次哈希,直接定位桶
高并发性能 并发度固定,高并发下瓶颈明显 锁粒度更细,高并发下性能更优
代码复杂度 结构清晰,逻辑相对简单 状态变量多,逻辑更复杂

五、面试高频深度问题

1. 为什么 JDK1.8 要放弃分段锁?

核心原因是分段锁的设计已经跟不上性能需求:

  1. 并发度有天花板:Segment 数量初始化后固定,无法随数据量增长扩展,默认 16 段在高并发场景下仍然会有大量锁竞争
  2. 锁粒度偏粗:同一个 Segment 内的操作全部串行,单个热点 Segment 会成为性能瓶颈
  3. 内存开销大:两层数组结构,内存占用更高
  4. synchronized 性能提升:JDK1.8 对 synchronized 做了大量优化(偏向锁、轻量级锁、自适应自旋),性能已经和 ReentrantLock 相当,而且不需要额外的对象开销,JVM 还能持续深度优化
  5. 代码复杂度:分段锁的两层结构让扩容、计数等逻辑更复杂,维护成本高

2. ConcurrentHashMap 为什么不允许 key 和 value 为 null?

这是并发集合的经典设计决策:

  • 歧义问题:在单线程 HashMap 中,get 返回 null 时,你可以通过 containsKey 判断是 key 不存在还是 value 为 null。但在并发环境下,你调用 get 和 containsKey 之间,数据可能已经被其他线程修改,无法判断 null 的真实含义
  • 设计哲学:并发集合的设计者 Doug Lea 认为,并发场景下不确定性是不可接受的,禁止 null 可以避免语义歧义,减少隐性 bug

3. get 方法需要加锁吗?为什么?

不需要加锁。

  • Node 节点的valnext指针都用volatile修饰,保证了内存可见性,一个线程对节点的修改,其他线程可以立刻读到最新值
  • 数组 table 也用 volatile 修饰,保证数组扩容后对其他线程立即可见
  • 因此 get 操作全程无锁,性能非常高

4. ConcurrentHashMap 是强一致性还是弱一致性?

弱一致性的:

  • 单个原子操作(put、remove、get)是线程安全的
  • 迭代器是弱一致的:迭代过程中数据被修改,不会抛出ConcurrentModificationException,但迭代器可能看不到最新的修改
  • size ()、isEmpty () 返回的是近似值,统计过程中数据变化不会被实时反映
  • 复合操作(如先检查再更新)不是原子的,需要额外同步保证

5. 为什么用 synchronized 而不是 ReentrantLock?

  1. 性能相当:JDK1.8 后 synchronized 经过锁升级优化,性能和 ReentrantLock 已经没有明显差距
  2. 内存开销小:ReentrantLock 需要创建额外的锁对象,而 synchronized 是基于对象头的 Mark Word 实现,不需要额外对象
  3. JVM 原生优化:synchronized 是 JVM 内置的锁,JVM 可以持续对其做深度优化,比如锁消除、锁粗化等
  4. 代码更简洁:不需要手动加锁释放锁,避免忘记释放锁的 bug

6. 多线程协助扩容会不会出现重复迁移?

不会,每个桶的迁移有严格的状态控制:

  • 迁移前,桶的头节点是正常的 Node
  • 开始迁移时,线程会用 CAS 将桶头替换为 ForwardingNode,只有 CAS 成功的线程才会迁移这个桶
  • 其他线程看到 ForwardingNode 就知道这个桶已经被处理,不会重复迁移
  • 任务领取也是通过 CAS 控制,每个线程只会领取属于自己的一段桶

六、常见坑点与最佳实践

常见坑点

坑 1:认为复合操作是线程安全的

很多人以为 ConcurrentHashMap 所有操作都是安全的,其实只有单个方法调用是原子的。

java 复制代码
// 错误:if判断和put不是原子操作,并发下会出现覆盖
if (!map.containsKey(key)) {
    map.put(key, value);
}

解决方案 :使用内置的原子方法,如putIfAbsentcomputeIfAbsentmerge等。

坑 2:依赖 size () 的精确结果

size () 返回的是弱一致的近似值,并发场景下不能用于精确计数判断。 解决方案 :如果需要精确计数,单独用AtomicLong维护计数器,或者在业务层加同步。

坑 3:自定义 key 不重写 hashCode 和 equals

和 HashMap 一样,自定义对象作为 key 必须同时重写两个方法,否则会出现元素重复、查询不到的问题。

最佳实践

  1. 优先使用 JDK1.8 + 版本:性能、功能都远优于 1.7 版本
  2. 复合操作使用原子方法 :优先用putIfAbsentcomputemerge等原子 API,避免自己写 if+put
  3. 合理设置初始容量:和 HashMap 一样,预估元素数 / 0.75+1,减少扩容次数
  4. 不要用 null 做键值 :会直接抛出NullPointerException
  5. 遍历不要修改数据:虽然不会抛异常,但弱一致性可能导致结果不可预期
  6. 高并发写场景下避免频繁调用 size:虽然无锁,但遍历 CounterCell 仍然有一定开销

七、总结

从 JDK1.7 到 JDK1.8,ConcurrentHashMap 的演进,本质上是不断降低锁粒度、提升并发性能的过程:

  • 1.7 用分段锁实现了初步的并发优化,但受限于固定的分段数量,性能有天花板
  • 1.8 彻底重构,将锁粒度细化到单个桶,结合 CAS 无锁操作、红黑树优化、多线程协助扩容等设计,在高并发场景下性能提升显著

理解 ConcurrentHashMap 的底层原理,不仅能帮你轻松通过面试,更能让你在高并发项目中正确使用并发集合,避开并发陷阱,写出更稳定、更高性能的代码。

相关推荐
阿维的博客日记1 小时前
那用到动态代理,关键的特征又是什么呢
java·动态代理
都说名字长不会被发现1 小时前
Spring Boot Starter 中间件账号密码加密方案设计与实现
java·spring boot·后端·中间件
摇滚侠1 小时前
Maven 依赖范围
java·maven
AKA__Zas1 小时前
芝士算法(滑动窗口片 2.0)
java·算法·leetcode·学习方法
刀法如飞2 小时前
《理解道德经》简单版第 3 章:不尚贤,使民不争
面试·程序员·创业
Zella折耳根4 小时前
复习篇-常用实用类
java
devilnumber9 小时前
Java 递归算法 详解 + 核心要点 + 实战运用 + 避坑指南
java·开发语言·算法
asdfg125896311 小时前
JavaBean是什么?怎么理解?有什么用途?
java·开发语言
kyriewen12 小时前
手写 Promise.all、race、any:不到 30 行代码,解决并发异步的所有姿势
前端·javascript·面试