上周团队在优化一个高并发配置中心时,针对缓存选型爆发了一场激烈争论。

一方主张:“直接用 ConcurrentHashMap,线程安全,性能好,JDK 自带,不用白不用。”

另一方反驳:“你们真的了解它的扩容机制吗?在高并发写入场景下,多个线程同时触发扩容,会不会导致 CPU 飙升甚至死循环?”

争论的焦点集中在一段看似简单的代码上:

ConcurrentHashMap<String, Config> configCache = new ConcurrentHashMap<>();
// 高并发环境下频繁 put
executor.submit(() -> {
    for (int i = 0; i < 10000; i++) {
        configCache.put("key_" + Thread.currentThread().getId() + "_" + i, new Config());
    }
});

起初大家都认为这是“标准写法”,直到压测时发现:在 32 核机器上,随着线程数增加到 64,CPU 使用率从 30% 飙升至 95%,且吞吐量不升反降。日志中未见 OOM,但 GC 频率正常,线程堆栈显示大量线程卡在 transfer 方法。

这显然不是预期的“无锁高性能”。于是我们决定深入 ConcurrentHashMap 的扩容源码,揭开其并发控制机制的真实面貌。


需求约束:为什么不能用 HashMap 或 Hashtable?

在开始源码分析前,先明确我们的业务场景约束:

  • 配置中心需支持每秒上万次读写;
  • 配置更新频率高,存在热点 key 频繁修改;
  • 不允许因并发问题导致数据丢失或脏读;
  • 系统运行在 JDK 17 环境,但需兼容 JDK 8+ 行为。

HashMap 在多线程下会因 rehash 导致链表成环,引发死循环;Hashtable 使用全表锁,并发度极低。因此 ConcurrentHashMap 成为唯一合理选择——但前提是我们必须正确使用它。


架构设计:ConcurrentHashMap 的并发哲学

ConcurrentHashMap 并非“完全无锁”,而是通过分段锁 + CAS + synchronized 精细化控制实现高并发。其核心设计思想是:

  1. 桶级别锁:每个哈希桶(bin)独立加锁,避免全局锁竞争;
  2. CAS 无锁读/写:在桶为空或仅有一个节点时,优先使用 CAS 插入;
  3. 协助扩容机制:当一个线程触发扩容时,其他线程可协助迁移数据,避免单点瓶颈;
  4. sizeCtl 状态机:通过 volatile 变量控制初始化、扩容、终止等状态。

然而,很多人误以为“只要用了 ConcurrentHashMap,就可以随意高并发 put”,忽略了扩容过程中的协作成本。


关键代码走读:transfer 方法如何工作?

我们定位到问题的核心:transfer 方法。这是扩容时数据迁移的关键逻辑,位于 java.util.concurrent.ConcurrentHashMap 类中。

错误直觉:扩容是串行的

很多人以为扩容只能由一个线程完成,其他线程必须等待。实际上,ConcurrentHashMap 允许多个线程并行迁移不同段的数据。

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // 每个线程最少处理 16 个桶

    if (nextTab == null) {            // 初始化新表
        try {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // 内存不足
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        transferIndex = n;
    }

    int bound = 0;
    ForwardingNode<K,V> fwd = new ForwardingNode<>(nextTab);
    boolean advance = true;
    boolean finishing = false;       // 是否完成迁移

    for (int i = 0, bound = 0; ; i = nextIndex) {
        // 获取当前要处理的桶范围
        while (advance) {
            nextIndex = transferIndex -= stride;
            if (nextIndex <= (bound = nextIndex - stride)) {
                nextIndex = transferIndex = 0;
                advance = false;
            }
        }

        if (i < 0 || i >= n || i + n >= nextn) {
            // 当前线程无任务可做
            if (finishing) {
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1); // 更新阈值
                return;
            }
            // 尝试减少工作线程数
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;
                finishing = advance = true;
                i = n; // 重新检查一遍
            }
        } else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex,
                                      bound = nextIndex - stride)) {
            // 成功抢到一段任务,开始迁移
            for (int j = i; j < bound; j++) {
                // 迁移单个桶
                migrateBucket(tab, nextTab, j, fwd);
            }
            advance = true;
        }
    }
}
关键机制解析
  1. stride 划分任务:每个线程负责迁移 stride 个桶(默认至少 16 个),避免任务过细;
  2. transferIndex 原子递减:通过 CAS 分配任务区间,确保不重复不遗漏;
  3. ForwardingNode 占位:原桶被迁移后,插入 ForwardingNode,告诉后续 get/put 操作“去新表查”;
  4. 协助扩容:任何线程在 put 时发现正在扩容,都会尝试调用 helpTransfer 协助迁移。
