ConcurrentHashMap 源码中的两个关键缺陷分析
Java 8 ConcurrentHashMap 的并发设计演进
Java 8 中的 ConcurrentHashMap 相较于 Java 7 版本进行了重大重构。它摒弃了基于 Segment 的分段锁机制,转而采用更细粒度的桶级同步策略,并结合无锁算法显著提升了并发性能。该实现不仅避免了读操作的双重哈希开销,还通过巧妙设计使扩容过程对读写操作几乎透明。
核心数据结构与辅助节点类型
除了常规的链表节点和红黑树节点外,ConcurrentHashMap 引入了一种特殊的转发节点类型:
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
}
当哈希表处于扩容状态时,原数组中的桶若已被迁移,则会被替换为 ForwardingNode。其主要作用包括:
- 标记当前桶已完成数据迁移;
- 将后续对该桶的访问请求自动引导至新的哈希表中,从而保证在扩容期间读操作仍可正常进行。
初始化逻辑与线程安全控制
构造函数接受初始容量、负载因子及并发等级参数,但后者在 Java 8 中已无实际用途,仅作保留兼容。真正关键的是成员变量 sizeCtl,它承担了多重职责:
- 值为 -1:表示正在进行表初始化;
- 负数(除-1):表示正在进行扩容,高16位存储扩容戳,低16位记录参与扩容的线程数;
- 正数:下一次触发扩容的阈值(即0.75倍容量)。
初始化方法通过 CAS 操作确保仅有一个线程执行建表动作:
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // 其他线程让出CPU
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
tab = (Node<K,V>[])new Node<?,?>[n];
table = tab;
sc = n - (n >>> 2); // 设置扩容阈值
}
} finally {
sizeCtl = sc; // 恢复正常状态
}
break;
}
}
return tab;
}
插入操作的关键路径
插入主流程如下:
- 若桶数组未初始化,则先调用
initTable()完成初始化; - 定位到目标桶后,若为空则尝试使用 CAS 插入新节点;
- 若发现桶头为
ForwardingNode,说明正在扩容,当前线程需协助完成迁移任务; - 否则对桶头节点加锁,执行链表或树的插入/更新操作。
其中判断是否需要协助扩容的逻辑由 helpTransfer 方法实现:
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
第一个已知问题:扩容条件判断错误
上述代码中存在一个长期存在的逻辑缺陷。变量 rs 是通过 resizeStamp(n) 生成的扩容标识,其定义如下:
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
该值左移16位后才会被写入 sizeCtl 的高位。然而,在 helpTransfer 中却直接比较了未经移位的 rs 与完整 sizeCtl 值:
if (sc == rs + 1 || sc == rs + MAX_RESIZERS)
这种比较毫无意义,因为 rs 和 sc 的数值范围完全不同。正确的写法应为:
int stamp = resizeStamp(n) << RESIZE_STAMP_SHIFT;
if (sc == stamp + 1 || sc == stamp + MAX_RESIZERS)
此问题已在 JDK 12 中修复(参考 bug ID: JDK-8214427),早期版本中可能导致无法正确限制扩容线程数量。
多线程协同扩容机制
扩容过程中多个线程可以并行迁移不同区段的数据。关键协调变量是 transferIndex,它记录了下一个可用迁移段的起始位置。每个线程通过原子减操作从该索引处领取一段连续桶进行处理:
while (advance) {
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
最后一个线程的二次扫描逻辑
当某个线程完成分配的任务后,会检查自身是否为最后一个退出的扩容线程。若是,则会重新扫描整个旧表以确认所有桶均已迁移:
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n;
}
尽管 Doug Lea 在邮件列表中承认这一"事后扫描"在当前实现中已非必需(因各线程分工明确且互不重叠),但由于其执行频率极低,影响微乎其微,因此暂未移除。
无锁计数器与 size 方法实现
为了高效统计元素总数,ConcurrentHashMap 采用类似 LongAdder 的分段累加思想:
核心字段
private transient volatile long baseCount;
transient volatile CounterCell[] counterCells;
@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
计数更新逻辑
每次成功插入后调用 addCount(1L, binCount) 更新总数。优先尝试修改 baseCount,失败则转向 counterCells 数组:
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[getProbe() & m]) == null ||
!(uncontended = U.compareAndSwapLong(a, CELLVALUE, a.value, a.value + x)))
fullAddCount(x, uncontended);
}
fullAddCount 中的竞争处理
当普通 CAS 失败时,进入 fullAddCount 进行复杂重试。其中包括:
- 初始化
counterCells数组; - 尝试在其他槽位进行更新;
- 检测冲突频繁时扩容数组(最大不超过 CPU 核心数)。
第二个潜在问题:扩容终止条件宽松
在 fullAddCount 中,决定是否扩容的条件之一是:
else if (counterCells != as || n >= NCPU)
collide = false;
即一旦数组长度达到 CPU 核心数即停止扩容。虽然理论上这能防止无限扩张,但在某些极端场景下(如大量线程短暂集中写入),可能过早结束扩容导致部分槽位持续高竞争。理想情况下可引入动态调整策略,但现有实现出于简洁性考虑采用了固定上限。
最终汇总
size() 方法通过简单求和完成:
public int size() {
long n = sumCount();
return (n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n;
}
final long sumCount() {
long sum = baseCount;
CounterCell[] as = counterCells;
if (as != null) {
for (CounterCell a : as)
if (a != null)
sum += a.value;
}
return sum;
}