ConcurrentHashMap 的核心设计目标是:在保证线程安全的前提下,尽可能缩小锁的作用范围、降低锁竞争,从而提升并发读写性能。不同 JDK 版本的实现思路存在本质差异。
一、JDK 1.7:分段锁(Segment)实现
1. 底层结构
采用 Segment 数组 + HashEntry 数组 + 链表 的两级哈希架构:
Segment继承自ReentrantLock,每个 Segment 是一个独立的重入锁,保护其内部的 HashEntry 数组与链表- 默认初始化 16 个 Segment,即默认并发度为 16,且扩容时 Segment 数量不变
- 每个 Segment 内部维护独立的哈希数组、计数器和扩容阈值,彼此完全隔离
2. 线程安全保障
- 写操作:仅对当前 key 所属的单个 Segment 加锁,其他 Segment 的读写完全不受影响,实现分段并发
- 读操作 :HashEntry 的
value字段用volatile修饰,保证内存可见性,读操作全程无锁 - 扩容:仅在单个 Segment 内执行单线程扩容,不会阻塞其他 Segment 的操作
3. 固有局限
- 锁粒度为「段级」,并发上限固定为 Segment 数量,无法随哈希数组扩容同步提升
- 哈希冲突严重时,单个 Segment 内链表过长,查询性能线性退化
- Segment 本身作为锁对象,带来额外的内存开销
二、JDK 1.8:CAS + synchronized + volatile 实现
JDK 1.8 彻底重构了底层实现,将锁粒度细化到单个哈希桶,并发性能大幅提升,是目前工业界的主流版本。
1. 底层结构
采用 Node 数组 + 链表 + 红黑树 的单级哈希结构:
- 移除了 Segment 分段层,直接用
Node<K,V>[] table作为核心哈希数组 - 引入红黑树优化:当链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转换为红黑树,将冲突场景下的查询复杂度从 O (n) 降至 O (logn)
- 扩容过程中使用
ForwardingNode占位旧桶,标识该桶已完成迁移
2. 线程安全的三大核心保障
(1)可见性:volatile 全域覆盖
这是读操作可以无锁执行的基础:
table数组本身用volatile修饰,保证数组引用变更的内存可见性- Node 节点的
val(存储值)和next(后继指针)均用volatile修饰,保证节点数据变更的可见性
(2)写原子性:分场景的同步策略
写操作(put/remove 等)根据桶的状态选择不同的同步方式,兼顾性能与安全:
- 空桶插入 :如果目标桶位置为 null,直接通过 CAS 原子操作 插入新节点,全程无锁
- 桶内操作 :如果目标桶已有节点(链表或红黑树),使用
synchronized锁定该桶的头节点 (链表头节点 / 红黑树容器 TreeBin)- 锁粒度从「段级」细化到「单个哈希桶」,并发度等于数组长度,且随扩容同步提升
- 扩容中状态 :如果遇到
ForwardingNode占位节点,说明该桶正在迁移,当前线程会主动协助参与扩容
(3)读操作:几乎全程无锁
get 方法在绝大多数场景下不需要加锁:
- 计算 hash 定位到桶,直接读取头节点数据
- 遍历链表或红黑树查找,完全依赖 volatile 保证可见性
- 仅当遇到扩容中的
ForwardingNode时,才会自动转发到新数组中继续查找 - 弱一致性:迭代器和 get 均为弱一致语义,不保证实时读到最新写入的数据
3. 并发扩容机制
JDK 1.8 最核心的优化之一是多线程并发全局扩容,进一步加快性能:
- 扩容时,旧数组的桶按批次迁移到新数组,每个桶迁移完成后用
ForwardingNode占位 - 多个业务线程可以同时参与扩容,通过 CAS 分配各自负责的桶区间,充分利用多核算力
- 迁移过程中,写操作会协助扩容,读操作会自动转发到新表,业务线程几乎无阻塞
三、JDK 1.8 相对 1.7 的核心变化总结
表格
| 对比维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 底层结构 | Segment 数组 + HashEntry 数组 + 链表 | Node 数组 + 链表 + 红黑树 |
| 锁机制 | 分段 ReentrantLock | CAS + synchronized |
| 锁粒度 | 段级(默认 16 段) | 桶级(单个哈希桶) |
| 并发度 | 固定为 Segment 数量 | 等于数组长度,扩容同步提升 |
| 扩容方式 | 单线程段内扩容 | 多线程并发全局扩容 |
| 计数方式 | 遍历所有 Segment 求和,并发误差大 | baseCount + CounterCell 分段计数,类似 LongAdder 思想 |
| 查询性能 | 链表遍历,冲突严重时线性退化 | 红黑树优化,稳定 O (logn) |
| 内存开销 | 大量 Segment 锁对象,开销高 | 无额外锁对象,内存更紧凑 |
| 插入方式 | 头插法 | 尾插法(需统计链表长度,方便转换红黑树) |
四、关键细节补充
1. 为什么用 synchronized 替代 ReentrantLock?
- JDK 1.8 对 synchronized 做了深度优化(偏向锁 → 轻量级锁 → 重量级锁的自适应升级),低竞争场景下性能优于 ReentrantLock
- synchronized 是 JVM 内置锁,不需要在 Java 堆中维护额外的锁对象,内存开销更低
- 锁粒度细化到桶级后,单个锁的持有时间极短,synchronized 的性能优势更明显
2. 为什么不允许 key/value 为 null?
与 HashMap 不同,ConcurrentHashMap 禁止 key 或 value 为 null,核心原因是并发场景下会产生歧义:无法区分「值本身就是 null」和「key 不存在」,避免并发读写时的语义混乱。