当前位置:首页 > 技术 > 正文内容

ConcurrentHashMap 源码中的两个关键缺陷分析

访客 技术 2026年7月6日 1

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;
}

插入操作的关键路径

插入主流程如下:

  1. 若桶数组未初始化,则先调用 initTable() 完成初始化;
  2. 定位到目标桶后,若为空则尝试使用 CAS 插入新节点;
  3. 若发现桶头为 ForwardingNode,说明正在扩容,当前线程需协助完成迁移任务;
  4. 否则对桶头节点加锁,执行链表或树的插入/更新操作。

其中判断是否需要协助扩容的逻辑由 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)

这种比较毫无意义,因为 rssc 的数值范围完全不同。正确的写法应为:

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;
}

相关文章

Linux crontab 详解

1) crontab 是什么cron 是 Linux 的定时任务守护进程;crontab 是用来编辑/查看“按时间周期执行命令”的表(cron table)。常见两类:用户 crontab:每个用户一份(crontab -e 编辑)系统级 crontab / cron.d:可指定执行用户(/etc/crontab、/etc/cron.d/*)2) crontab 时间...

富文本里可以允许的 HTML 属性

一、所有标签默认允许的安全属性(极少)class        (可选)id           (通常建议禁用)title️ 注意:id 容易被滥用做锚点注入,很多系统直接禁用class 允许的话最好只允许固定前缀(如 editor-*)二、a 标签允许属性<a href="" t...

Mac 安装 Node.js 指南

方法一:通过官网安装包(最简单,适合初学者)如果你只是想快速安装并开始使用,这是最直接的方法。访问 Node.js 官网。页面会显示两个版本:LTS (Recommended For Most Users):长期支持版,最稳定。建议选这个。Current:最新特性版,包含最新功能但可能不够稳定。下载 .pkg 安装包并运行。按照安装向导点击“下一步”即可完成。方法二:使用 Homebrew 安装(...

Dom\HTML_NO_DEFAULT_NS 的副作用:自动加闭合标签

在使用Dom\HTMLDocument时,Dom\HTML_NO_DEFAULT_NS 将禁止在解析过程中设置元素的命名空间, 此设置是为了与DOMDocument向后兼容而存在的。当使用它时,已知的一个副作用就是:自动加闭合标签例如 </img> 为什么会这样?当你使用:Dom\HTML_NO_DEFAULT_NS文档会变成 无命名空间模式,此时内部更接近 XML...

Laravel 事件和监听器创建

在 Laravel 中,使用 Artisan 命令创建 Events(事件) 和 Listeners(监听器) 是非常高效的。你可以通过以下几种方式来实现:1. 手动创建单个 Event如果你只想创建一个事件类,可以使用 make:event 命令:Bashphp artisan make:event UserRegistered执行后,文件将生成在 app/Even...

自定义域名解析神器 dnsmasq

什么是 dnsmasq?dnsmasq 是一个轻量级、功能强大的网络服务工具,专为小型和中等规模网络设计。它是一个综合的网络基础设施解决方案[1]。dnsmasq 能做什么?功能说明应用场景DNS 转发与缓存将 DNS 查询转发到上游服务器(ISP、Google DNS 等),并在本地缓存结果加快 DNS 查询速度,减少外部 DNS 流量本地 DNS解析本地网络设备的主机名,无需编辑&n...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。