为什么ConcurrentHashMap的1.7版本到1.8,从ReentrantLock变成了synchronized,扩容区别

ConcurrentHashMap 在 Java 1.7 到 1.8 版本的锁机制从ReentrantLock切换为synchronized,主要基于性能优化、数据结构调整、JVM 特性支持等多方面考量。以下是具体原因分析:

一、JVM 对synchronized的优化

在 Java 1.6 之后,JVM 对synchronized进行了大量优化(如自适应自旋锁、锁膨胀、偏向锁等),使其性能大幅提升,接近甚至在某些场景下超过ReentrantLock。具体优化包括:

  1. 轻量级锁:通过 CAS 操作尝试在栈帧中记录锁状态,避免直接进入内核态,减少线程阻塞开销。
  2. 自适应自旋:根据锁竞争情况动态调整自旋次数,避免无意义的忙等待。
  3. 偏向锁:当锁仅被单个线程访问时,通过标记线程 ID 避免锁竞争,进一步降低开销。

结果:synchronized的轻量化特性使其更适合高并发场景下的细粒度锁需求,而ReentrantLock的显式锁机制(如lock()/unlock())在代码复杂度和性能上不再具有明显优势。

二、数据结构调整:从分段锁(Segment)到节点锁

1.7 版本:分段锁(Segment)架构

  • 核心结构:由多个Segment(默认 16 个)组成,每个Segment是一个独立的哈希表,内部使用ReentrantLock保证线程安全。
  • 问题:
    • 锁粒度粗:虽然不同Segment可并发访问,但每个Segment对应一个较大的锁范围(如整个链表),锁竞争仍可能发生。
    • 空间浪费:每个Segment需要独立维护锁对象和哈希表结构,内存占用较高。

1.8 版本:数组 + 链表 / 红黑树 + 节点锁

  • 核心结构:取消Segment,直接使用数组存储节点,每个节点(链表头或红黑树根节点)通过synchronized加锁。
  • 优势:
    • 锁粒度更细:锁直接作用于链表头节点或红黑树根节点,不同节点的操作可并发执行,减少锁竞争。
    • 结构简化:无需维护独立的Segment对象,节省内存空间,且数据结构更紧凑。

三、减少内存开销与复杂度

  1. ReentrantLock的额外开销:
    • 每个Segment需要存储一个ReentrantLock对象,包含锁状态、等待队列等信息,增加内存占用。
    • 显式锁需要手动调用lock()unlock(),代码复杂度较高,且容易因遗漏unlock()导致死锁。
  2. synchronized的轻量化:
    • 作为 JVM 内置关键字,synchronized的锁信息直接存储在对象头(Object Header)中,无需额外对象,内存占用更低。
    • 锁的获取和释放由 JVM 自动管理,代码更简洁,减少人为失误风险。

四、结合 CAS 操作提升性能

在 1.8 版本中,ConcurrentHashMap 大量使用CAS(Compare-And-Swap)操作处理无竞争场景(如插入新节点时),仅在竞争发生时才切换至synchronized锁。这种 “非阻塞优先,阻塞兜底” 的策略进一步提升了并发性能:

  • 无竞争场景:通过 CAS 直接修改数据,无需加锁,性能接近普通 HashMap。
  • 低竞争场景:利用synchronized的优化特性(如偏向锁、自旋锁)快速获取锁,减少线程阻塞。
  • 高竞争场景:通过红黑树优化链表遍历效率,并结合锁粒度细化,降低锁冲突概率。

五、兼容性与未来优化

  • JVM 特性对齐:随着 JVM 对synchronized的持续优化(如 ZGC 等新垃圾回收器的支持),使用内置锁更便于后续性能调优。
  • 代码维护性:统一使用synchronized可减少锁类型混杂带来的维护成本,使代码逻辑更清晰。

总结:核心变化对比

维度1.7 版本(ReentrantLock)1.8 版本(synchronized)
锁粒度 分段锁(Segment 级别) 节点锁(链表头 / 红黑树根节点)
锁性能 依赖 ReentrantLock 的显式控制 利用 JVM 对 synchronized 的优化
数据结构 分段哈希表 数组 + 链表 / 红黑树
内存开销 每个 Segment 独立锁,空间占用高 对象头存储锁信息,空间更紧凑
并发策略 分段并发 + 显式锁 CAS 优先 + 轻量级锁 + 红黑树优化

