作为 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> 链表节点 // 哈希冲突时用链表存储
核心组件说明
- Segment :继承自
ReentrantLock,既是锁对象,又是独立的哈希表容器。每个 Segment 保护自己内部的 HashEntry 数组,修改操作只需要锁当前 Segment,不影响其他分段。 - HashEntry :链表节点,
key、value、next字段都用volatile修饰,保证内存可见性,让 get 操作可以无锁执行。
默认参数
- 默认总初始容量:16
- 默认并发级别:16(即 Segment 数组默认长度为 16,理论上最多支持 16 个线程同时写)
- 默认加载因子:0.75
- 每个 Segment 的最小哈希表容量:2
按默认参数计算,总容量 16 分给 16 个 Segment,每个 Segment 初始哈希表容量为 2,保证是 2 的幂次。
2. 核心操作流程
put 操作
- 对 key 做哈希计算,用 hash 的高位定位到对应的 Segment
- 获取该 Segment 的独占锁(ReentrantLock)
- 在 Segment 内部的哈希表中执行插入逻辑:计算桶下标,遍历链表,key 存在则覆盖,不存在则头插法插入
- 检查 Segment 内元素数是否超过阈值,超过则对该 Segment 单独扩容
- 释放锁
get 操作
- 计算 hash 定位到 Segment 和对应桶
- 遍历链表,通过 equals 匹配 key
- 全程不需要加锁:因为 HashEntry 的 value 和 next 都用 volatile 修饰,保证了内存可见性,一个线程的修改对其他线程立即可见
size 操作
size 统计采用「乐观重试 + 悲观加锁」的策略:
- 先不加锁,连续统计两次所有 Segment 的元素总数,同时记录 modCount
- 如果两次统计的 modCount 一致,说明期间没有修改操作,直接返回统计结果
- 如果两次结果不一致,升级为悲观模式:给所有 Segment 依次加锁,重新统计总数
- 默认最多重试 2 次,失败则全表加锁

