
ConcurrentHashMap核心原理系统性知识体系
一、整体概述
ConcurrentHashMap是Java集合框架中线程安全的哈希表实现,解决了HashMap线程不安全和Hashtable性能低下的问题。它通过细粒度的锁机制和无锁并发操作,在保证线程安全的同时,大幅提升了并发访问性能,是高并发场景下的首选哈希表实现。
二、JDK1.7 ConcurrentHashMap核心原理
2.1 数据结构:Segment数组+HashEntry数组+链表
ConcurrentHashMap
└── Segment<K,V>[] segments (分段锁数组)
└── HashEntry<K,V>[] table (每个Segment内部的哈希表)
└── HashEntry<K,V> (链表节点,key/value/next均为volatile)
- Segment:继承自ReentrantLock,既是锁对象又是哈希表容器
- HashEntry:链表节点,核心字段均为
volatile修饰,保证内存可见性 - 默认参数:初始容量16,并发级别16(Segment数组长度),负载因子0.75
2.2 线程安全实现:分段锁(Segment Lock)机制
- 核心思想:将整个哈希表分成16个独立的Segment,每个Segment独立加锁
- 锁粒度:Segment级别,不同Segment的读写操作互不影响
- 读操作:完全无锁(HashEntry的value和next为volatile)
- 写操作:只锁定当前操作所在的Segment,其他Segment不受影响
- 并发度:默认等于Segment数组长度(16),可通过构造函数指定
2.3 核心操作流程
put操作
- 计算key的hash值,定位到对应的Segment
- 尝试获取该Segment的锁(ReentrantLock)
- 再次计算hash值,定位到Segment内部table数组的索引
- 遍历该索引位置的链表,若key已存在则更新value
- 若key不存在,创建新的HashEntry节点插入链表头部
- 检查是否需要扩容,若需要则对该Segment的table进行扩容
- 释放锁
get操作
- 计算key的hash值,定位到对应的Segment
- 再次计算hash值,定位到Segment内部table数组的索引
- 遍历该索引位置的链表,通过equals方法找到对应的节点
- 返回节点的value值(volatile保证可见性)
2.4 扩容机制
- 触发条件:当某个Segment的元素数量超过阈值(table长度*负载因子)
- 扩容范围:只扩容当前Segment的table数组,其他Segment不受影响
- 扩容过程:
- 创建一个长度为原来2倍的新table数组
- 遍历旧table数组的每个元素,重新计算hash值并迁移到新table
- 迁移完成后,将Segment的table引用指向新数组
- 特点:分段扩容,避免了全表扩容的性能开销
三、JDK1.8+ ConcurrentHashMap核心原理
3.1 数据结构:Node数组+链表+红黑树
ConcurrentHashMap
└── Node<K,V>[] table (哈希表数组)
├── Node<K,V> (链表节点,key/value/next均为volatile)
└── TreeNode<K,V> (红黑树节点,当链表长度≥8时转换)
└── TreeBin<K,V> (红黑树容器,持有根节点和读写锁)
- 取消Segment:摒弃了分段锁设计,直接使用table数组作为锁对象
- 链表转红黑树:当链表长度≥8且table长度≥64时,链表转换为红黑树
- 核心节点:
- Node:普通链表节点
- TreeNode:红黑树节点
- TreeBin:红黑树容器,维护红黑树的根节点和读写锁
- ForwardingNode:扩容时的标记节点,指向新table数组
3.2 线程安全实现:CAS+synchronized
- 核心思想:使用CAS实现无锁并发操作,使用synchronized实现细粒度的同步
- 锁粒度:table数组的单个桶(bucket)级别,比Segment更细
- 读操作:完全无锁(Node的value和next为volatile)
- 写操作:
- 当桶为空时,使用CAS插入新节点
- 当桶不为空时,使用synchronized锁定桶的头节点
- 性能优势:
- 锁粒度更细,并发度更高
- synchronized在JDK1.6+得到了大幅优化(偏向锁、轻量级锁、重量级锁)
- 避免了ReentrantLock的额外开销
3.3 核心操作流程
put操作
- 计算key的hash值,定位到table数组的索引
- 如果table数组未初始化,使用CAS初始化table
- 如果该索引位置的桶为空,使用CAS插入新节点
- 如果该索引位置是ForwardingNode节点,说明正在扩容,协助扩容
- 否则,使用synchronized锁定桶的头节点
- 遍历链表或红黑树,若key已存在则更新value
- 若key不存在,创建新节点插入链表尾部或红黑树
- 检查链表长度是否≥8,若满足则转换为红黑树
- 检查元素总数是否超过阈值,若满足则进行全表扩容
- 释放锁
get操作
- 计算key的hash值,定位到table数组的索引
- 如果该索引位置的桶为空,返回null
- 如果该索引位置的节点是ForwardingNode,到新table中查找
- 否则,遍历链表或红黑树,通过equals方法找到对应的节点
- 返回节点的value值(volatile保证可见性)
3.4 扩容机制
- 触发条件:当元素总数超过阈值(table长度*负载因子)
- 扩容范围:全表扩容,table数组长度变为原来的2倍
- 核心创新:多线程协助扩容
- 扩容过程:
- 创建一个长度为原来2倍的新table数组
- 计算每个线程需要迁移的桶数量(默认16个)
- 每个线程从后往前迁移桶,使用CAS标记已迁移的桶
- 迁移过程中,将旧桶的头节点替换为ForwardingNode
- 当所有桶都迁移完成后,将table引用指向新数组
- 特点:
- 多线程协作,大幅提升扩容速度
- 迁移过程中,读操作可以正常进行
- 写操作会协助扩容,避免扩容阻塞
四、JDK1.7 vs 1.8+ 全面对比
| 对比维度 | JDK1.7 | JDK1.8+ |
|---|---|---|
| 数据结构 | Segment数组+HashEntry数组+链表 | Node数组+链表+红黑树 |
| 锁机制 | 分段锁(ReentrantLock) | CAS+synchronized |
| 锁粒度 | Segment级别 | 单个桶(bucket)级别 |
| 并发度 | 默认16(Segment数量) | 理论上等于table数组长度 |
| 链表插入方式 | 头插法 | 尾插法 |
| 扩容方式 | 单线程分段扩容 | 多线程全表协助扩容 |
| 查询复杂度 | O(n)(链表) | O(logn)(红黑树) |
| 死锁风险 | 无 | 无(尾插法避免了扩容时的死循环) |
| 性能 | 高并发下性能瓶颈明显 | 高并发下性能更优 |
五、关键技术细节
5.1 哈希算法
- JDK1.7:对key的hashCode进行多次异或和移位操作,减少哈希冲突
- JDK1.8+:简化了哈希算法,
hash = key.hashCode() ^ (key.hashCode() >>> 16)- 高16位与低16位异或,让高位也参与哈希计算
- 减少了哈希冲突,提高了计算效率
5.2 volatile的作用
- JDK1.7:HashEntry的value和next字段为volatile
- JDK1.8+:Node的value和next字段为volatile,table数组为volatile
- 作用:
- 保证内存可见性,一个线程修改的结果对其他线程立即可见
- 禁止指令重排序,避免出现空指针异常
5.3 红黑树转换条件
- 链表转红黑树:链表长度≥8且table长度≥64
- 红黑树转链表:红黑树节点数≤6
- 设计原因:
- 链表长度为8时,查询性能已经不如红黑树
- 红黑树节点数为6时,查询性能与链表相当,但占用更多内存
5.4 size()方法实现
- JDK1.7:先尝试不加锁统计所有Segment的count,若统计过程中发生变化,则加锁统计
- JDK1.8+:使用baseCount和counterCells数组统计元素数量
- 无竞争时,直接CAS更新baseCount
- 有竞争时,使用counterCells数组分散计数
- 最终size = baseCount + sum(counterCells)
六、常见面试考点与易错点
为什么JDK1.8摒弃了分段锁?
- 分段锁的并发度受限于Segment数量
- ReentrantLock的开销比优化后的synchronized更高
- 单个桶级别的锁粒度更细,并发度更高
ConcurrentHashMap是强一致性吗?
- 不是,是最终一致性
- get操作不需要加锁,可能读到其他线程正在修改的数据
- 但保证了单个操作的原子性(put、remove等)
ConcurrentHashMap支持null键和null值吗?
- 不支持,会抛出NullPointerException
- 原因:无法区分null是key不存在还是value本身为null
JDK1.8 ConcurrentHashMap扩容时如何保证线程安全?
- 使用ForwardingNode标记已迁移的桶
- 多线程通过CAS竞争迁移任务
- 迁移过程中,写操作会协助扩容
- 读操作可以正常进行,若遇到ForwardingNode则到新table中查找
ConcurrentHashMap和Hashtable的区别?
- 锁机制:Hashtable使用全局锁,ConcurrentHashMap使用细粒度锁
- 性能:ConcurrentHashMap并发性能远高于Hashtable
- 功能:ConcurrentHashMap支持更多并发工具方法(如putIfAbsent)
七、性能对比与使用场景
7.1 性能对比
- 低并发场景:两者性能差异不大
- 高并发场景:JDK1.8+ ConcurrentHashMap性能是JDK1.7的2-3倍
- 读多写少场景:性能接近HashMap
- 写多读少场景:性能优于Hashtable和Collections.synchronizedMap
7.2 使用场景
- 高并发环境下的缓存实现
- 多线程环境下的共享数据存储
- 分布式系统中的本地缓存
- 不适合需要强一致性的场景
ConcurrentHashMap面试版核心考点清单(可直接背诵)
一、基础认知必背
- 定位:Java线程安全哈希表,解决HashMap线程不安全、Hashtable全局锁性能低下问题
- 核心设计思想:细粒度锁+无锁并发,高并发场景首选
- 不支持null键和null值:无法区分"key不存在"和"value本身为null"
- 一致性模型:最终一致性,单个操作原子性,不保证跨操作事务性
二、JDK1.7核心考点
数据结构
- 三层结构:
Segment数组 + HashEntry数组 + 链表 - Segment:继承ReentrantLock,既是锁又是哈希表容器
- HashEntry:
key/value/next均为volatile,保证内存可见性 - 默认参数:初始容量16,并发级别16(Segment数组长度),负载因子0.75
线程安全:分段锁机制
- 锁粒度:Segment级别,不同Segment读写互不干扰
- 读操作:完全无锁(volatile保证可见性)
- 写操作:仅锁定当前操作的Segment
- 并发度上限:等于Segment数组长度(默认16,构造函数可指定)
核心操作
- put:头插法插入链表,仅扩容当前Segment
- get:无锁遍历链表,volatile保证最新值
- 扩容:单线程分段扩容,仅扩容触发条件的Segment,长度×2
三、JDK1.8+核心考点
数据结构
- 两层结构:
Node数组 + 链表 + 红黑树 - 彻底取消Segment,直接以table数组的桶为锁对象
- 特殊节点:
- TreeNode:红黑树节点
- TreeBin:红黑树容器,维护根节点和读写锁
- ForwardingNode:扩容标记节点,指向新table
- 转换条件:链表长度≥8 且 table长度≥64 → 转红黑树;节点数≤6 → 转回链表
线程安全:CAS+synchronized
- 锁粒度:单个桶(bucket)级别,比Segment更细
- 写操作逻辑:
- 桶为空:CAS无锁插入新节点
- 桶非空:synchronized锁定桶的头节点
- 性能优势:synchronized在JDK1.6+已优化(偏向→轻量→重量),开销低于ReentrantLock
核心操作
- put:尾插法插入,避免扩容死循环
- get:无锁遍历,遇到ForwardingNode则到新table查找
- 扩容:多线程全表协助扩容,长度×2
扩容机制(面试高频)
- 触发条件:元素总数 > 阈值(table长度×0.75)
- 核心创新:写线程协助扩容,避免单线程扩容阻塞
- 迁移过程:
- 每个线程认领16个桶,从后往前迁移
- 迁移完成的桶替换为ForwardingNode
- 读操作正常进行,写操作协助迁移
- 完成标志:所有桶迁移完毕,table引用指向新数组
四、JDK1.7 vs 1.8+ 核心对比(必背表格)
| 对比维度 | JDK1.7 | JDK1.8+ |
|---|---|---|
| 数据结构 | Segment+HashEntry+链表 | Node+链表+红黑树 |
| 锁机制 | 分段锁(ReentrantLock) | CAS+synchronized |
| 锁粒度 | Segment级别 | 单个桶级别 |
| 并发度 | 默认16 | 理论等于table长度 |
| 插入方式 | 头插法 | 尾插法 |
| 扩容 | 单线程分段扩容 | 多线程全表协助扩容 |
| 查询复杂度 | O(n) | O(logn)(红黑树) |
| 性能瓶颈 | 高并发下Segment数量限制 | 几乎无明显瓶颈 |
五、关键细节必背
- 哈希算法:
hash = key.hashCode() ^ (key.hashCode() >>> 16),高16位参与计算,减少冲突 - size()实现:
- 1.7:先无锁统计,变化则加锁统计
- 1.8+:
baseCount + counterCells数组,无竞争更新baseCount,有竞争分散到counterCells
- volatile作用:保证Node的value/next、table数组的内存可见性,禁止指令重排序
六、高频易错点
- ❌ ConcurrentHashMap是强一致性 → ✅ 最终一致性
- ❌ 支持null键/值 → ✅ 不支持,抛NullPointerException
- ❌ 1.8扩容会阻塞所有读写 → ✅ 读正常进行,写协助扩容
- ❌ 链表长度到8必转红黑树 → ✅ 需同时满足table长度≥64
3道高频面试题标准答案(可直接背诵)
面试题1:为什么JDK1.8摒弃了分段锁,改用CAS+synchronized?
标准答案:
- 锁粒度更细:分段锁的并发度受限于Segment数量(默认16),而单个桶级别的锁理论并发度等于table长度,大幅提升高并发性能
- synchronized性能优化:JDK1.6+对synchronized进行了锁升级优化(偏向锁→轻量级锁→重量级锁),在低竞争场景下性能优于ReentrantLock
- 减少内存开销:Segment继承自ReentrantLock,每个Segment都需要维护锁状态,取消Segment后节省了大量内存
- 红黑树引入:红黑树的查询复杂度为O(logn),弥补了链表查询性能的不足,使得细粒度锁的优势更加明显
- 多线程协助扩容:分段锁无法实现全表多线程扩容,而新的锁机制支持写线程协助扩容,解决了扩容时的性能瓶颈
面试题2:ConcurrentHashMap如何保证线程安全?
标准答案:
JDK1.7实现
- 分段锁机制:将哈希表分成多个独立的Segment,每个Segment独立加锁,不同Segment的操作互不影响
- volatile关键字:HashEntry的value和next字段用volatile修饰,保证读操作能看到最新的修改
- 不变性设计:HashEntry的key和hash是final的,只能在尾部插入新节点,避免了并发修改的问题
JDK1.8+实现
- CAS无锁操作:当桶为空时,使用CAS插入新节点,避免加锁开销
- synchronized细粒度锁:当桶不为空时,只锁定桶的头节点,其他桶的操作不受影响
- volatile关键字:Node的value、next和table数组用volatile修饰,保证内存可见性
- 扩容安全机制:使用ForwardingNode标记已迁移的桶,多线程通过CAS竞争迁移任务,保证扩容过程的线程安全
- 红黑树安全操作:TreeBin内部维护读写锁,保证红黑树并发操作的安全性
面试题3:JDK1.7和1.8的ConcurrentHashMap扩容机制有什么区别?
标准答案:
| 对比维度 | JDK1.7 | JDK1.8+ |
|---|---|---|
| 触发条件 | 单个Segment元素数超过阈值 | 全局元素总数超过阈值 |
| 扩容范围 | 仅扩容当前Segment的table | 全表扩容整个table数组 |
| 执行线程 | 单线程执行(触发扩容的线程) | 多线程协助(所有写线程) |
| 迁移过程 | 遍历旧table,逐个节点重新hash迁移 | 按高位hash值将桶拆分为两个,直接迁移整个桶 |
| 对读写的影响 | 扩容时该Segment的写操作阻塞 | 读操作正常进行,写操作协助扩容 |
| 性能 | 高并发下扩容易成为瓶颈 | 多线程协作,扩容速度提升数倍 |