结论:1.8 版本通过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 扩容核心流程简述:

  1. 触发扩容: 线程在 put 或 remove 后检查元素总数 (或链表长度条件),若超过阈值,调用 transfer()

  2. 初始化新数组: 第一个触发扩容的线程负责创建新数组 (通常是原数组的 2 倍)。

  3. 设置状态 (sizeCtl): 设置 sizeCtl 为一个很大的负数,表示扩容开始,并记录第一个线程信息或初始线程数。

  4. 分配迁移任务: 迁移工作被逻辑上划分为多个区间 (stride)。每个准备操作 table 的线程(无论是 put/remove 还是主动帮忙):

    • 检查当前是否有扩容在进行 (sizeCtl < 0)。

    • 计算自己应该负责迁移的桶区间范围 (通过 CAS 更新 transferIndex)。

    • 领取任务区间。

  5. 迁移桶 (核心):

    • 遍历自己负责的桶区间。

    • 对每个桶,加锁 (synchronized 锁住桶的头节点)。

    • 将桶中的节点迁移到新数组:

      • 链表:遍历旧链表,根据 (e.hash & oldCap) == 0 判断节点应该留在新数组的“低位”(原索引位置) 还是“高位”(原索引 + oldCap 位置)。使用尾插法构建新链表。

      • 红黑树:有专门的方法 TreeNode.split 处理树的迁移和可能的退化为链表。

    • 在原桶位置放置一个 ForwardingNode 占位符,标记此桶已迁移,并指向新数组。

  6. 协助与协作: 任何线程在执行操作时遇到 ForwardingNode,都会先尝试协助迁移(领取迁移任务)。迁移完一个桶后,该桶即可被新数据访问。

  7. 扩容完成: 当所有桶都被迁移完毕,最后一个完成迁移的线程:

    • 将 table 引用指向新数组。

    • 更新 sizeCtl 为新容量的 0.75 倍 (新的扩容阈值)。

    • 清理临时状态。


二、ConcurrentHashMap (1.8) 与 HashMap (1.8) 扩容区别

特性HashMap (JDK 1.8)ConcurrentHashMap (JDK 1.8)
线程安全性 不安全。并发扩容会导致数据丢失、死循环 (1.7 头插法)、结果不可预测。 安全。精心设计的并发控制机制保证扩容过程的线程安全。
并发扩容 不支持。只能由触发扩容的单一线程完成整个扩容和数据迁移。其他线程的操作被阻塞或导致错误。 支持多线程协同扩容。多个线程可以并发迁移不同桶区间的数据,大大加速扩容过程。
扩容期间访问 阻塞或不安全。扩容期间,整个 map 可能处于不一致状态,访问结果不可靠。 非阻塞访问。通过 ForwardingNode 机制,读写操作在扩容期间仍然可以进行,并能获得正确结果或协助迁移。
锁的使用 无显式锁。依赖内部状态和重新哈希。 使用 synchronized 锁住单个桶的头节点 进行迁移和写操作,结合大量的 CAS 操作更新状态和控制变量 (sizeCtltransferIndex)。
迁移触发点 只在 put 操作中检查并可能触发扩容。 在 put 和 remove 操作后都可能检查并触发扩容。
ForwardingNode 无。 核心机制。用于标记已迁移的桶,实现扩容期间的访问转发和协助迁移。
sizeCtl 无。使用 threshold 作为扩容阈值。 核心控制变量。不仅表示扩容阈值,还作为扩容状态标志 (负数) 和记录协助线程数。
迁移策略 (链表) 同样使用 尾插法 (解决 1.7 头插法死循环问题)。利用 (e.hash & oldCap) == 0 判断高低位。 完全相同。尾插法 + 高低位判断。
迁移策略 (树) 处理树的拆分和可能的退化。 处理树的拆分和可能的退化,但操作需要在锁保护下进行。
复杂度 相对简单,单线程操作。 极其复杂。需要处理多线程间的协调、状态同步、任务分配、无锁化探测等,代码量巨大。
首要目标 单线程性能。 高并发下的线程安全与性能。

关键区别总结:

  1. 线程安全与并发控制: 这是最根本的区别。HashMap 完全不考虑并发,其扩容在并发下会崩溃。ConcurrentHashMap 1.8 的扩容机制是其高并发能力的核心体现,通过 ForwardingNode、细粒度锁 (synchronized on bucket)、CAS 控制变量 (sizeCtltransferIndex) 以及多线程协同迁移,实现了扩容期间的高并发访问和高效扩容。

  2. 阻塞 vs 非阻塞/协作: HashMap 扩容会阻塞整个 Map (对用户感知可能是错误结果)。ConcurrentHashMap 1.8 扩容时,读操作几乎无影响,写操作可能会短暂阻塞 (等待桶锁) 或主动参与迁移,整体是非阻塞和协作式的。

  3. 单线程 vs 多线程迁移: HashMap 只能单线程迁移。ConcurrentHashMap 利用多线程并发迁移不同桶区间,显著提升大 Map 的扩容速度。

  4. 状态管理复杂度: ConcurrentHashMap 需要维护复杂的并发状态 (sizeCtl 的多重含义),而 HashMap 的状态管理相对简单直接。

结论:

    • JDK 1.8 的 ConcurrentHashMap 相对于 1.7 版本,在扩容机制上进行了革命性的改进,摒弃了分段锁,采用了类似 HashMap 1.8 的数组+链表/红黑树结构,但通过引入 ForwardingNode、多线程协同迁移、细粒度桶锁和精妙的 CAS 控制,实现了高并发下的高效、非阻塞式扩容,这是其卓越并发性能的关键所在。

    • ConcurrentHashMap 1.8 的扩容机制与 HashMap 1.8 在基础算法(尾插法、高低位判断)上相似,但其并发控制、状态管理、协作机制的复杂度和先进性远超 HashMap,这也是专门为线程安全和高并发场景设计的必然结果。HashMap 的扩容设计则专注于单线程下的简洁和效率。

 
 
 
 
posted @ 2025-05-26 18:00  飘来荡去evo  阅读(104)  评论(0)    收藏  举报