为什么压测时 CPU 飙升?

根本原因在于:线程数远超过 CPU 核心数,导致大量线程争抢 transferIndex,CAS 失败率高,自旋消耗 CPU。

此外,如果初始容量设置过小(如默认 16),在短时间内插入大量数据会频繁触发扩容,进一步加剧竞争。


复盘:如何正确使用 ConcurrentHashMap?

经过源码分析和压测验证,我们总结出以下实践建议:

  1. 预估容量,避免频繁扩容:

    // 错误:默认容量 16,插入 10w 数据会扩容多次
    new ConcurrentHashMap<>();
    
    // 正确:预估大小,减少扩容次数
    new ConcurrentHashMap<>(100000);
    
  2. 控制并发写入线程数:

    • 线程数建议不超过 CPU 核心数的 2 倍;
    • 若必须高并发,考虑使用 computeIfAbsent 或 merge 减少 put 竞争。
  3. 避免热点 key 集中写入:

    • 热点 key 会导致同一桶频繁操作,即使有锁分离也可能成为瓶颈;
    • 可考虑本地缓存 + 异步同步策略。
  4. 监控 sizeCtl 和 transferIndex:

    • 通过反射或 JMX 监控扩容状态,及时发现异常;
    • 在日志中打印 table.length 观察扩容频率。
  5. JDK 版本差异注意:

    • JDK 8 使用分段锁 + CAS;
    • JDK 9+ 引入 synchronized 替代部分 ReentrantLock,性能更优;
    • 建议使用最新 LTS 版本。

最终,我们将配置中心改造为:

  • 使用 ConcurrentHashMap 但预分配容量;
  • 写入操作通过 computeIfAbsent 保证原子性;
  • 添加本地 Caffeine 缓存作为一级缓存,减少对 ConcurrentHashMap 的直接访问;
  • 压测结果显示:64 线程下 CPU 使用率降至 45%,吞吐量提升 3 倍。

技术补丁包

  1. ConcurrentHashMap 扩容机制 原理:通过 transferIndex 原子分配迁移任务,多个线程可并行迁移不同桶;迁移完成后插入 ForwardingNode 引导查询到新表。 设计动机:避免单点扩容瓶颈,提升高并发写入性能。 边界条件:线程数过多会导致 CAS 竞争加剧,反而降低性能;初始容量过小会频繁触发扩容。 落地建议:预估数据量预分配容量,控制并发线程数,监控扩容频率。

  2. CAS 与 synchronized 的协作 原理:桶为空时使用 CAS 插入;桶非空且需扩容时使用 synchronized 锁住头节点。 设计动机:最小化锁粒度,兼顾性能与正确性。 边界条件:CAS 在高度竞争下失败率高,可能退化为锁竞争;synchronized 在 JDK 15+ 有显著优化。 落地建议:优先使用 CAS 场景,避免人为制造热点 key。

  3. ForwardingNode 的作用 原理:扩容期间,原桶被替换为 ForwardingNode,其 hash 值为 -1,指向新表。 设计动机:实现无锁读的连续性,get 操作无需等待扩容完成。 边界条件:put 操作遇到 ForwardingNode 会触发 helpTransfer,可能增加延迟。 落地建议:理解其存在意义,避免误判为“空桶”或“异常节点”。

  4. sizeCtl 状态机 原理:volatile 变量控制初始化(-1)、正常(阈值)、扩容(负值表示扩容线程数)等状态。 设计动机:统一协调初始化、扩容、终止等生命周期事件。 边界条件:多线程同时初始化或扩容时依赖 CAS 保证原子性。 落地建议:可通过反射查看其值辅助排查问题,但勿手动修改。

  5. 协助扩容(helpTransfer) 原理:任何线程在 put/get 时发现正在扩容,都会尝试协助迁移数据。 设计动机:充分利用系统资源,加快扩容速度。 边界条件:协助线程仍需竞争 transferIndex,可能成为新瓶颈。 落地建议:在高并发场景下合理设置初始容量,减少扩容触发频率。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