在 Java 并发编程中,ConcurrentHashMap 是当之无愧的明星容器。它既要在多线程环境下保证线程安全,又要追求接近 HashMap 的高性能。本文将带你深入剖析 ConcurrentHashMap 的 put 和 get 流程,从源码视角解读其精妙设计,理解它如何通过无锁读、CAS 原子操作、细粒度锁以及多线程协助扩容,实现了并发与性能的完美平衡。
一、从 Hashtable 到 ConcurrentHashMap
在 Java 早期,Hashtable 是唯一的线程安全哈希表,但它对所有方法都加 synchronized,整个表被一把大锁保护。高并发下,这把锁成为严重的性能瓶颈------任何时刻只有一个线程能访问。而 HashMap 虽然性能高,却非线程安全。于是,Java 并发包推出了 ConcurrentHashMap,目标是:
-
细粒度锁:将锁的粒度从整个表缩小到每个桶(bucket)。
-
无锁读 :读操作不加锁,依靠
volatile保证可见性。 -
CAS 无锁更新:对于简单操作(如空桶插入、计数器更新),使用乐观锁 CAS 替代重量级锁。
-
多线程协助扩容:将耗时的扩容任务拆解,让多个线程共同完成,减少单次扩容的停顿时间。
二、核心数据结构(Java 8)
Java 8 的 ConcurrentHashMap 彻底重构,放弃了 Java 7 的分段锁(Segment),采用了更精妙的数组 + 链表 + 红黑树结构:
-
Node<K,V>[] table:哈希桶数组,用volatile修饰,保证数组引用对所有线程可见。 -
Node节点:包含hash、key、val(volatile)和next(volatile)。 -
当链表长度超过 8 且数组长度 ≥ 64 时,链表会转换为红黑树(
TreeNode),查询复杂度从 O(n) 降到 O(log n)。 -
ForwardingNode:扩容时的特殊节点,用于指向新数组,标记该桶已被迁移。
三、put 流程详解:并发写入的精妙设计
ConcurrentHashMap 的 put 方法既要处理普通插入,又要应对并发竞争、扩容等复杂场景。下面我们逐步拆解其核心流程。
1. 参数校验与哈希扰动
java
public V put(K key, V value) {
return putVal(key, value, false);
}
-
首先,
ConcurrentHashMap不允许 key 或 value 为 null ,一旦检测到 null,直接抛出NullPointerException。 -
对 key 的
hashCode()进行二次扰动(spread()方法),目的是让高位也参与运算,减少哈希冲突,使数据分布更均匀。
java
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
2. 计算桶下标
通过 (n - 1) & hash 计算桶下标,其中 n 是数组长度(总是 2 的幂次)。这个操作等价于取模,但效率更高。
3. 空桶插入:CAS 无锁操作
如果定位到的桶为空,则使用 CAS 原子地插入新节点。这是无锁操作,避免了加锁开销:
java
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // 插入成功,跳出循环
}
如果 CAS 失败(说明有其他线程抢先插入了),则进入下一次自旋重试。
4. 正在扩容:协助迁移
如果桶的头节点是 ForwardingNode(其 hash 值为 MOVED),说明当前数组正在扩容。此时当前线程不会阻塞等待,而是调用 helpTransfer() 方法主动加入扩容任务,帮助迁移其他桶中的数据,再继续自己的 put 操作。这种设计将扩容的压力分散到多个写线程,大大缩短了整体扩容时间。
5. 正常插入:细粒度锁
如果桶不为空且不是 ForwardingNode,就对桶的头节点加 synchronized 锁(锁对象就是这个节点)。注意:这里只锁住了当前桶,其他桶的操作完全不受影响,实现了细粒度并发控制。
加锁后,再次检查头节点是否被修改(双重检查),然后根据节点类型进行插入:
-
链表遍历 :遍历链表,如果找到相同 key,则覆盖 value(根据
onlyIfAbsent参数决定);否则在链表尾部插入新节点(Java 8 采用尾插法,避免并发下链表环的问题)。 -
红黑树 :如果是
TreeBin节点,则调用红黑树的插入方法,同样在锁保护下进行。
6. 树化判断
插入完成后,如果当前桶的链表长度 ≥ 8,会触发树化判断:
-
如果数组长度 < 64,优先扩容数组(
tryPresize),因为较短的数组容易导致哈希冲突,扩容可以更有效地解决冲突。 -
如果数组长度 ≥ 64,则将链表转换为红黑树,将查询复杂度从 O(n) 降到 O(log n)。
7. 更新计数与扩容触发
最后,调用 addCount() 更新元素总数。该方法使用 LongAdder 思想 ,将计数分散到 baseCount 和 CounterCell 数组中,通过 CAS 原子更新,减少高并发下的竞争。同时,addCount() 会检查当前元素数量是否超过 sizeCtl(扩容阈值),若达到则触发扩容流程。
四、get 流程:全程无锁,性能接近 HashMap
ConcurrentHashMap 的 get 操作是 完全无锁 的,这是其高并发读性能的核心。整个查询过程仅依赖 volatile 保证可见性。
java
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.val;
}
// 2. 特殊节点(红黑树或扩容转发节点)
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 3. 普通链表遍历
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
步骤解析:
-
计算桶下标 ,通过
tabAt获取桶头节点(volatile读,保证可见性)。 -
检查头节点:如果头节点的 key 匹配,直接返回 value。
-
特殊节点 :如果头节点的 hash 小于 0(表示是红黑树节点或扩容转发节点),调用其
find方法查找。红黑树的查找遵循二叉搜索树规则:从根开始比较哈希值,小于走左子树,大于走右子树,哈希相等再用equals判断;扩容转发节点则会去新数组查找。 -
链表遍历:否则,按链表顺序遍历,找到匹配的 key 则返回 value,否则返回 null。
整个过程没有加任何锁,只依赖 volatile 保证读到的值是最新的。即使有并发写,volatile 也能保证修改对读线程可见,因此读性能几乎与 HashMap 持平。
五、其他关键机制
1. 计数机制:LongAdder 思想
ConcurrentHashMap 的 size() 方法需要实时返回元素个数。高并发下,使用 AtomicLong 会导致大量 CAS 失败,影响性能。因此,它采用了类似 LongAdder 的设计:
-
baseCount:基础计数器。 -
CounterCell[] counterCells:辅助计数数组,每个线程可以独立更新自己的 CounterCell。 -
cellsBusy:自旋锁,用于控制 counterCells 的初始化或扩容。
当 CAS 更新 baseCount 失败时,会尝试更新 CounterCell,如果仍失败,则尝试扩容 counterCells 数组。最终 size() 返回 baseCount + sum(CounterCell)。
2. 多线程协助扩容
ConcurrentHashMap 的扩容不是单线程完成,而是允许多个线程共同参与。当某个线程发现需要扩容时,它会设置 sizeCtl 为负数,并创建 ForwardingNode。其他线程在插入时如果遇到 ForwardingNode,会调用 helpTransfer 协助迁移数据。每个线程负责一个"步长"(stride)的桶,通过 CAS 修改 transferIndex 来分配任务。迁移完成后,将桶设为 ForwardingNode,后续操作自动转发到新数组。这种并发扩容机制,使得扩容时间随参与线程数增加而缩短,避免了单线程扩容时的长时间停顿。
六、总结
ConcurrentHashMap 通过一系列精妙的设计,在并发场景下实现了高性能的哈希表操作:
-
无锁读 :
volatile保证可见性,读操作不加锁,性能接近HashMap。 -
细粒度锁:写操作只锁当前桶的头节点,并发写入互不干扰。
-
CAS 无锁更新:空桶插入、计数更新等简单操作使用 CAS,避免锁开销。
-
多线程协助扩容:将扩容任务分摊给多个写线程,大幅减少扩容停顿时间。
-
高效计数 :
LongAdder思想分散计数压力,高并发下依然准确高效。