一、为什么需要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通过三种机制配合,实现细粒度线程安全,兼顾性能与可靠性。
-
volatile关键字:修饰数组table、Node的value和next指针,保证多线程下数据可见性,禁止指令重排序,支撑无锁读操作
-
CAS操作:无锁原子操作,用于数组初始化、头节点插入、计数器更新、扩容任务标记,无并发冲突时直接完成操作,避免加锁开销
-
synchronized桶级锁:CAS操作失败时触发,仅锁定当前操作的哈希桶头节点,锁粒度极小,JVM对其深度优化,性能远超ReentrantLock
三、ConcurrentHashMap核心流程详解(JDK8)
3.1 put插入流程(核心逻辑)
put方法是ConcurrentHashMap的核心操作,全程遵循无锁优先、局部加锁的原则,步骤严谨清晰。
-
参数校验:严格禁止key和value为null,传入null值直接抛出NullPointerException
-
哈希计算:对key的hashCode进行二次哈希扰动,降低哈希冲突概率
-
桶定位:通过(n-1)&hash计算数组下标,定位目标哈希桶
-
空桶插入:若目标桶为空,通过CAS无锁插入新节点,操作成功则直接结束
-
扩容协助:若桶内为ForwardingNode节点,说明当前Map正在扩容,当前线程协助完成扩容
-
加锁写入:对当前桶头节点加synchronized锁,遍历链表或红黑树;key已存在则覆盖value,不存在则尾插新节点
-
树化判断:插入完成后,检查链表长度,达到阈值则执行树化或数组扩容
-
计数更新:更新元素总数,判断是否触发全局扩容
3.2 get查询流程(全程无锁)
get操作全程不加锁,依靠volatile保证读取到最新数据,效率和HashMap几乎一致。
-
计算key哈希值,定位对应哈希桶
-
检查桶头节点,若key匹配则直接返回value
-
若头节点不匹配,判断节点类型:红黑树则按树结构查询,链表则遍历查询
-
查询到匹配节点返回value,未查询到则返回null
3.3 扩容机制(多线程并发扩容)
扩容触发条件
-
元素总数达到扩容阈值,阈值=数组容量×负载因子0.75
-
链表长度≥8,但数组长度小于64,优先扩容数组,不执行树化
并发扩容流程
JDK8支持多线程协助扩容,大幅缩短扩容耗时,避免单线程阻塞。
-
触发扩容后,创建容量为原数组2倍的新数组
-
通过CAS操作标记transferIndex,为每个线程分配迁移任务
-
线程迁移完对应桶的数据后,将旧桶标记为ForwardingNode
-
其他线程访问到ForwardingNode节点,自动跳转至新数组,或协助迁移数据
-
所有桶迁移完成后,将table引用指向新数组,完成扩容
3.4 size统计流程
ConcurrentHashMap不会全局加锁统计元素数量,避免阻塞读写操作。
采用baseCount+CounterCell[]分段计数模式:无并发冲突时,直接更新baseCount;有冲突时,将计数分散到不同的CounterCell中,减少竞争。最终统计总数时,累加baseCount和所有CounterCell数值,得到近似准确的结果,兼顾性能与准确性。