ConcurrentHashMap介绍

一、为什么需要ConcurrentHashMap?

想要理解ConcurrentHashMap,首先要明确它的诞生背景,也就是现有Map集合在并发场景下的缺陷。

1.1 HashMap非线程安全,不适合并发场景

HashMap是日常开发中最常用的Map实现,底层基于数组+链表+红黑树实现,查询和插入效率极高,但它完全没有线程安全设计。在多线程并发读写、并发扩容的场景下,会引发一系列严重问题:

  • 数据覆盖:多个线程同时执行put操作,出现值覆盖,导致数据丢失

  • 链表成环:JDK7版本中,并发扩容会造成链表闭环,引发死循环,耗尽CPU资源

  • 扩容异常:扩容过程中数据迁移错乱,导致查询结果错误

因此,多线程环境下直接使用HashMap,无法保证数据的正确性和程序稳定性。

1.2 传统线程安全Map性能极差

早期Java提供了两种线程安全的Map实现,但都存在致命的性能短板,无法满足高并发需求。

Hashtable

Hashtable通过在所有公开方法上添加synchronized关键字,实现线程安全。这种方式属于全表锁,只要有一个线程执行读写操作,整个Map都会被锁定,其他线程必须阻塞等待,多线程下完全串行执行,并发能力几乎为零。

Collections.synchronizedMap

该工具方法是对普通Map的包装,底层同样使用synchronized锁,锁对象为整个Map实例,本质和Hashtable一致,锁粒度粗,并发性能低下,不适合高并发业务场景。

1.3 ConcurrentHashMap的设计目标

基于以上痛点,业界需要一款兼顾线程安全高并发性能的Map集合,ConcurrentHashMap就此诞生。它的核心设计目标:

  • 保证多线程环境下的数据一致性,杜绝线程安全问题

  • 大幅缩小锁粒度,提升并发读写效率

  • 读操作无锁,性能接近HashMap

  • 支持多线程协助扩容,减少阻塞耗时

二、ConcurrentHashMap设计演进:JDK7与JDK8对比

ConcurrentHashMap在JDK7和JDK8版本中,实现逻辑发生了颠覆性重构,JDK8优化了锁机制和数据结构,并发性能大幅提升,也是面试的核心考点。

2.1 JDK7版本:分段锁机制

底层数据结构

JDK7的ConcurrentHashMap采用Segment数组+HashEntry数组+链表的结构。

  • Segment:继承ReentrantLock,是一个独立的锁单元,默认容量16个,初始化后数量固定不可扩容

  • HashEntry:存储键值对的节点,内部包含key、value、next指针、hash值

并发控制逻辑

采用分段锁设计,线程操作数据时,先通过哈希定位到对应的Segment,仅对当前Segment加锁,其他Segment不受影响,可被其他线程并发访问。

  • 读操作:全程无锁,依靠volatile关键字保证数据可见性

  • 写操作:获取当前Segment的ReentrantLock锁,执行完操作后释放锁

核心缺陷
  • Segment数量固定,最大并发度受限

  • 锁粒度依旧偏大,单个Segment内操作串行执行

  • 仅支持链表结构,冲突严重时查询效率为O(n)

  • 扩容仅支持单线程执行,耗时较长

2.2 JDK8版本:彻底重构,性能飞跃

JDK8摒弃了Segment分段锁,重新设计了底层结构和锁机制,成为目前主流的实现方式。

底层数据结构

采用Node数组+链表+红黑树的结构,和JDK8 HashMap结构保持一致,但新增了特殊节点用于并发扩容。

  • Node:普通键值对节点,value和next指针用volatile修饰

  • TreeNode:红黑树节点,链表过长时转换

  • ForwardingNode:扩容标记节点,标记当前桶正在迁移数据

树化规则:链表长度≥8,且数组长度≥64时,链表转为红黑树,查询效率提升至O(logn);红黑树节点数≤6时,退化为链表,节省资源开销。

线程安全核心三要素

