ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ConcurrentHashMap扩容机制详解:多线程协作与性能优化实战

ConcurrentHashMap扩容机制详解:多线程协作与性能优化实战 做后端开发这些年Java 并发集合里让我又爱又恨的ConcurrentHashMap 绝对算一个。爱的是它日常用起来真省心高并发下读写都很稳恨的是一旦你没搞清楚它的扩容机制线上服务可能在毫无征兆的情况下 CPU 飙高、请求变慢。我之前就遇到过一回大促刚开始订单服务突然大面积超时线程 dump 一看大量线程堵在 ConcurrentHashMap 的 transfer 方法附近当时对扩容协作机制理解不深排查了很久才定位到是瞬时写入量太大触发了连续多轮扩容。可以说ConcurrentHashMap 的扩容过程直接决定了它在高并发写入下的性能底线。这篇文章专门回答一个问题ConcurrentHashMap 扩容过程如何优化性能我会从 Java 8 的实现细节出发把数组翻倍、状态协调、多线程协作、单桶迁移这些环节拆开讲透最后再给出一套线上调优和排查的经验。适合正在准备 Java 并发面试的同学也适合想对服务端性能优化做深入优化的开发者。如果你只是背过“ConcurrentHashMap 扩容时多线程协助”这个结论却不知道底层怎么协作那这篇文章正好可以补上这段空白。1. 扩容到底在做什么从 HashMap 到 ConcurrentHashMap 的差异1.1 数组不够用就要翻倍先回到最基础的问题为什么需要扩容HashMap 的底层是一个桶数组每个桶后面可以挂链表或红黑树。写入元素时先根据 key 的 hashCode 算出桶下标命中同一个桶的节点会串成链表。当元素越来越多哈希冲突越来越严重链表越来越长查询效率会从理想的 O(1) 退化成 O(n)。为了解决这个问题集合框架会在元素数量超过阈值时把桶数组长度扩大为原来的两倍再把旧桶里的节点重新分布到新桶中这个动作就是扩容也叫 resize。所有基于哈希表的 Map 都逃不开这个设计HashMap 有Hashtable 有ConcurrentHashMap 也有。但落到高并发场景问题就复杂了HashMap 在多线程扩容时会丢数据甚至死循环Hashtable 则干脆把整张表锁住所有读写都排队性能惨不忍睹。ConcurrentHashMap 要兼顾线程安全和高吞吐那就必须从扩容的每个环节里“抠”性能。1.2 Java 8 明确的三条“性能红线”Java 8 重写了 ConcurrentHashMap 的整个底层实现不再用 Java 7 的 Segment 分段锁而是采用 CAS synchronized 处理并发。到了扩容这里它给自己定下了三条必须遵守的性能红线第一扩容时不能阻塞读。读操作是整个 Map 使用频率最高的一类操作如果扩容期间所有 get 都阻塞那系统停顿的时间会非常难看。所以读路径必须做到基本无锁。第二写操作不能锁全表。Java 7 里的扩容是同步完成的会有一个很大的临界区Java 8 把锁粒度降到了“单个桶”只有正在迁移的那个桶会被锁住其他桶的写入仍然可以并发进行。第三多核资源要利用起来。扩容不是单一线程的“私事”空闲的写线程会主动参与迁移。一个 1024 容量的表如果只有单个线程搬可能要卡很久如果 8 个线程一起搬停顿时间能缩短到原来的八分之一甚至更低。理解了这三条红线再看后面的源码和优化手段就会非常有感觉。所有看似复杂的机制本质上都是为了让这三个目标同时成立。2. 扩容的入口sizeCtl 与扩容戳2.1 sizeCtl一个变量管四件事ConcurrentHashMap 在并发控制上有一个灵魂字段叫sizeCtl翻译过来是“表控制状态”。千万不要小看这个 int它在不同阶段表达的含义完全不同sizeCtl 取值含义0默认状态数组还没初始化使用默认容量 16正数数组已初始化表示下一次触发扩容的阈值约等于容量 * 0.75-1数组正在初始化其他线程看到这个值会让出 CPU负数且非 -1扩容正在进行高 16 位存扩容戳低 16 位存参与迁移的线程数加 1也就是说一个 int 变量同时承担了“初始化标志”“扩容阈值”“扩容状态”“参与线程计数”多个角色的通信功能。这是非常典型的多线程状态压缩技巧。每次状态变化都依赖 CAS 原子更新而不是加锁所以它的性能开销很小。如果你在构造 ConcurrentHashMap 时传入了 initialCapacitysizeCtl 还会被临时用来保存“初始容量”的 2 的幂结果直到第一次 put 触发数组初始化时再用它建表。这也是很多人忽略的细节构造参数传的是预估元素数不是桶数组长度内部会做一个 1.5 倍加 1 再对齐到 2 的幂的处理。2.2 resizeStamp给扩容发“身份证”你可能会想线程之间怎么知道当前这次扩容是哪一次扩容如果两个线程同时发现“需要扩容”它们协作的是同一个扩容过程吗为了解决这个问题ConcurrentHashMap 引入了一个resizeStamp方法。它根据当前数组长度 n 生成一个扩容戳核心公式是Integer.numberOfLeadingZeros(n) | (1 15)n 是 2 的幂所以不同的数组长度会生成不同的前导零个数自然生成不同的 stamp。这个值在扩容开始时会记录在 sizeCtl 的高 16 位。后续线程想协助扩容必须先检查“当前 sizeCtl 高 16 位是不是我预期的扩容戳”。是就进来帮忙不是说明这次扩容对应的旧表长度和我看到的不一致不能乱帮。这个设计避免了“一波扩容还没结束另一波基于不同旧长度的扩容又启动”的状态错乱。相当于给每次扩容发了一张带唯一编号的工牌只有同一批次的人才能进场。2.3 触发链路addCount、tryPresize 与 helpTransfer扩容不是从天上掉下来的它有明确的触发入口。平时最常见的触发点在putVal方法尾部插入成功后会调用addCount把元素数加一然后检查当前元素数量是否已经达到了 sizeCtl 阈值。如果达到了就尝试调用transfer启动扩容。putAll这类批量写入方法会走另一条路先调用tryPresize根据传入的总数据量计算一个合适的容量提前把表扩到足够大再逐条插入。这样做可以避免在批量插入过程中反复多次扩容是一个很实用的优化思路。第三个入口是helpTransfer。当一个写线程在 put 时发现自己要写入的桶已经变成了 ForwardingNode说明该桶已经迁移完了当前线程最合理的做法不是傻等而是先去帮忙扩容等扩容推进到一定程度后再继续写入。这就是“写线程同时也是迁移线程”的协作模式。3. 多线程迁移扩容性能优化的关键3.1 用 transferIndex 把桶分给不同线程扩容迁移的核心方法叫transfer它负责把旧数组的节点搬到新数组。如果这一步只有一个线程做大表扩容耗时是非常可观的。Java 8 的设计是把它拆成多个任务交给多个线程协作完成。拆任务的“调度员”是一个transferIndex字段它表示“下一个待领取的迁移区间起点”。整个迁移过程从旧数组的最后一个桶往前扫描线程每次通过 CAS 操作领取一段桶区间。CAS 成功就表示这段区间归我其他线程不会再领到CAS 失败就重新读最新值继续竞争。每次领取的区间长度 stride 也不是随手定的它有个计算公式stride (NCPU 1) ? (n 3) / NCPU : n; if (stride MIN_TRANSFER_STRIDE) stride MIN_TRANSFER_STRIDE;MIN_TRANSFER_STRIDE在 Java 8 中默认是 16。也就是说如果数组很大而核数有限每个线程每次至少搬 16 个桶。比如数组长度 1024、8 核 CPUstride 算出来是 16每次线程领 16 个桶这批任务最多能被拆成 64 份然后由各个写线程动态领取。任务粒度足够细既能充分利用多核又不会因为线程互相等待调度而浪费时间。3.2 写线程通过 helpTransfer 进来“搭把手”helpTransfer是整个协作扩容机制的关键入口。当一个写线程发现目标桶是 ForwardingNode 时会先看这个扩容是否还在进行中同时校验扩容戳是否匹配。条件满足就通过 CAS 把 sizeCtl 加 1表示“我加入扩容”然后进入transfer方法参与搬运。这里有个很精妙的地方每个写线程既要做自己的写入任务又要顺手当“搬运工”。它不是强制要求所有线程都来而是谁碰到了谁就帮一把。这种“路过就搭把手”的机制让扩容吞吐量能随着写并发度自动扩张。写入越密集参与的线程越多扩容反而越快不会出现“写入把 CPU 打满但扩容还卡住”的最坏情况。当某个辅助线程把自己的任务区间搬完再尝试领取新区间时发现没有可领的了它就会把 sizeCtl 减 1然后退出迁移。只有最后一个退出迁移的线程负责收尾把 table 指向新数组并清空 nextTable。这个“最后一个离开的人关门”的逻辑非常优雅。3.3 ForwardingNode迁移已完成的路标ForwardingNode 是扩容过程中一个特殊的节点类型它的 hash 固定为 -1表示 MOVED。当一个桶迁移完成后旧数组该位置会被放入一个 ForwardingNode里面保存了对新数组 nextTable 的引用。对读操作来说get 线程遍历到某个桶时如果发现桶头 hash 是 -1就知道这个桶已经搬走了于是根据 ForwardingNode 里的 nextTable 跳到新数组对应位置继续查找。整个过程没有任何锁等待读线程甚至感知不到扩容正在发生。对写操作来说ForwardingNode 则是一个“提醒信号”你准备写入的桶已经迁移先别急着写去帮一下扩容然后基于新数组继续写入。这样既保证了数据不会被写到废弃的旧桶里又让扩容能借用更多线程力量。一个节点两种语义读和写都照顾到了。4. 单个桶迁移细节处处都在省性能4.1 高低位拆分扩容后节点只去两个地方很多讲扩容的资料都会提到“节点要么在原桶要么在原桶加旧长度”这是整个迁移优化里最核心的数学基础。桶下标计算用的是hash (length - 1)。比如旧数组长度是 16二进制是 10000下标只取 hash 的低 4 位。扩容后长度变为 32二进制是 100000下标变成取 hash 的低 5 位。多出来的那一位恰好对应旧长度 16 的二进制位。于是判断一个节点该留在原下标还是搬到“原下标 16”只需要看一个条件hash 16 0 - 留在原位置 i hash 16 ! 0 - 搬到 i 16这就是高低位拆分。迁移时不需要重新计算每个节点的完整下标只需要做一次位运算性能极高。这也是为什么 ConcurrentHashMap 扩容后节点的分布能保持均衡它本质上是把原来冲突在同一桶的节点按 hash 的某个二进制位拆成两拨分别落到两个桶里。4.2 lastRun 优化能复用旧链表就复用链表桶迁移时ConcurrentHashMap 会维护两根链低位链 ln原位置用和高位链 hn新位置用。但它并没有简单地从链表头到尾遍历一遍逐个 new 新节点而是加入了一个lastRun优化。具体做法是先从链表头部开始往后扫描记录从哪个节点开始后续所有节点的(hash n)结果都是同一个值。这个节点就是 lastRun。因为 lastRun 及它后面的整段链表在高低位拆分后都会落到同一边所以这段可以整段复用不需要创建任何新节点。最后再把 lastRun 之前那些“分类会变化”的节点逐个用头插法构建到 ln 或 hn 链上。这样做的收益在长链表场景下非常明显。如果一条链表有 50 个节点其中后 30 个节点的高低位结果一致那么这 30 个节点全部直接复用只有前 20 个需要新建节点。链表越长复用比例越高GC 压力就越小。这是很多人读源码时容易忽略的性能细节。4.3 红黑树桶怎么迁移当桶里节点数量超过 8 个链表会转换成红黑树桶头是 TreeBin 节点。迁移树桶时ConcurrentHashMap 会遍历树里的所有节点仍然按(hash n)分成低位和高位两条链表同时记录两边的节点数量。如果某一条链表的节点数少于等于 6就直接退化成普通链表因为节点太少时红黑树的优势体现不出来反而增加维护成本。如果节点数仍然较多就重新构建一棵新的红黑树。整个迁移过程同样在锁桶头的 synchronized 块内完成其他桶不受影响。TreeBin 内部自身还有一个读写锁机制用来协调树结构在查询和结构调整时的并发安全问题。这部分逻辑比链表迁移复杂不少但在性能优化上它的核心思想是一致的锁粒度最小化、节点复用最大化、分类判断用位运算。5. 源码视角看 transfer 主流程5.1 transfer 的骨架如果你想系统学习 ConcurrentHashMap 的扩容建议直接读 Java 8 的transfer方法。这里我先给一个简化后的关键骨架帮大家建立全局印象private final void transfer(NodeK,V[] tab, NodeK,V[] nextTab) { int n tab.length; int stride (NCPU 1) ? (n 3) / NCPU : n; if (stride MIN_TRANSFER_STRIDE) stride MIN_TRANSFER_STRIDE; // 第一个发起扩容的线程需要先创建 nextTab if (nextTab null) { nextTab new Node[n 1]; nextTable nextTab; } // 主循环领取迁移区间 - 遍历桶 - 迁移桶 - 继续领下一段 while (true) { // ... 通过 CAS 更新 transferIndex领取一段区间 // ... 遍历区间内每个桶 // 如果桶为空CAS 放入 ForwardingNode // 如果桶头是 ForwardingNode跳过 // 否则 synchronized(f) 锁住桶头执行高低位拆分迁移 } }代码看着不算长但每个分支都有极强的并发考量。比如设置空桶为 ForwardingNode 时用的是 CAS这样可以避免多个线程同时对一个空桶做迁移标记迁移非空桶时用 synchronized 锁桶头保证同时只有一个线程在迁移同一个桶。5.2 为什么只锁桶头锁桶头这个设计是整个扩容并发模型的精髓。迁移一个桶时只要锁住旧数组该位置的桶头节点其他线程就无法在这个桶上做插入或替换操作。但这个锁只影响当前一个桶其他桶的读写仍然完全并行。熟悉 Java 并发编程的同学应该知道synchronized 在 JDK 8 之后已经有了偏向锁、轻量级锁等优化。对于桶头这种“锁持有时间极短”的场景锁竞争成本可以忽略不计。这正是 ConcurrentHashMap 敢在写路径上用 synchronized 而不是用重量级锁的原因。我在早期读源码时有一个误解以为迁移整个桶时要把链表完整复制走锁桶头只是顺手。后来才意识到桶头锁真正保护的是“当前桶的链表结构不能被并发修改”因为迁移过程中要同时读取链表节点并构建新链如果有其他线程在旧链上插入节点迁移结果就不一致。锁住了桶头这个问题就解决了。5.3 扩容期间写入操作会发生什么很多人担心扩容期间写入会被“卡住”或“丢失”。实际上写入线程的行为非常清晰如果目标桶还是普通节点或空桶写入线程会在锁桶头后直接插入旧表上的未迁移区域仍然可以正常写入。如果目标桶已经是 ForwardingNode写入线程先在 helpTransfer 里帮忙搬运一部分桶再将本地变量 tab 切换到新数组去新数组里找到对应桶继续写入。如果写入线程到达时桶正在被其他线程迁移它会先在 synchronized 上等待等迁移线程释放锁后重新检查发现桶头已经变成了 ForwardingNode于是按上一条逻辑处理。整个过程中没有任何“全局锁等待”最多只是在一个桶上短暂等待。这就是 ConcurrentHashMap 在面对扩容这种“大动作”时依然能维持高吞吐的原因。6. 实际使用中的性能优化建议6.1 初始容量要按数据量估而不是按心情我在代码评审里经常看到有人这样写ConcurrentHashMapString, Object map new ConcurrentHashMap();等到线上数据量一大容量从默认 16 开始一路扩容每次扩容都要复制整个旧表高并发下会带来非常明显的性能毛刺。正确的姿势是先预估这个 Map 最多会放多少个元素然后直接把这个预估值传给构造函数。比如你预计最多存 10 万个 key可以这么写ConcurrentHashMapString, Object map new ConcurrentHashMap(100000);ConcurrentHashMap 内部会做tableSizeFor(100000 50000 1)之类的计算最终桶数组长度对齐到 262144阈值大约在 196608足够覆盖 10 万这个量级。换句话说构造函数里的参数是“你能接受的最大元素量”而不是“桶数组长度”。估算容量时宁多勿少。多分配一些桶只会多占一点内存但能省掉一到多次扩容带来的延迟和内存抖动。尤其在低延迟交易链路里抢出来的毫秒级收益是很可观的。6.2 批量导入时如何躲开多次扩容如果你要往 ConcurrentHashMap 里灌大量数据最忌讳的是用默认容量然后 for 循环一个个 put。这种情况下Map 会随着元素增多反复扩容每次扩容都会创建新数组然后在某个时刻触发一次全表迁移。批量越大扩容次数越多GC 压力和 CPU 消耗都成倍增长。实际处理时我一般分两步。第一步先用数据量预估容量构造 Map第二步再执行批量写入。如果数据量不是一次性到位的而是分批不断追加可以在每个批次开始时关注当前 size必要时主动扩容而不是等 put 触发。不过手动判断容易出错我更推荐用一个带“容量水位线”的封装在框架里统一处理。有一种情况容易被忽略putAll在内部会调用tryPresize所以它其实已经帮你做了提前扩容。如果你能一次性把所有数据通过putAll传入性能通常比循环 put 更好。核心是别让自己写的循环 put 成为扩容风暴的导火索。6.3 keySet 视图和扩容的弱一致性热搜词里有 “concurrenthashmap keyset”这里专门说一下。ConcurrentHashMap 的keySet()返回的是一个视图不是数据快照。你拿到它之后Map 里新增或删除节点这个 Set 也能看到它和 Map 共用同一份底层数据。这个视图在扩容期间遍历时有几个特点不会抛 ConcurrentModificationException因为迭代器走的是弱一致性路径。遍历过程中可能遇到正在迁移的桶迭代器会跟随 ForwardingNode 去新数组继续扫描所以结果不能保证是某个时间点的精确快照。并发写入越频繁遍历结果越“模糊”。如果业务要求必须导出一份精确的 key 清单比如要对账、批量发消息不要直接依赖 keySet 遍历结果。比较稳妥的做法是业务自己用额外的 ConcurrentHashMap 或状态位维护一个“全量 key”的最终一致集合或者接受弱一致性。这个点在很多高并发对账场景中都是坑。7. 线上故障排查与监控实录7.1 线程 dump 一片 transfer 是死锁吗有一次线上排查服务线程池里大量线程栈都停在ConcurrentHashMap.transfer方法上第一眼很容易误判为死锁。其实线程 dump 里大量出现 transfer 未必是坏事它说明这些线程正在协作扩容属于正常的高并发协助行为。但线程长时间“卡”在 transfer就值得警惕了。可能的原因有三种扩容触发太频繁线程总是在帮扩容实际业务写入被延后。数组长度极大即使多线程协作单轮迁移也需要很长时间。线程数不够或者写线程比例低能参与扩容的“帮工”太少。排查思路是先统计 map 的写入量和当前容量看看有没有连续触发多轮扩容再用 jstack 多次抓取线程栈确认 transfer 是否稳定出现最后配合 GC 日志和监控曲线判断是 CPU 高还是暂停时间长。7.2 瞬时双倍内存与 GC 抖动扩容期间旧数组和新数组会同时存在直到全部迁移完成才把 table 指针切到新数组。这意味着瞬时内存占用会接近“旧表 新表”的总和。如果你已经在一个 Map 里放了上千万个 key一次扩容可能额外吃掉几十 GB 内存。这类问题最典型的表现是老年代内存曲线出现一个陡坡随后 GC 次数明显增多如果内存不够还可能直接触发 Full GC导致整个服务停顿好几秒。我在一次大数据导入任务里就遇到过这个问题Map 里放了两千多万个对象默认容器内存不够扩容时直接把 JVM 压到了 Full GC。解决方式不复杂评估 Map 最大容量初始容量给定足够大同时为 JVM 留足内存余量。大型 Map 的扩容不是“数组翻倍”那么简单背后还跟着巨大的 GC 成本和对象分配开销必须提前做容量规划。7.3 JFR 和火焰图定位扩容热点现在排查 Java 线上性能问题我强烈推荐 JFR。它可以记录方法级的调用事件定位到哪些线程在哪个方法上花掉了大量时间。如果 ConcurrentHashMap 的addCount、transfer出现频率很高就能明确判断是扩容引起了性能问题。用火焰图也能看得非常清楚。采样一段时间后如果transfer的栈占比很大说明扩容相关开销已经成为了当前系统的主要瓶颈。这时再去业务代码里找哪里在无脑写入大 Map通常能很快定位到问题源头。我还习惯配合 jstat 看老年代增长曲线。如果老年代增长与“某个 Map 扩容”的时点高度吻合那基本可以锁定元凶。性能优化不怕问题复杂怕的是没有数据支撑、全靠猜。8. 我沉淀下来的几个使用习惯8.1 先估算再构建现在不管写什么代码只要涉及 ConcurrentHashMap我第一件事就是估算最大容量。哪怕估算得不太准也比用默认容量裸奔好。这一步的成本极低但收益立竿见影。尤其是 Redis 缓存回源、配置下发、白名单这类会有瞬时大流量的场景初始容量到位了扩容抖动自然就少了。8.2 复用实例比新建实例更省事如果业务里有一个 Map 会周期性清空重建不要频繁 new 新的 ConcurrentHashMap。因为反复新建意味着每次都要重复“小容量到大容量”的扩容路径。保留同一个实例用 clear 清空桶数组长度还在下次再写入时能省掉很多扩容损耗。当然如果旧实例过大了该释放还得释放不然会造成内存浪费。8.3 源码不看死要带着性能问题去读最后分享一个阅读源码的心得。ConcurrentHashMap 的源码绕来绕去很容易把人绕晕。我自己的方法是不按顺序逐行读而是先想清楚问题多线程怎么通信、锁怎么控制、迁移怎么切分。带着这些问题再去看 transfer、helpTransfer、addCount 这几个核心方法逻辑一下就通了。这里再分享一个小技巧多线程协作扩容这种设计不只存在于 ConcurrentHashMap。你在做任何需要“多个 worker 分片处理大任务”的系统时都可以借鉴类似的思路——维护一个全局共享进度指针用 CAS 抢占任务区间任务完成后由最后一个线程统一收尾。这套模型在很多分片任务框架里都是通用的学一次受用很久。
返回列表