当前位置: 首页 > news >正文

一次 ConcurrentHashMap 并发扩容源码走读:从错误使用到理解分段锁与 CAS 的协作机制

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

一方主张:“直接用 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 倍;
    • 若必须高并发,考虑使用computeIfAbsentmerge减少 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,可能成为新瓶颈。 落地建议:在高并发场景下合理设置初始容量,减少扩容触发频率。

http://www.jsqmd.com/news/591644/

相关文章:

  • 实战演练:基于真实订单数据,用快马平台和codex编写数据统计脚本
  • 晶存科技冲刺港股:年营收59亿 利润8.8亿 估值38亿
  • 2026年好用的燃气辐射采暖解决方案盘点,天津公司哪家强 - myqiye
  • OpenClaw+千问3.5-9B智能爬虫:安全采集网络数据
  • KeySequence:嵌入式USB HID键盘序列控制库
  • Jetson Orin Nano (Jetpack 6.2) 上OpenCV CUDA加速的避坑与性能调优实战
  • PlugY开源工具:暗黑破坏神2单机体验增强解决方案
  • LLM Guard:构建企业级大语言模型安全防护体系的架构解析与实践路径
  • 3个步骤快速上手Kazumi:打造您的个性化番剧播放中心
  • YimMenu:GTA V增强工具的技术解析与实践指南
  • 抖音视频高效下载工具:从入门到精通的完整指南
  • 3个步骤掌握MobaXterm中文版:终极远程管理工具完全指南
  • 3个步骤掌握网络资源下载工具res-downloader
  • 探讨2026年临汾正规西餐培训学校,口碑好的西点学校怎么收费 - 工业推荐榜
  • 跨平台音乐资源整合:高效解决方案与实践指南
  • GitHub Desktop中文界面完整攻略:3步实现高效汉化
  • LLM Guard:构建企业级大语言模型安全防护体系的技术架构与实践
  • 3个维度破解Figma语言壁垒:中文设计师效率提升指南
  • 终极指南:如何快速掌握Insomnia跨平台API测试工具
  • web图像插入
  • ROS2机器人控制环境搭建避坑指南:从输入法到MuJoCo仿真的完整配置清单
  • ai辅助c语言开发:让快马优化你的排序算法与代码结构
  • SillyTavern终极教程:5个步骤打造专业级AI角色聊天体验
  • 先胜业财实施服务商:冠融的实施方法论与选型建议 - 冠融盈科
  • GSE高级宏编译器:告别魔兽世界复杂技能循环,实现一键连招的智能方案
  • YimMenu:GTA V安全防护与体验增强的综合解决方案
  • AI辅助开发:让快马平台智能生成dhnvr416h-hd设备指令重试与状态同步模块
  • 如何轻松备份微信聊天记录:WeChatMsg完全使用指南
  • 知识蒸馏实战指南:如何为不同任务匹配合适的师生网络组合
  • Balena Etcher终极指南:安全高效的系统镜像烧录工具