JDK8通过三种机制配合,实现细粒度线程安全,兼顾性能与可靠性。

  1. volatile关键字:修饰数组table、Node的value和next指针,保证多线程下数据可见性,禁止指令重排序,支撑无锁读操作

  2. CAS操作:无锁原子操作,用于数组初始化、头节点插入、计数器更新、扩容任务标记,无并发冲突时直接完成操作,避免加锁开销

  3. synchronized桶级锁:CAS操作失败时触发,仅锁定当前操作的哈希桶头节点,锁粒度极小,JVM对其深度优化,性能远超ReentrantLock

三、ConcurrentHashMap核心流程详解(JDK8)

3.1 put插入流程(核心逻辑)

put方法是ConcurrentHashMap的核心操作,全程遵循无锁优先、局部加锁的原则,步骤严谨清晰。

  1. 参数校验:严格禁止key和value为null,传入null值直接抛出NullPointerException

  2. 哈希计算:对key的hashCode进行二次哈希扰动,降低哈希冲突概率

  3. 桶定位:通过(n-1)&hash计算数组下标,定位目标哈希桶

  4. 空桶插入:若目标桶为空,通过CAS无锁插入新节点,操作成功则直接结束

  5. 扩容协助:若桶内为ForwardingNode节点,说明当前Map正在扩容,当前线程协助完成扩容

  6. 加锁写入:对当前桶头节点加synchronized锁,遍历链表或红黑树;key已存在则覆盖value,不存在则尾插新节点

  7. 树化判断:插入完成后,检查链表长度,达到阈值则执行树化或数组扩容

  8. 计数更新:更新元素总数,判断是否触发全局扩容

3.2 get查询流程(全程无锁)

get操作全程不加锁,依靠volatile保证读取到最新数据,效率和HashMap几乎一致。

  1. 计算key哈希值,定位对应哈希桶

  2. 检查桶头节点,若key匹配则直接返回value

  3. 若头节点不匹配,判断节点类型:红黑树则按树结构查询,链表则遍历查询

  4. 查询到匹配节点返回value,未查询到则返回null

3.3 扩容机制(多线程并发扩容)

扩容触发条件
  • 元素总数达到扩容阈值,阈值=数组容量×负载因子0.75

  • 链表长度≥8,但数组长度小于64,优先扩容数组,不执行树化

并发扩容流程

JDK8支持多线程协助扩容,大幅缩短扩容耗时,避免单线程阻塞。

  1. 触发扩容后,创建容量为原数组2倍的新数组

  2. 通过CAS操作标记transferIndex,为每个线程分配迁移任务

  3. 线程迁移完对应桶的数据后,将旧桶标记为ForwardingNode

  4. 其他线程访问到ForwardingNode节点,自动跳转至新数组,或协助迁移数据

  5. 所有桶迁移完成后,将table引用指向新数组,完成扩容

3.4 size统计流程

ConcurrentHashMap不会全局加锁统计元素数量,避免阻塞读写操作。

采用baseCount+CounterCell[]分段计数模式:无并发冲突时,直接更新baseCount;有冲突时,将计数分散到不同的CounterCell中,减少竞争。最终统计总数时,累加baseCount和所有CounterCell数值,得到近似准确的结果,兼顾性能与准确性。

相关推荐
JY.yuyu2 小时前
Java Web上架流程(Nginx反向代理+负载均衡 ,Apache配置,Maven安装打包,Tomcat配置)
java·开发语言·前端
Bert.Cai2 小时前
Python标识符详解
开发语言·python
lifewange2 小时前
insert
开发语言·python
逸Y 仙X2 小时前
文章十二:索引数据的写入和删除
java·大数据·spring boot·spring·elasticsearch·搜索引擎·全文检索
看山是山_Lau2 小时前
如何封装和定义一个函数
c语言·开发语言·c++·笔记
代码探秘者2 小时前
【算法篇】5.链表
java·数据结构·人工智能·python·算法·spring·链表
1104.北光c°2 小时前
Leetcode3.无重复字符的最长子串 HashSet+HashMap 【hot100算法个人笔记】【java写法】
java·开发语言·笔记·程序人生·算法·leetcode·滑动窗口
Binary-Jeff2 小时前
Maven 依赖作用域详解:compile、provided、runtime、test
java·spring·spring cloud·servlet·java-ee·maven
QH_ShareHub2 小时前
Rstudio 与 R 打开 Rdata (压缩文件) 差异
java·前端·r语言