3. JDK 1.7 的局限性
- 并发度有上限:并发度由 Segment 数量决定,初始化后固定,无法随数据量增长动态扩展
- 锁粒度仍偏粗:同一个 Segment 内的所有操作仍然串行,单个 Segment 数据量大时性能下降
- 链表过长性能退化:和 1.7 的 HashMap 一样,只有链表结构,极端哈希冲突下查询退化为 O (n)
- size 操作高并发下性能差:冲突严重时需要全表加锁,所有读写都被阻塞
- 两次哈希损耗:先算 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 局部加锁」的混合策略,不同场景使用不同的同步方式:
- 数组初始化 :通过 CAS 修改
sizeCtl变量,保证只初始化一次 - 空桶插入:桶位置为空时,用 CAS 自旋插入新节点,完全不需要加锁
- 有元素的桶 :用
synchronized锁住该桶的首节点,保证桶内操作串行 - 扩容迁移 :多线程协同迁移,用
ForwardingNode标记已迁移的桶
3. put 方法完整执行流程
这是面试最核心的流程,一共分为 9 个步骤:
- 数组初始化检查:如果 table 为空,通过 CAS 竞争初始化数组,失败的线程自旋等待
- 计算桶下标 :对 key 做哈希扰动,通过
hash & (n - 1)定位数组下标 - 空桶 CAS 插入:如果该位置为 null,用 CAS 尝试插入新节点,成功则直接跳转到计数步骤
- 协助扩容检查 :如果该位置是
ForwardingNode(hash=-1,MOVED 状态),说明正在扩容,当前线程先参与协助扩容 - 加锁桶首节点 :否则用
synchronized锁住该桶的头节点 - 执行插入逻辑:判断当前是链表还是红黑树,遍历查找 key,存在则覆盖 value,不存在则插入尾部(链表)或对应节点(红黑树)
- 检查树化条件 :插入后如果链表长度≥8,调用
treeifyBin:若数组长度 < 64 则优先扩容,否则将链表转为红黑树 - 元素计数与扩容检查 :调用
addCount增加元素计数,检查总数是否超过阈值,超过则触发扩容 - 释放锁
4. 扩容机制:多线程协助扩容
JDK1.8 的扩容是最大的亮点之一,支持多线程协同完成数据迁移,大幅提升扩容效率。
扩容触发条件
- 元素总数超过阈值(数组长度 × 0.75)
- 链表长度达到 8,但数组长度小于 64,优先扩容而非树化
核心流程
- 发起扩容 :第一个触发扩容的线程创建长度为原数组 2 倍的新数组,设置
sizeCtl为扩容状态(负数,包含扩容标识和参与线程数) - 任务分配:将老数组的桶按段划分,每个线程领取一段(默认 16 个桶),从后往前迁移
- 桶迁移 :迁移完成一个桶,就用 CAS 将老数组该位置替换为
ForwardingNode,标记已迁移 - 协助扩容 :其他线程执行 put 时遇到
ForwardingNode,不会阻塞等待,而是调用helpTransfer加入迁移任务 - 收尾替换:最后一个完成迁移的线程检查所有桶是否迁移完毕,完成后将 table 指向新数组,更新扩容阈值
5. size 计数原理
JDK1.8 采用类似LongAdder的分散计数思想,全程无锁,性能极高。
- baseCount:无竞争时,直接用 CAS 更新 baseCount
- CounterCell 数组:有竞争时,线程通过哈希映射到不同的 CounterCell 上更新,分散计数热点
- 统计结果 :
size() = baseCount + 所有CounterCell的值之和
这种设计牺牲了强一致性,换来的是极高的并发性能,最终返回的是弱一致的近似值。
6. get 操作
- 计算 hash 定位到对应桶
- 首节点匹配则直接返回
- 是红黑树则按树查找,是链表则遍历查找
- 全程无锁: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 要放弃分段锁?
核心原因是分段锁的设计已经跟不上性能需求:
- 并发度有天花板:Segment 数量初始化后固定,无法随数据量增长扩展,默认 16 段在高并发场景下仍然会有大量锁竞争
- 锁粒度偏粗:同一个 Segment 内的操作全部串行,单个热点 Segment 会成为性能瓶颈
- 内存开销大:两层数组结构,内存占用更高
- synchronized 性能提升:JDK1.8 对 synchronized 做了大量优化(偏向锁、轻量级锁、自适应自旋),性能已经和 ReentrantLock 相当,而且不需要额外的对象开销,JVM 还能持续深度优化
- 代码复杂度:分段锁的两层结构让扩容、计数等逻辑更复杂,维护成本高
2. ConcurrentHashMap 为什么不允许 key 和 value 为 null?
这是并发集合的经典设计决策:
- 歧义问题:在单线程 HashMap 中,get 返回 null 时,你可以通过 containsKey 判断是 key 不存在还是 value 为 null。但在并发环境下,你调用 get 和 containsKey 之间,数据可能已经被其他线程修改,无法判断 null 的真实含义
- 设计哲学:并发集合的设计者 Doug Lea 认为,并发场景下不确定性是不可接受的,禁止 null 可以避免语义歧义,减少隐性 bug
3. get 方法需要加锁吗?为什么?
不需要加锁。
- Node 节点的
val和next指针都用volatile修饰,保证了内存可见性,一个线程对节点的修改,其他线程可以立刻读到最新值 - 数组 table 也用 volatile 修饰,保证数组扩容后对其他线程立即可见
- 因此 get 操作全程无锁,性能非常高
4. ConcurrentHashMap 是强一致性还是弱一致性?
是弱一致性的:
- 单个原子操作(put、remove、get)是线程安全的
- 迭代器是弱一致的:迭代过程中数据被修改,不会抛出
ConcurrentModificationException,但迭代器可能看不到最新的修改 - size ()、isEmpty () 返回的是近似值,统计过程中数据变化不会被实时反映
- 复合操作(如先检查再更新)不是原子的,需要额外同步保证
5. 为什么用 synchronized 而不是 ReentrantLock?
- 性能相当:JDK1.8 后 synchronized 经过锁升级优化,性能和 ReentrantLock 已经没有明显差距
- 内存开销小:ReentrantLock 需要创建额外的锁对象,而 synchronized 是基于对象头的 Mark Word 实现,不需要额外对象
- JVM 原生优化:synchronized 是 JVM 内置的锁,JVM 可以持续对其做深度优化,比如锁消除、锁粗化等
- 代码更简洁:不需要手动加锁释放锁,避免忘记释放锁的 bug
6. 多线程协助扩容会不会出现重复迁移?
不会,每个桶的迁移有严格的状态控制:
- 迁移前,桶的头节点是正常的 Node
- 开始迁移时,线程会用 CAS 将桶头替换为 ForwardingNode,只有 CAS 成功的线程才会迁移这个桶
- 其他线程看到 ForwardingNode 就知道这个桶已经被处理,不会重复迁移
- 任务领取也是通过 CAS 控制,每个线程只会领取属于自己的一段桶
六、常见坑点与最佳实践
常见坑点
坑 1:认为复合操作是线程安全的
很多人以为 ConcurrentHashMap 所有操作都是安全的,其实只有单个方法调用是原子的。
java
// 错误:if判断和put不是原子操作,并发下会出现覆盖
if (!map.containsKey(key)) {
map.put(key, value);
}
解决方案 :使用内置的原子方法,如putIfAbsent、computeIfAbsent、merge等。
坑 2:依赖 size () 的精确结果
size () 返回的是弱一致的近似值,并发场景下不能用于精确计数判断。 解决方案 :如果需要精确计数,单独用AtomicLong维护计数器,或者在业务层加同步。
坑 3:自定义 key 不重写 hashCode 和 equals
和 HashMap 一样,自定义对象作为 key 必须同时重写两个方法,否则会出现元素重复、查询不到的问题。
最佳实践
- 优先使用 JDK1.8 + 版本:性能、功能都远优于 1.7 版本
- 复合操作使用原子方法 :优先用
putIfAbsent、compute、merge等原子 API,避免自己写 if+put - 合理设置初始容量:和 HashMap 一样,预估元素数 / 0.75+1,减少扩容次数
- 不要用 null 做键值 :会直接抛出
NullPointerException - 遍历不要修改数据:虽然不会抛异常,但弱一致性可能导致结果不可预期
- 高并发写场景下避免频繁调用 size:虽然无锁,但遍历 CounterCell 仍然有一定开销
七、总结
从 JDK1.7 到 JDK1.8,ConcurrentHashMap 的演进,本质上是不断降低锁粒度、提升并发性能的过程:
- 1.7 用分段锁实现了初步的并发优化,但受限于固定的分段数量,性能有天花板
- 1.8 彻底重构,将锁粒度细化到单个桶,结合 CAS 无锁操作、红黑树优化、多线程协助扩容等设计,在高并发场景下性能提升显著
理解 ConcurrentHashMap 的底层原理,不仅能帮你轻松通过面试,更能让你在高并发项目中正确使用并发集合,避开并发陷阱,写出更稳定、更高性能的代码。