ConcurrentHashMap 源码解析(JDK8)高并发哈希表的终极实现
哈喽,各位程序员伙伴~👋
春节已过,继续收心学习。我们继续把 Java 并发底层啃透!
上一篇我们彻底讲清了 ThreadLocal 线程本地存储与内存泄漏。今天我们进入 JUC 最核心、面试最高频的组件:
ConcurrentHashMap(JDK8 版)
它是高并发场景下必用的线程安全 Map,从 JDK7 的分段锁,到 JDK8 的 CAS + synchronized 锁头节点,整个设计堪称并发工程的教科书。
一、先一句话抓住核心(JDK7 vs JDK8)
-
JDK7
-
结构:Segment 分段锁 + 数组 + 链表 -
锁粒度:Segment(粗) -
并发度:等于 Segment 个数 -
JDK8
-
结构:数组 + 链表 / 红黑树 -
锁:CAS + synchronized 锁头节点 -
锁粒度:桶级别(最小化) -
并发能力:大幅提升
一句话总结:JDK8 抛弃分段锁,用更细粒度的锁 + 无锁 CAS,实现真正高并发。
二、JDK8 ConcurrentHashMap 核心结构
public class ConcurrentHashMap<K,V> {// 哈希表数组transient volatile Node<K,V>[] table;// 链表转红黑树阈值static final int TREEIFY_THRESHOLD = 8;// 红黑树退化为链表阈值static final int UNTREEIFY_THRESHOLD = 6;// 树化的最小容量static final int MIN_TREEIFY_CAPACITY = 64;// 控制标识:初始化、扩容、计数transient volatile int sizeCtl;}
三、核心 Node 节点(JDK8 真实结构)
static class Node<K,V> implements Map.Entry<K,V> {final int hash;final K key;volatile V val;volatile Node<K,V> next;// ...}
关键点:
table
是 volatile,保证多线程可见。 sizeCtl
是整个 ConcurrentHashMap 的 “指挥中心”。
四、核心流程:put 方法源码(最精简准确版)
final V putVal(K key, V value, boolean onlyIfAbsent) {if (key == null || value == null)throw new NullPointerException();int hash = spread(key.hashCode());int binCount = 0;for (Node<K,V>[] tab = table;;) {Node<K,V> f; int n, i, fh;// 1. 数组为空 → 初始化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<>(hash, key, value, null)))break;}// 3. 头节点 hash == MOVED → 正在扩容,帮助扩容else if ((fh = f.hash) == MOVED)tab = helpTransfer(tab, f);// 4. 正常哈希冲突 → 锁头节点else {V oldVal = null;synchronized (f) { // 只锁当前桶的头节点!if (tabAt(tab, i) == f) {if (fh >= 0) {// 链表插入} else {// 红黑树插入}}}}}addCount(1L, binCount);return oldVal;}
put 真正逻辑(背会就能吊打面试)
- key/value 都不能为 null
-
计算 hash(spread 做二次扰动,减少碰撞) -
数组未初始化 → 用 sizeCtl控制初始化 -
目标桶为空 → CAS 无锁插入 -
桶头是 FORWARD节点 → 当前正在扩容,线程协助迁移 -
否则:synchronized 锁住头节点,再插入 -
链表长度 ≥8 且数组长度≥64 → 转为红黑树 -
最后计数,并判断是否需要扩容
五、为什么 JDK8 这么强?
-
锁粒度极小
只锁当前桶的头节点,不同桶完全并行。
-
无冲突 = 无锁
大部分写操作直接 CAS 完成,性能接近 HashMap。
-
扩容多线程协同
迁移过程分段、分桶,线程之间互不抢锁。
-
链表 / 红黑树自动切换
最坏时间复杂度从 O (n) → O (logn),防哈希攻击。
六、必须搞懂的关键细节(面试高频)
1. 为什么 key 和 value 不能为 null?
-
HashMap 允许 key/value 为 null -
ConcurrentHashMap 都不允许
原因:高并发场景下,get(null) 无法区分是不存在还是值为 null,会带来歧义。为了并发安全,直接禁止 null。
2. get 方法真・完全无锁
-
全程不加锁 -
靠 volatile Node[] table -
靠 volatile Node.val -
靠 tabAt原子读取
只要写入用 volatile 保证可见,读取就可以安全无锁。
3. sizeCtl 的 4 种含义(超级高频)
sizeCtl = -1:正在初始化sizeCtl < -1:正在扩容sizeCtl = 0:未初始化sizeCtl > 0:下一次扩容的阈值(容量 × 0.75)
它是ConcurrentHashMap 所有并发控制的 “总开关”。
4. 为什么链表长度 ≥8 才树化?
-
理想哈希下,链表长度达到 8 的概率极低(泊松分布) -
达到 8 基本意味着:哈希攻击 或 极差 hashCode -
树化是为了保证最坏情况下的性能:O(logn)
5. 树化必须满足两个条件
-
链表长度 ≥ 8 - 数组长度 ≥ 64
不满足 64 时,优先扩容而不是树化,目的是减少冲突。
6. 扩容机制(JDK8 灵魂)
-
扩容条件:元素数量 ≥ sizeCtl -
新容量 = 旧容量 × 2 -
迁移方式:按桶分配,多线程协同迁移 -
迁移中:头节点标记为 FORWARD,其他读写线程会协助迁移 -
迁移完成才切换到新 table
这是 JDK8 ConcurrentHashMap 高并发的核心。
7. size () 是弱一致性
size()不是实时精确值-
高并发下不加全局锁统计,而是累加计数单元 -
追求并发性能 > 绝对实时准确 -
正式推荐使用: mappingCount()(返回 long)
七、JDK7 vs JDK8 一张表总结
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
八、总结(可直接背)
- JDK8 ConcurrentHashMap 抛弃分段锁,采用数组 + 链表 + 红黑树。
- 读完全无锁,写无冲突用 CAS,有冲突只锁桶头。
- 扩容多线程协同,性能极强。
- key/value 不能为 null,size 弱一致,get 完全无锁。
-
高并发场景下:必用 CHM,绝不用 HashMap、Hashtable。
下一篇预告
BlockingQueue 全家桶源码解析
ArrayBlockingQueue / LinkedBlockingQueue / SynchronousQueue线程池的 “血脉”,面试必问。
夜雨聆风
