为什么ConcurrentHashMap的1.7版本到1.8,从ReentrantLock变成了synchronized,扩容区别
ReentrantLock
切换为synchronized
,主要基于性能优化、数据结构调整、JVM 特性支持等多方面考量。以下是具体原因分析:一、JVM 对synchronized
的优化
synchronized
进行了大量优化(如自适应自旋锁、锁膨胀、偏向锁等),使其性能大幅提升,接近甚至在某些场景下超过ReentrantLock
。具体优化包括:- 轻量级锁:通过 CAS 操作尝试在栈帧中记录锁状态,避免直接进入内核态,减少线程阻塞开销。
- 自适应自旋:根据锁竞争情况动态调整自旋次数,避免无意义的忙等待。
- 偏向锁:当锁仅被单个线程访问时,通过标记线程 ID 避免锁竞争,进一步降低开销。
synchronized
的轻量化特性使其更适合高并发场景下的细粒度锁需求,而ReentrantLock
的显式锁机制(如lock()
/unlock()
)在代码复杂度和性能上不再具有明显优势。二、数据结构调整:从分段锁(Segment)到节点锁
1.7 版本:分段锁(Segment)架构
- 核心结构:由多个
Segment
(默认 16 个)组成,每个Segment
是一个独立的哈希表,内部使用ReentrantLock
保证线程安全。 - 问题:
- 锁粒度粗:虽然不同
Segment
可并发访问,但每个Segment
对应一个较大的锁范围(如整个链表),锁竞争仍可能发生。 - 空间浪费:每个
Segment
需要独立维护锁对象和哈希表结构,内存占用较高。
- 锁粒度粗:虽然不同
1.8 版本:数组 + 链表 / 红黑树 + 节点锁
- 核心结构:取消
Segment
,直接使用数组存储节点,每个节点(链表头或红黑树根节点)通过synchronized
加锁。 - 优势:
- 锁粒度更细:锁直接作用于链表头节点或红黑树根节点,不同节点的操作可并发执行,减少锁竞争。
- 结构简化:无需维护独立的
Segment
对象,节省内存空间,且数据结构更紧凑。
三、减少内存开销与复杂度
-
ReentrantLock
的额外开销:- 每个
Segment
需要存储一个ReentrantLock
对象,包含锁状态、等待队列等信息,增加内存占用。 - 显式锁需要手动调用
lock()
和unlock()
,代码复杂度较高,且容易因遗漏unlock()
导致死锁。
- 每个
-
synchronized
的轻量化:- 作为 JVM 内置关键字,
synchronized
的锁信息直接存储在对象头(Object Header)中,无需额外对象,内存占用更低。 - 锁的获取和释放由 JVM 自动管理,代码更简洁,减少人为失误风险。
- 作为 JVM 内置关键字,
四、结合 CAS 操作提升性能
synchronized
锁。这种 “非阻塞优先,阻塞兜底” 的策略进一步提升了并发性能:- 无竞争场景:通过 CAS 直接修改数据,无需加锁,性能接近普通 HashMap。
- 低竞争场景:利用
synchronized
的优化特性(如偏向锁、自旋锁)快速获取锁,减少线程阻塞。 - 高竞争场景:通过红黑树优化链表遍历效率,并结合锁粒度细化,降低锁冲突概率。
五、兼容性与未来优化
- JVM 特性对齐:随着 JVM 对
synchronized
的持续优化(如 ZGC 等新垃圾回收器的支持),使用内置锁更便于后续性能调优。 - 代码维护性:统一使用
synchronized
可减少锁类型混杂带来的维护成本,使代码逻辑更清晰。
总结:核心变化对比
维度 | 1.7 版本(ReentrantLock) | 1.8 版本(synchronized) |
---|---|---|
锁粒度 | 分段锁(Segment 级别) | 节点锁(链表头 / 红黑树根节点) |
锁性能 | 依赖 ReentrantLock 的显式控制 | 利用 JVM 对 synchronized 的优化 |
数据结构 | 分段哈希表 | 数组 + 链表 / 红黑树 |
内存开销 | 每个 Segment 独立锁,空间占用高 | 对象头存储锁信息,空间更紧凑 |
并发策略 | 分段并发 + 显式锁 | CAS 优先 + 轻量级锁 + 红黑树优化 |
synchronized
与 CAS 的结合、更细粒度的锁控制和数据结构优化,在保证线程安全的同时,显著提升了并发场景下的性能和内存效率,是 Java 集合框架演进中的重要优化。扩容:ConcurrentHashMap的1.7版本到1.8的扩容机制区别,和hashmap扩容区别
好的,我们来详细对比一下 ConcurrentHashMap
(CHM) 在 JDK 1.7 和 1.8 中的扩容机制,以及它们与 HashMap
扩容的区别。
核心目标差异:
-
HashMap
: 主要关注单线程性能和正确性(虽然并发下会出错)。 -
ConcurrentHashMap
: 首要目标是保证线程安全和高并发性能,扩容机制的设计必须围绕这个核心。
一、ConcurrentHashMap 1.7 vs 1.8 扩容机制区别
特性 | ConcurrentHashMap (JDK 1.7) | ConcurrentHashMap (JDK 1.8) |
---|---|---|
底层结构 | 分段锁 (Segment + HashEntry) | 数组 + 链表/红黑树 + Synchronized/CAS (类似 HashMap 1.8) |
锁粒度 | 段锁 (Segment)。扩容锁住整个 Segment。 | 桶级别锁 (链表头节点/树根节点)。扩容涉及更细粒度的锁和 CAS。 |
扩容触发条件 | 检查单个 Segment 内的 HashEntry 数量是否超过阈值。 | 检查整个 Map 的元素数量是否超过 sizeCtl 阈值,或 单个链表长度超过 8 但数组长度小于 64。 |
扩容单位 | Segment。每个 Segment 独立扩容。 | 整个数组。扩容是针对整个 table 的。 |
扩容过程并发性 | 单线程。当一个线程触发某个 Segment 扩容时,它会获取该 Segment 的独占锁,其他线程对该 Segment 的 put/remove 操作被阻塞,只能等待或尝试访问其他 Segment。 | 多线程协同。第一个触发扩容的线程初始化新数组。后续 put/remove 操作的线程可以协助迁移数据。迁移任务被分成多个小的区间 (stride),多个线程可以并发迁移不同区间的桶。 |
数据迁移方式 | 对 Segment 内的所有桶进行重新哈希,头插法 (可能导致并发问题根源之一)。 | 尾插法。迁移链表时使用尾插法到新数组的桶中。对于树节点,有特殊处理逻辑。 |
扩容期间访问 | 正在扩容的 Segment 阻塞访问。 | 非阻塞访问。使用 ForwardingNode 占位符:* 读操作:遇到 ForwardingNode ,转发到新数组查询。* 写操作 (put/remove):遇到 ForwardingNode ,先协助迁移当前桶,再执行操作。 |
关键辅助机制 | 无 | ForwardingNode : 标记桶已迁移。sizeCtl : 控制状态 (负数表示扩容中,记录参与扩容的线程数)。迁移任务划分 (stride): 支持并发迁移。 |
优点 | 分段锁提供一定并发度,扩容只影响单个 Segment。 | 高并发度:读写操作在大多数情况下不阻塞,扩容期间也能访问,且利用多线程加速扩容。避免了全局锁。 |
缺点 | 扩容时阻塞对应 Segment 的访问。锁粒度相对较粗。头插法有潜在隐患。 | 实现极其复杂。需要精确的状态控制和并发协调。 |
JDK 1.8 ConcurrentHashMap 扩容核心流程简述:
-
触发扩容: 线程在 put 或 remove 后检查元素总数 (或链表长度条件),若超过阈值,调用
transfer()
。 -
初始化新数组: 第一个触发扩容的线程负责创建新数组 (通常是原数组的 2 倍)。
-
设置状态 (
sizeCtl
): 设置sizeCtl
为一个很大的负数,表示扩容开始,并记录第一个线程信息或初始线程数。 -
分配迁移任务: 迁移工作被逻辑上划分为多个区间 (stride)。每个准备操作 table 的线程(无论是 put/remove 还是主动帮忙):
-
检查当前是否有扩容在进行 (
sizeCtl < 0
)。 -
计算自己应该负责迁移的桶区间范围 (通过 CAS 更新
transferIndex
)。 -
领取任务区间。
-
-
迁移桶 (核心):
-
遍历自己负责的桶区间。
-
对每个桶,加锁 (synchronized 锁住桶的头节点)。
-
将桶中的节点迁移到新数组:
-
链表:遍历旧链表,根据
(e.hash & oldCap) == 0
判断节点应该留在新数组的“低位”(原索引位置) 还是“高位”(原索引 + oldCap 位置)。使用尾插法构建新链表。 -
红黑树:有专门的方法
TreeNode.split
处理树的迁移和可能的退化为链表。
-
-
在原桶位置放置一个
ForwardingNode
占位符,标记此桶已迁移,并指向新数组。
-
-
协助与协作: 任何线程在执行操作时遇到
ForwardingNode
,都会先尝试协助迁移(领取迁移任务)。迁移完一个桶后,该桶即可被新数据访问。 -
扩容完成: 当所有桶都被迁移完毕,最后一个完成迁移的线程:
-
将
table
引用指向新数组。 -
更新
sizeCtl
为新容量的 0.75 倍 (新的扩容阈值)。 -
清理临时状态。
-
二、ConcurrentHashMap (1.8) 与 HashMap (1.8) 扩容区别
特性 | HashMap (JDK 1.8) | ConcurrentHashMap (JDK 1.8) |
---|---|---|
线程安全性 | 不安全。并发扩容会导致数据丢失、死循环 (1.7 头插法)、结果不可预测。 | 安全。精心设计的并发控制机制保证扩容过程的线程安全。 |
并发扩容 | 不支持。只能由触发扩容的单一线程完成整个扩容和数据迁移。其他线程的操作被阻塞或导致错误。 | 支持多线程协同扩容。多个线程可以并发迁移不同桶区间的数据,大大加速扩容过程。 |
扩容期间访问 | 阻塞或不安全。扩容期间,整个 map 可能处于不一致状态,访问结果不可靠。 | 非阻塞访问。通过 ForwardingNode 机制,读写操作在扩容期间仍然可以进行,并能获得正确结果或协助迁移。 |
锁的使用 | 无显式锁。依赖内部状态和重新哈希。 | 使用 synchronized 锁住单个桶的头节点 进行迁移和写操作,结合大量的 CAS 操作更新状态和控制变量 (sizeCtl , transferIndex )。 |
迁移触发点 | 只在 put 操作中检查并可能触发扩容。 | 在 put 和 remove 操作后都可能检查并触发扩容。 |
ForwardingNode |
无。 | 核心机制。用于标记已迁移的桶,实现扩容期间的访问转发和协助迁移。 |
sizeCtl |
无。使用 threshold 作为扩容阈值。 |
核心控制变量。不仅表示扩容阈值,还作为扩容状态标志 (负数) 和记录协助线程数。 |
迁移策略 (链表) | 同样使用 尾插法 (解决 1.7 头插法死循环问题)。利用 (e.hash & oldCap) == 0 判断高低位。 |
完全相同。尾插法 + 高低位判断。 |
迁移策略 (树) | 处理树的拆分和可能的退化。 | 处理树的拆分和可能的退化,但操作需要在锁保护下进行。 |
复杂度 | 相对简单,单线程操作。 | 极其复杂。需要处理多线程间的协调、状态同步、任务分配、无锁化探测等,代码量巨大。 |
首要目标 | 单线程性能。 | 高并发下的线程安全与性能。 |
关键区别总结:
-
线程安全与并发控制: 这是最根本的区别。
HashMap
完全不考虑并发,其扩容在并发下会崩溃。ConcurrentHashMap
1.8 的扩容机制是其高并发能力的核心体现,通过ForwardingNode
、细粒度锁 (synchronized on bucket)、CAS 控制变量 (sizeCtl
,transferIndex
) 以及多线程协同迁移,实现了扩容期间的高并发访问和高效扩容。 -
阻塞 vs 非阻塞/协作:
HashMap
扩容会阻塞整个 Map (对用户感知可能是错误结果)。ConcurrentHashMap
1.8 扩容时,读操作几乎无影响,写操作可能会短暂阻塞 (等待桶锁) 或主动参与迁移,整体是非阻塞和协作式的。 -
单线程 vs 多线程迁移:
HashMap
只能单线程迁移。ConcurrentHashMap
利用多线程并发迁移不同桶区间,显著提升大 Map 的扩容速度。 -
状态管理复杂度:
ConcurrentHashMap
需要维护复杂的并发状态 (sizeCtl
的多重含义),而HashMap
的状态管理相对简单直接。
结论:
-
JDK 1.8 的
ConcurrentHashMap
相对于 1.7 版本,在扩容机制上进行了革命性的改进,摒弃了分段锁,采用了类似HashMap
1.8 的数组+链表/红黑树结构,但通过引入ForwardingNode
、多线程协同迁移、细粒度桶锁和精妙的 CAS 控制,实现了高并发下的高效、非阻塞式扩容,这是其卓越并发性能的关键所在。 -
ConcurrentHashMap
1.8 的扩容机制与HashMap
1.8 在基础算法(尾插法、高低位判断)上相似,但其并发控制、状态管理、协作机制的复杂度和先进性远超HashMap
,这也是专门为线程安全和高并发场景设计的必然结果。HashMap
的扩容设计则专注于单线程下的简洁和效率。