一、前言
在上一篇文章中,我们掌握了 JDK1.8 ConcurrentHashMap 的核心锁机制 ------CAS 无锁操作与桶级 synchronized 锁的协同逻辑。而这些机制最终都落地到 put 和 get 这两个核心方法中,它们的执行流程直接决定了 ConcurrentHashMap 的并发安全与存取效率。
本文将基于 JDK1.8 的 ConcurrentHashMap 源码,逐行拆解 put 和 get 方法的完整执行链路,厘清每一步的并发安全保障细节,带你从代码层面吃透高并发 Map 的存取逻辑。
二、ConcurrentHashMap 核心成员变量
在分析源码前,先明确几个核心成员变量的作用,这是理解方法逻辑的基础:
java
// 哈希桶数组,存储键值对的核心容器
transient volatile Node<K,V>[] table;
// 扩容时的临时新数组
private transient volatile Node<K,V>[] nextTable;
// 控制数组初始化和扩容的核心标识,不同取值对应不同状态
// -1:数组初始化中;负数且≠-1:扩容中(值为resizeStamp<<16 + 线程数);正数:扩容阈值;0:默认初始容量
private transient volatile int sizeCtl;
// 链表转红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;
// 红黑树转回链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
// 触发树化的最小数组容量(避免小容量数组过度树化)
static final int MIN_TREEIFY_CAPACITY = 64;
// 节点hash值标识:MOVED=-1(扩容中转节点)、TREEBIN=-2(红黑树根节点容器)
static final int MOVED = -1;
static final int TREEBIN = -2;
三、put 方法:并发安全的元素插入流程
ConcurrentHashMap 的 put 方法对外暴露,实际核心逻辑在 putVal 方法中实现,调用链路为 put(K key, V value) → putVal(key, value, false) 。以下先展示完整源码,再逐段拆解。
1. put 与 putVal 方法完整源码
java
// 对外暴露的put方法,不允许key/value为null
public V put(K key, V value) {
return putVal(key, value, false);
}
/**
* 内部核心插入方法
* @param key 插入的键(不可为null)
* @param value 插入的值(不可为null)
* @param onlyIfAbsent 为true时,key存在则不覆盖旧值
* @return 旧值(key存在时)或null(key不存在时)
*/
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 校验key和value,不允许为null,否则抛NPE
if (key == null || value == null) throw new NullPointerException();
// 计算key的哈希值(扰动处理,与HashMap一致)
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<K,V>(hash, key, value, null)))
break; // CAS成功,直接退出自旋
}
// 场景3:目标桶是扩容中转节点(MOVED),协助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 场景4:目标桶非空且非扩容状态,加锁处理
else {
V oldVal = null;
// 对桶首节点加synchronized锁,保证桶内操作原子性
synchronized (f) {
// 二次校验桶首节点,防止加锁期间被修改
if (tabAt(tab, i) == f) {
// 子场景4.1:桶内是普通链表结构
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 找到key相同的节点,处理旧值覆盖
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.value;
if (!onlyIfAbsent)
e.value = value;
break;
}
Node<K,V> pred = e;
// 遍历到链表尾部,尾插法插入新节点
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value, null);
break;
}
}
}
// 子场景4.2:桶内是红黑树结构
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
// 调用红黑树插入方法
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
oldVal = p.value;
if (!onlyIfAbsent)
p.value = value;
}
}
}
}
// 插入完成后,判断是否需要将链表转为红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i); // 树化(会先判断数组容量)
if (oldVal != null)
return oldVal;
break;
}
}
}
// 插入新节点后,更新元素数量并检查是否需要扩容
addCount(1L, binCount);
return null;
}
2. putVal 方法逐步骤拆解
步骤 1:null 值校验与哈希计算
ConcurrentHashMap 严格禁止 key 或 value 为 null ,若传入 null 会直接抛出 NullPointerException ,这是与 HashMap 的核心区别之一。随后通过 spread 方法完成哈希扰动,公式为 (h ^ (h >>> 16)) & 0x7fffffff ,既混合高低位特征,又保证哈希值为正数。
步骤 2:自旋处理多线程竞争
方法外层采用 for(;;) 自旋循环,保证即使 CAS 失败或需要协助扩容,也能通过重试完成最终插入,避免操作中断。
步骤 3:数组初始化(initTable)
若哈希桶数组 table 未初始化,调用 initTable 方法完成初始化。该方法通过 CAS + 自旋实现,保证多线程下仅能初始化一次(详见上一篇 CAS 应用场景)。
步骤 4:空桶的 CAS 无锁插入
通过 tabAt 方法(volatile 读)获取目标桶的首节点,确认桶为空后,调用 casTabAt 方法尝试原子插入新 Node。若 CAS 成功,直接退出自旋;若失败,说明有其他线程抢先插入,进入后续加锁流程。
步骤 5:协助扩容(helpTransfer)
若桶首节点的 hash 为 MOVED (-1),说明该桶是扩容中转节点(ForwardingNode),当前线程会调用 helpTransfer 方法协助扩容,将元素迁移至新数组,扩容完成后再继续插入操作。
步骤 6:非空桶的 synchronized 加锁处理
这是 putVal 的核心并发安全逻辑,对桶首节点加 synchronized 锁,保证同一桶内操作互斥,分两种子场景:
-
**子场景 6.1:普通链表处理:**遍历链表,若找到相同 key 则根据 onlyIfAbsent 参数决定是否覆盖旧值;若遍历至尾部,则通过 尾插法 插入新节点,同时记录链表长度 binCount 。
-
**子场景 6.2:红黑树处理:**若桶首节点是 TreeBin (红黑树容器),调用 putTreeVal 方法完成红黑树节点插入,该方法会维护红黑树的平衡特性。
步骤 7:链表转红黑树(treeifyBin)
若链表长度 binCount≥8 ,调用 treeifyBin 方法触发树化。该方法会先判断数组容量,若数组容量 TREEIFYCAPACITY(64) ,会先扩容数组而非直接树化;若容量达标,则将链表转为红黑树。
步骤 8:更新元素数量与扩容检查(addCount)
插入新节点后调用 addCount ,通过 CAS 原子更新元素总数。同时该方法会检查元素数量是否超过扩容阈值,若超过则触发 transfer 扩容方法,且支持多线程协作扩容。
四、get 方法:无锁化的并发查询流程
ConcurrentHashMap 的 get 方法全程无锁,通过 volatile 的可见性保证和结构化遍历实现高效查询,核心逻辑在 getNode 方法中,调用链路为 get(Object key) → getNode(hash(key), key) 。
1. get 与 getNode 方法完整源码
java
// 对外暴露的get方法
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 计算哈希值
int h = spread(key.hashCode());
// 校验数组有效且目标桶存在节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 场景1:桶首节点直接匹配
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.value;
}
// 场景2:桶内是扩容中转节点或红黑树,特殊处理
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.value : null;
// 场景3:桶内是普通链表,遍历查询
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.value;
}
}
return null;
}
// Node类的find方法(普通Node默认实现,ForwardingNode和TreeBin会重写)
Node<K,V> find(int h, Object k) {
Node<K,V> e = this;
if (k != null) {
do {
K ek;
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
} while ((e = e.next) != null);
}
return null;
}
2. get 方法逐步骤拆解
步骤 1:哈希计算与数组有效性校验
先通过 spread 方法计算 key 的哈希值,再校验哈希桶数组是否已初始化、目标桶是否存在节点,若任一条件不满足则直接返回 null 。
步骤 2:桶首节点直接匹配
优先对比桶首节点的哈希值和 key,若匹配则直接返回该节点的 value,这是查询的最优路径(无哈希冲突时)。
步骤 3:特殊节点的查询处理
若桶首节点的 hash<0 ,说明是特殊节点( MOVED 或 TREEBIN ),调用节点的 find 方法处理:
-
ForwardingNode(MOVED): find 方法会到新数组中查询目标 key;
-
TreeBin(TREEBIN): find 方法会通过红黑树的二叉查找逻辑定位节点,时间复杂度 O (log n)。
步骤 4:普通链表的遍历查询
若桶内是普通链表,循环遍历链表节点,逐一对比哈希值和 key,找到匹配节点则返回 value,遍历结束无匹配则返回 null 。
3. get 方法的并发安全保障
get 方法全程无锁,其并发安全依赖以下两点:
-
**Node 属性的 volatile 修饰:**Node 类的 value 和 next 属性均被 volatile 修饰,保证多线程下的内存可见性,即一个线程修改节点值或链表指针后,其他线程能立即读取到最新值;
-
**无修改操作:**get 仅为读操作,不涉及节点结构修改,无需加锁;若查询时遇到扩容,ForwardingNode 会引导至新数组查询,保证数据一致性。
五、put/get 方法的并发安全关键细节
1.加锁粒度的精准控制
put 方法仅对桶首节点加 synchronized 锁,锁粒度为 "桶级别",不同桶的操作可完全并发,同一桶的操作才会互斥,最大化降低锁竞争。
2.二次校验的防并发修改
在 synchronized 加锁后,会通过 tabAt(tab, i) == f 二次校验桶首节点,防止加锁期间其他线程修改了桶的结构(如扩容迁移、链表转红黑树),保证锁内操作基于最新的桶状态。
3.扩容时的读写协作
扩容过程中,原数组的桶会被替换为 ForwardingNode:
-
**写操作(put):**遇到 ForwardingNode 会先协助扩容,再执行插入;
-
**读操作(get):**遇到 ForwardingNode 会直接到新数组查询,不阻塞且能获取最新数据。
4.元素数量的原子更新
addCount 方法通过 CAS 原子更新元素总数,同时采用分段统计的方式(CounterCell 数组)避免高并发下的计数竞争,保证 size 统计的准确性。
六、put/get 方法的时间复杂度分析
put 方法
-
无哈希冲突(CAS 插入):O (1),仅需哈希计算和 CAS 操作;
-
链表结构:O (n),n 为链表长度,加锁后遍历链表;
-
红黑树结构:O (log n),红黑树的插入操作时间复杂度为对数级别。
get 方法
-
无哈希冲突(桶首匹配):O (1),直接返回桶首节点值;
-
链表结构:O (n),遍历链表查询;
-
红黑树结构:O (log n),二叉查找定位节点。
JDK1.8 引入红黑树后,大幅优化了哈希冲突严重时的查询和插入性能,保证了高并发场景下的性能稳定性。
七、结语
本文通过逐行拆解源码,完整梳理了 ConcurrentHashMap 的 put 和 get 方法执行流程: put 方法通过 "CAS 无锁插入→synchronized 桶级锁→协作扩容" 实现并发安全插入, get 方法基于 volatile 可见性和无锁遍历实现高效查询。
下一篇文章,我们将聚焦 ConcurrentHashMap 的扩容机制,深度解析高并发场景下的安全扩容实现,以及多线程协作扩容的底层逻辑,敬请关注!