在 Java 并发编程中,ConcurrentHashMap 是最常用的并发容器之一。它既要保证线程安全,又要追求接近 HashMap 的高性能。本文将深入剖析 ConcurrentHashMap 的整体设计思路,从数据结构、锁粒度、无锁读、CAS 无锁更新到多线程协助扩容,带你领略并发容器设计的精妙之处。
一、从 HashTable 到 ConcurrentHashMap
在 ConcurrentHashMap 出现之前,Hashtable 是 Java 中唯一的线程安全哈希表。它的实现非常粗暴:对所有方法(如 put、get)都加上 synchronized 关键字,整个表被一把大锁保护。在高并发场景下,这把锁成为严重的性能瓶颈,因为任何时刻只有一个线程能够访问哈希表。
HashMap 虽然性能高,但非线程安全。于是,Java 并发包推出了 ConcurrentHashMap,旨在解决以下问题:
-
细粒度锁:将锁的粒度从整个表缩小到每个桶(bucket)。
-
无锁读 :读操作不加锁,只依赖
volatile保证可见性。 -
无锁更新:对于简单的更新操作,使用 CAS(Compare And Swap)无锁算法替代加锁。
-
多线程协助扩容:将耗时的扩容操作分摊到多个线程,降低单次扩容的停顿时间。
二、核心设计理念
ConcurrentHashMap 的整体设计可以概括为:
降低锁粒度 + 无锁读 + CAS 无锁更新 + 局部加锁
1. 为什么读操作可以不加锁?
在实际应用中,对哈希表的读操作远多于写操作。如果读操作也加锁,性能会大幅下降,甚至不如 Hashtable。ConcurrentHashMap 的读操作完全不加锁,依靠 volatile 保证可见性。
volatile 的作用是:
-
可见性 :一个线程修改了
volatile变量,会立即刷新到主内存,其他线程读取时会直接从主内存读取,而不是自己的 CPU 缓存,从而总是读到最新值。 -
禁止指令重排序:保证对象的构造过程不会提前将引用暴露出去,避免读到半初始化对象。
在 ConcurrentHashMap 中,数组 table 是 volatile 的,每个桶的节点(Node)的 val 和 next 也是 volatile 的。这样,当读线程访问某个桶时,它能看到最新的节点引用和值,无需加锁。
2. 如何降低锁粒度?
Hashtable 的锁是整个对象,而 ConcurrentHashMap 只锁住当前操作的桶(bucket)的头节点。每个桶对应一个 Node 链表的头节点(或红黑树的根节点)。当多个线程同时操作不同的桶时,它们可以完全并发,互不干扰。
这种设计称为分段锁(Segmented Locking) ,在 Java 8 中进一步演进为锁桶(Lock Striping),锁的粒度更细,并发度更高。
3. 什么时候使用 CAS?
对于简单的、无需遍历链表的操作,如:
-
初始化空桶
-
更新计数器(
size) -
修改
sizeCtl(扩容状态)
使用 CAS 可以避免加锁,实现无锁更新,进一步提升性能。
4. 为什么采用多线程协助扩容?
扩容需要将旧数组中的元素重新哈希分配到新数组,是一个耗时操作。如果单线程完成,可能会造成长时间停顿。ConcurrentHashMap 将扩容任务拆分成多个小任务,允许其他线程在插入数据时"顺带"帮忙完成一部分迁移工作,从而大幅减少整体扩容时间,避免性能毛刺。
三、数据结构演进:Java 8 的优化
Java 7 及之前的 ConcurrentHashMap 采用 分段锁 设计,内部由多个 Segment 组成,每个 Segment 独立加锁,锁粒度较粗。Java 8 彻底重构,采用 数组 + 链表 + 红黑树 的结构,锁的粒度细化到每个桶的头节点。
Java 8 的核心数据结构:
-
Node<K,V>[] table:哈希桶数组,volatile修饰。 -
Node节点:包含hash、key、val(volatile)和next(volatile)。 -
当链表长度超过阈值(
TREEIFY_THRESHOLD = 8)且数组长度大于MIN_TREEIFY_CAPACITY = 64时,链表转为红黑树(TreeNode),以优化极端情况下的查找性能。 -
ForwardingNode:在扩容过程中,用于标记已经被迁移的桶,指向新数组。
四、读操作(get)的实现
get 方法的流程如下:
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;
}
-
无锁 :整个读过程没有任何
synchronized或Lock。 -
volatile保证 :table是volatile的,Node的val和next也是volatile的,所以即使没有锁,也能读到最新值。 -
特殊节点 :如果头节点的
hash小于 0,说明是红黑树节点(TreeBin)或扩容节点(ForwardingNode),会调用其find方法进行查找,同样不加锁。
由于读操作不加锁,ConcurrentHashMap 的并发读性能几乎与 HashMap 持平,这是其设计的核心优势。
五、写操作(put)的实现
put 方法的关键步骤(简化):
java
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;
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // 1. 初始化数组(CAS)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 2. 空桶:CAS 直接插入新节点
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}
else if ((fh = f.hash) == MOVED)
// 3. 正在扩容:协助扩容
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) { // 4. 锁住头节点
// 双重检查,确保头节点未被修改
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 普通链表
// 遍历链表,找到插入位置或更新
// ...
} else if (f instanceof TreeBin) {
// 红黑树插入
// ...
}
}
}
// 5. 链表长度超过阈值,转为红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
break;
}
}
}
addCount(1L, binCount); // 6. 计数更新(CAS)
return null;
}
-
初始化数组 :使用 CAS 保证只有一个线程能成功初始化
table。 -
空桶插入 :通过
casTabAt原子地插入新节点,无需加锁。 -
非空桶 :对桶的头节点加锁(
synchronized (f)),这样其他线程访问同一桶会被阻塞,但不同桶的线程完全并发。 -
红黑树:同样加锁,树节点的操作也在锁保护下进行。
-
扩容协助:如果检测到正在扩容,当前线程会帮助迁移数据,降低扩容总时间。
这种设计使得写操作只在必要的时候加锁,且锁的粒度非常小,极大提升了并发写入能力。
六、CAS 无锁更新的应用
CAS(Compare And Swap)是一种乐观锁技术,它通过比较并交换的方式原子地更新变量,避免加锁开销。在 ConcurrentHashMap 中,CAS 被广泛应用于:
-
初始化数组 :
sizeCtl通过 CAS 控制初始化状态。 -
空桶插入 :
casTabAt确保只有成功插入的线程才能创建节点。 -
计数器更新 :使用
LongAdder思想的CounterCell数组,通过 CAS 更新计数值,高并发下避免了竞争。 -
修改 sizeCtl :在扩容过程中,
sizeCtl用于控制扩容进度,通过 CAS 修改其值。
七、多线程协助扩容机制
扩容是 ConcurrentHashMap 中最复杂的部分。Java 8 实现了并发扩容,允许多个线程共同参与迁移,大幅缩短扩容时间。
1. 触发扩容的条件
-
插入元素后,链表长度超过阈值(
TREEIFY_THRESHOLD),且数组长度小于MIN_TREEIFY_CAPACITY。 -
元素个数超过
sizeCtl(即扩容阈值)。
2. 扩容的核心思想
-
当需要扩容时,首先计算新数组容量(原数组的 2 倍)。
-
设置
sizeCtl为一个负数,表示正在扩容,并记录参与扩容的线程数。 -
创建
ForwardingNode作为占位节点,指向新数组。 -
将旧数组的桶分配给不同的线程去迁移。每个线程负责一个"步长"(
stride)的桶,迁移完成后通过 CAS 修改transferIndex来获取下一个任务范围。 -
线程在迁移某个桶时,先对该桶的头节点加锁,然后将其中的元素重新哈希分配到新数组的对应桶中。
-
迁移完成后,将桶设置为
ForwardingNode,后续的读写操作会通过该节点转发到新数组。
3. 协助扩容
当线程执行 put 时,如果发现当前桶的头节点是 ForwardingNode,它会调用 helpTransfer 方法加入扩容队伍,帮助迁移数据,而不是傻傻等待。
这种设计使得扩容不再是单线程的"stop-the-world"操作,而是变成多线程并行执行,整体扩容时间大幅缩短,尤其在高并发场景下效果显著。
八、计数机制:LongAdder 思想
ConcurrentHashMap 需要知道当前元素个数(size()),且在高并发下计数必须准确。如果使用 AtomicLong,高并发 CAS 竞争会很激烈,影响性能。
Java 8 引入了 LongAdder 的思想,将计数分散到多个 CounterCell 中,每个线程通过 CAS 更新自己的计数器,最后求和。这样减少了 CAS 冲突,提高了计数性能。
关键类:
-
baseCount:基础的计数器。 -
CounterCell[] counterCells:辅助计数数组。 -
cellsBusy:自旋锁,用于控制counterCells的初始化或扩容。
当 CAS 更新 baseCount 失败时,会尝试更新 CounterCell,若仍失败,则扩容 counterCells 数组。最终 size() 返回 baseCount 加上所有 CounterCell 的值之和。
九、总结
ConcurrentHashMap 通过一系列精妙的设计,在保证线程安全的前提下,提供了接近 HashMap 的读写性能。其设计精髓可以总结为:
-
无锁读 :借助
volatile保证可见性,读操作完全不加锁,实现高并发读。 -
细粒度锁:锁的粒度从整个表缩小到每个桶的头节点,并发写能力大幅提升。
-
CAS 无锁更新:对于简单的、无需遍历的操作,使用 CAS 原子更新,避免锁开销。
-
多线程协助扩容:将耗时的扩容任务拆分为多个子任务,允许写线程帮忙迁移,降低扩容停顿。
-
高效计数 :采用
LongAdder思想,分散计数压力,提升高并发下的计数性能。