ConcurrentHashMap 源码解析(JDK8):CAS+Synchronized 双剑合璧,高并发哈希表的最优解
哈喽,各位程序员伙伴~ 👋
上一篇我们吃透了 ConcurrentLinkedQueue 的无锁 CAS 设计,搞定了高并发非阻塞队列的核心实现,而在高并发容器中,哈希表才是业务开发的「高频主力军」—— 缓存存储、数据索引、配置映射,几乎所有系统的核心数据存储都离不开哈希表。
但传统的 HashMap 非线程安全,高并发下会出现死循环、数据错乱;Hashtable 全表加锁的设计又会导致性能暴跌;JDK7 的 ConcurrentHashMap 基于分段锁实现,虽有优化但仍存在锁粒度较粗、并发度受限的问题。
JDK8 对 ConcurrentHashMap 做了里程碑式的重构:摒弃分段锁,采用 CAS 原子操作 + 局部 Synchronized 锁 + 红黑树 的组合设计,将锁粒度缩小到单个桶节点,结合 CAS 实现无锁更新,在保证线程安全的前提下,把哈希表的并发性能推到了新高度。它是高并发场景下哈希表的最优解,也是后端面试中「并发容器」模块的必考核心。
这篇文章,我们紧扣「按需聚焦、精准精炼 + 个性化感悟」的要求,摒弃冗余赘述,从核心设计重构到底层源码精读,从并发安全实现到实战场景选型,直击 ConcurrentHashMap(JDK8)的核心要点,同时融入实战开发的感悟,让你吃透设计精髓,落地到实际业务中。
一、核心重构:JDK7 vs JDK8,为何要彻底改头换面?
要理解 JDK8 的设计精髓,首先要明确为何摒弃 JDK7 的分段锁—— 核心是解决「并发度受限、哈希冲突后性能下降」的痛点,先通过一张表直击两代版本的核心差异,精准把握重构思路:
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
个性化感悟
JDK8 的重构,本质是 **「用更精细的并发控制,适配更极致的高并发场景」—— 分段锁看似实现了「锁分离」,但本质还是「批量加锁」,当一个分段内的哈希冲突增多,所有操作仍会互斥;而 JDK8 把锁粒度压到最小的桶节点,再结合 CAS 实现无锁更新,让「大部分操作无锁,少数操作局部加锁」,这才是真正的细粒度并发 **。同时,链表转红黑树的设计,解决了哈希表的「性能天花板」问题,让哈希表在高冲突场景下仍能保持高效。
二、核心设计:3 大核心技术,撑起高并发线程安全
JDK8 ConcurrentHashMap 的核心设计,浓缩为3 大技术点,三者相互配合,既保证线程安全,又实现极致性能,这是整个容器的设计基石,也是源码解析的核心脉络:
1. CAS 原子操作:无锁更新,规避轻量竞争
针对桶节点初始化、扩容标记、值更新等无冲突 / 轻冲突场景,采用 CAS 原子操作实现无锁更新,核心调用 Unsafe 类的 compareAndSwapXxx 方法,保证操作原子性,无需加锁,避免锁竞争的开销。
-
适用场景:桶节点为空时的初始化、扩容时的节点迁移标记、非头节点的轻量更新; -
核心价值:让大部分无竞争的操作,全程无锁,这是高并发下性能的核心保障。
2. 局部 Synchronized 锁:粗锁细用,解决重度竞争
针对桶节点非空、哈希冲突、链表 / 红黑树修改等重度竞争场景,对单个桶的头节点加 Synchronized 锁,锁粒度仅为一个桶节点,而非整个数组或分段,保证同一桶内的操作互斥,不同桶的操作完全并行。
✨ 关键设计巧思
很多人疑惑「为何用 Synchronized 而非 ReentrantLock?」——JDK8 对 Synchronized 做了偏向锁、轻量级锁、重量级锁的自适应优化,在细粒度、短持有锁的场景下,开销极低。ConcurrentHashMap 选用 Synchronized,核心是适配自身桶级细粒度锁的场景,同时代码更简洁、无需手动释放锁;ReentrantLock 的可中断、公平锁等高级特性,在该场景并非必需。
3. 链表转红黑树:突破性能瓶颈,适配高冲突场景
当单个桶的链表节点数≥8 时,优先判断数组长度—— 若数组长度 < 64,直接触发数组扩容,不树化;仅当数组长度≥64 且链表节点数≥8 时,才将链表转为红黑树;当节点数≤6 时,红黑树退化回链表,核心是「先扩容,后树化」,避免小数组下的无意义树化。
-
链表:适合低冲突场景,节点增删快,维护开销小; -
红黑树:适合高冲突场景,查改删的时间复杂度从 O (n) 优化为 O (log n),突破链表的性能瓶颈。
✨ 个性化感悟
这个设计完美体现了 **「数据结构的动态适配」—— 哈希表的性能核心是「低哈希冲突」,但实际业务中无法完全避免,JDK8 没有强行追求「完美哈希」,而是通过「链表 + 红黑树」的动态切换,让哈希表在任何冲突场景下都能保持最优性能 **,这是工程化设计的智慧。
三、核心结构:源码核心属性(精准拆解,无冗余)
ConcurrentHashMap(JDK8)的核心结构比 JDK7 更简洁,核心属性仅围绕「数组、哈希、扩容、树化」展开,无分段锁相关的冗余属性,基于 JDK8 源码精简拆解,直击核心:
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>implements ConcurrentMap<K,V>, Serializable {// 1. 底层数组:volatile 修饰,保证数组引用可见性,存储桶节点transient volatile Node<K,V>[] table;// 2. 扩容时的临时数组:volatile 修饰,扩容完成后替换 tableprivate transient volatile Node<K,V>[] nextTable;// 3. 扩容控制标记:volatile 修饰,负数表示正在扩容,正数表示下次扩容的阈值private transient volatile int sizeCtl;// 4. 链表转红黑树的阈值:桶节点数 >=8 时触发树化static final int TREEIFY_THRESHOLD = 8;// 5. 红黑树转链表的阈值:桶节点数 <=6 时触发退化static final int UNTREEIFY_THRESHOLD = 6;// 6. 树化的最小数组长度:数组长度 <64 时,仅扩容不树化static final int MIN_TREEIFY_CAPACITY = 64;// 7. 核心 CAS 方法:封装 Unsafe 的 CAS 操作,用于无锁更新private final native boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v);}// 基础桶节点类:单向链表,volatile 修饰属性,保证可见性static class Node<K,V> implements Map.Entry<K,V> {final int hash;final K key;volatile V val;volatile Node<K,V> next;}// 红黑树节点类:继承 Node,实现树节点接口static final class TreeNode<K,V> extends Node<K,V> {TreeNode<K,V> parent, left, right;TreeNode<K,V> prev;boolean red;}
核心属性总结(3 句话吃透)
table
是底层核心数组, nextTable是扩容临时数组,二者均为 volatile 修饰,保证多线程下的可见性;sizeCtl
是核心控制变量,一人身兼数职:未初始化时表示初始容量,初始化后表示扩容阈值,扩容时表示扩容标记; -
三个树化 / 扩容阈值相互配合,保证「先扩容,后树化」,避免数组过小导致的频繁树化 / 退化。
四、核心操作:2 大核心方法(源码精读 + 流程拆解,按需聚焦)
ConcurrentHashMap 的核心操作是put (K,V) 存值和get (K,V) 取值,这两个方法浓缩了「CAS 无锁、Synchronized 局部锁、链表 / 红黑树切换、扩容协作」的所有设计精髓,我们按需聚焦核心流程,精简拆解源码,摒弃冗余细节,直击实现本质。
1. 存值操作:put (K,V)——CAS 无锁初始化 + Synchronized 局部锁修改 + 动态树化 / 扩容
put 方法是 ConcurrentHashMap 并发安全的核心体现,全程遵循「无锁优先,加锁兜底;动态适配,按需扩容 / 树化」的原则,核心流程拆解为 5 步核心步骤,源码为 JDK8 精简版,精准对应流程:
public V put(K key, V value) {return putVal(key, value, false);}final V putVal(K key, V value, boolean onlyIfAbsent) {if (key == null || value == null) throw new NullPointerException(); // 禁止null键值int hash = spread(key.hashCode()); // 哈希值重计算,减少冲突int binCount = 0;for (Node<K,V>[] tab = table;;) { // 自旋,保证操作成功Node<K,V> f; int n, i, fh;// 步骤1:数组未初始化,CAS 无锁初始化数组if (tab == null || (n = tab.length) == 0)tab = initTable();// 步骤2:桶节点为空,CAS 无锁创建新节点,放入桶中else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))break;}// 步骤3:当前桶正在扩容,协助扩容,再自旋重试else if ((fh = f.hash) == MOVED)tab = helpTransfer(tab, f);// 步骤4:桶节点非空,加 Synchronized 锁(锁头节点),执行修改else {V oldVal = null;synchronized (f) { // 锁单个桶的头节点,粒度最小if (tabAt(tab, i) == f) { // 二次校验,避免锁期间数组被修改if (fh >= 0) { // 链表节点binCount = 1;for (Node<K,V> e = f;; ++binCount) {K ek;// 键存在,更新值if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) {oldVal = e.val;if (!onlyIfAbsent) e.val = value;break;}// 键不存在,尾插新节点Node<K,V> pred = e;if ((e = e.next) == null) {pred.next = new Node<K,V>(hash, key, value, null);break;}}}else if (f instanceof TreeBin) { // 红黑树节点Node<K,V> p;binCount = 2;// 红黑树中插入/更新节点if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {oldVal = p.val;if (!onlyIfAbsent) p.val = value;}}}}// 步骤5:根据节点数,判断是否树化/扩容if (binCount != 0) {if (binCount >= TREEIFY_THRESHOLD) // 节点数>=8,树化/扩容treeifyBin(tab, i);if (oldVal != null)return oldVal;break;}}}// 统计元素个数,判断是否触发扩容addCount(1L, binCount);return null;}
核心流程总结(精准精炼)
- 无锁初始化
数组未初始化时,CAS 保证只有一个线程完成初始化,其他线程自旋等待; - 无锁放节点
桶为空时,CAS 无锁创建节点,避免轻量竞争下的加锁开销; - 协助扩容
遇到扩容中的桶,主动协助扩容,提升扩容效率,而非阻塞等待; - 局部加锁修改
桶非空时,对头节点加 Synchronized 锁,保证同一桶内操作互斥,不同桶并行; - 动态适配
修改完成后,根据节点数执行先扩容,后树化的逻辑。节点数≥8 时,先检查数组长度,不足 64 则仅扩容,足够 64 则树化,保证哈希表在合理的数组规模下才做树化操作。
2. 取值操作:get (K,V)—— 全程无锁,极致高效
get 方法是 ConcurrentHashMap 读性能的核心体现,全程无锁、无同步、无阻塞,仅通过 volatile 修饰的属性保证可见性,时间复杂度为 O (1)(链表)/O (log n)(红黑树),和 HashMap 的读操作性能一致,核心源码 + 流程拆解:
public V get(Object key) {Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;int hash = spread(key.hashCode()); // 重计算哈希值// 步骤1:数组非空,且桶节点非空if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & hash)) != null) {// 步骤2:桶头节点就是目标节点,直接返回值if ((eh = e.hash) == hash) {if ((ek = e.key) == key || (ek != null && key.equals(ek)))return e.val;}// 步骤3:红黑树节点,树中查找else if (eh < 0)return (p = e.find(hash, key)) != null ? p.val : null;// 步骤4:链表节点,遍历查找while ((e = e.next) != null) {if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek))))return e.val;}}// 未找到,返回nullreturn null;}
核心设计亮点(无锁的本质)
- 全程无锁
无 Synchronized,无 Lock,直接通过数组下标访问桶节点,避免锁竞争开销; - volatile 保证可见性
桶节点的 val和next均为 volatile 修饰,一个线程修改后,其他线程能立即感知,保证读操作能获取到最新值; - 无二次校验
读操作无需校验数组是否被修改,因为所有写操作都不会直接修改原节点,而是创建新节点 / 替换节点引用,读操作始终能读到完整的节点数据,不会出现数据错乱。
✨ 个性化感悟
ConcurrentHashMap 的「读无锁,写局部锁」设计,完美契合了高并发场景下「读多写少」的业务特征—— 实际开发中,哈希表的读操作占比往往超过 90%,让读操作全程无锁,用极小的写操作加锁代价,换取读操作的极致性能,这是 **「性能取舍」的经典体现 **,也是工程化设计中「抓主要矛盾」的智慧。
五、核心亮点:扩容机制 —— 多线程协助扩容,无锁 CAS 迁移(按需聚焦)
扩容是哈希表的性能瓶颈,而 JDK8 ConcurrentHashMap 的扩容机制做了极致优化——多线程协助扩容,无锁 CAS 迁移节点,彻底解决了单线程扩容的阻塞问题,核心亮点拆解为 3 点,精准把握精髓:
- 扩容触发
扩容触发有两大场景。① 全局元素个数超过 sizeCtl(扩容阈值),触发整体扩容;② 单个桶链表节点数≥8 但数组长度 < 64 时,触发扩容(「先扩容,后树化」的前置操作),数组长度翻倍后再判断是否树化。 - 多线程协助
当线程发现当前桶正在扩容时,不会阻塞,而是主动加入扩容,通过 CAS 标记扩容区间,多线程并行迁移节点,提升扩容效率; - 无锁 CAS 迁移
迁移节点时,通过 CAS 原子操作将节点从原数组迁移到新数组,无需加锁,保证迁移的原子性; - 扩容标记
通过 sizeCtl和节点的hash=MOVED标记,标识数组正在扩容,让其他线程能快速感知并协助。
✨ 个性化感悟
JDK8 的扩容机制,把 **「并发协作」的思想发挥到了极致 —— 传统哈希表的扩容是「单线程独占」,高并发下会导致所有操作阻塞;而 ConcurrentHashMap 让「所有线程都成为扩容的参与者,而非等待者」,通过无锁 CAS 实现节点迁移的并行化,这是高并发设计中「化阻塞为协作」的经典案例 **。
六、场景化延伸 + 实战指引(核心落地,无空话)
一、场景化延伸:ConcurrentHashMap 的设计思想,能迁移到哪些业务场景?
ConcurrentHashMap 的 **「CAS 无锁优先 + 局部加锁兜底」「细粒度并发」「动态结构适配」三大设计思想,并非仅适用于哈希表,还能迁移到高并发业务开发的多个场景 **,实现性能优化:
- 高并发缓存设计
本地缓存的更新操作,可采用「CAS 无锁更新轻量数据,Synchronized 局部锁更新重度数据」,避免全缓存加锁; - 高并发计数器
简单计数器用 AtomicInteger(CAS 实现),复杂计数器用「分段 CAS 计数器」(类似 JDK7 的分段锁,细粒度并发); - 高并发列表更新
对列表的不同位置做「局部加锁」,而非全列表加锁,让不同位置的更新操作并行; - 动态数据结构设计
业务中的数据存储结构,可根据数据量动态切换(如「数组 + 链表」切换为「红黑树」),保证任何数据量下的最优性能。
二、实战指引:ConcurrentHashMap 的精准选型 + 避坑技巧(落地可执行)
✅ 最优适用场景(精准匹配)
- 高并发读多写少场景
如系统配置缓存、用户信息缓存、热点数据索引,读操作占比高,写操作少,能最大化发挥「读无锁」的性能优势; - 高并发多线程操作场景
如分布式系统的本地缓存、多线程任务的结果存储、高并发接口的参数映射,需要线程安全且高并发性能; - 哈希冲突不可避免的场景
如海量数据的存储、高基数键的映射,链表转红黑树的设计能保证高冲突下的查询性能; - 需要高效扩容的场景
如数据量动态变化的场景,多线程协助扩容能避免扩容导致的性能瓶颈。
❌ 实战避坑技巧(3 个高频坑,落地可规避)
- 坑点 1:存储 null 键 / 值
——ConcurrentHashMap 禁止 null 键和 null 值,会直接抛出空指针异常,解决方案:入参前做非空校验,用空对象(Optional)替代 null; - 坑点 2:频繁扩容导致性能下降
——解决方案:初始化时根据业务数据量指定合适的初始容量(如预计存储 100 万条数据,初始容量设为 100 万 / 0.75=133 万,避免频繁扩容); - 坑点 3:误用为单线程场景
—— 单线程场景下,ConcurrentHashMap 的性能略低于 HashMap(CAS 和 volatile 有微小开销),解决方案:单线程用 HashMap,多线程用 ConcurrentHashMap,按需选择; - 坑点 4:依赖 size () 方法的绝对精确值
size() 底层直接调用 mappingCount(),返回long 类型 的近似计数值。无锁累加各桶节点数,统计过程中不阻塞写入,高并发下可能存在微小误差。该值并非 “完全不准确”,但无法保证实时绝对精确,适合监控、日志打点、流量统计等非精确计数场景,绝对禁用在依赖精确计数的业务逻辑中(如库存扣减、订单计数等)。解决方案:若业务需要绝对精确的计数,禁止使用 size()/mappingCount(),需在业务层通过 AtomicLong 等原子计数器自定义计数(put 时自增,remove 时自减),牺牲少量性能换取计数的绝对精确。🛠 实战优化技巧(2 个可执行技巧,提升性能)
- 指定初始容量
根据业务预计数据量,指定初始容量为「预计数据量 / 负载因子(0.75)」,避免频繁扩容,这是最有效的性能优化手段; - 减少哈希冲突
重写键的 hashCode()方法,保证哈希值的均匀分布,减少桶节点的冲突,避免链表过长或树化,提升查改性能; - 避免长时间持有锁
业务中对 ConcurrentHashMap 的写操作,尽量保证「短平快」,避免在写操作中执行耗时逻辑,减少 Synchronized 锁的持有时间,提升并发度。
下一篇预告
这篇我们吃透了 ConcurrentHashMap(JDK8)的CAS+Synchronized 核心设计、无锁读 / 局部锁写的实现,以及多线程协助扩容的精髓,搞定了高并发哈希表的核心实现。
下一篇,我们将进入Java 并发工具类的领域,拆解 《CountDownLatch & CyclicBarrier 源码解析:并发同步的两大神器,精准把控多线程执行节奏》—— 彻底搞懂这两个高频并发工具的核心原理、区别与实战落地,让你在多线程开发中精准把控线程执行节奏!
夜雨聆风
