资讯动态

ConcurrentHashMap并发度详解:从JDK7分段锁到JDK8桶级锁

发布时间:2026/10/13 2:33:01 来源:尧图企业网站定制
ConcurrentHashMap 的并发度是什么这个问题我一年里能见到几十次面试官爱问技术群常聊代码评审里也偶尔有人抛出来。我每次都得先确认一句——你问的是 JDK 7 还是 JDK 8这两个版本对并发度的答案其实是完全不同的两个故事。JDK 7 的答案很直接默认并发度 16写在构造器参数里JDK 8 的答案则变成了一套机制需要从 CAS、桶锁、扩容迁移这些设计里去理解。这篇文章就围绕这个关键点展开把这几个层次一次讲透并发度到底由什么决定、JDK 8 里怎么把它用好、以及你在实际业务里碰到并发度相关的性能问题该怎么排查。适合正在用 ConcurrentHashMap 做并发开发的工程师也适合面试前想把这个知识点彻底搞明白的同学。1. 先搞清楚 JDK 7 的并发度 16是怎么来的很多人的记忆还停留在 JDK 7。那时候的 ConcurrentHashMap 内部结构是一个 Segment 数组每个 Segment 是一个小型的哈希表继承自 ReentrantLock自带锁。构造器里有一个参数 concurrencyLevel默认值是 16这个 Segment 数组的长度也会取到大于等于这个值的最小 2 的幂。所以当年官方文档里并发度的定义非常明确Segment 数组的长度也就是最多能支撑多少个线程同时写而互不阻塞的上限。1.1 分段锁到底锁了什么JDK 7 的 put 流程是先用哈希定位到某个 Segment然后 lock 这个 Segment再进入 Segment 内部去做和 HashMap 差不多的插入操作。读取操作在大多数情况下不需要加锁因为 key 对应的 value 本身是 volatile 的。分段锁的设计思路在那个年代是对的把整张表分成 16 段最多 16 个线程不同段并发写。但真正用起来就会发现两个问题。第一个问题锁粒度还是粗。一个 Segment 内部往往有多达几十个桶。假设两个线程分别写同一个 Segment 里不同桶的两个 key它们依然要竞争同一把锁。并发度 16 只代表最多有 16 个区域并行不代表任意 16 个写操作都能并行。第二个问题内存开销大。每个 Segment 都是一套独立的数组和扩容逻辑16 个 Segment 就意味着至少 16 个内部数组再加上锁对象、阈值计算等开销整体内存占用比 JDK 8 明显高。这个代价在小内存的场景下尤其难受。1.2 JDK 7 的并发度参数是个推荐值这里有个很多文章没说透的细节concurrencyLevel 是用户想要的并发度但 Segment 数组实际长度会按大于等于该值的 2 的幂处理。比如你传 15实际 Segment 长度是 16你传 17实际是 32。所以并发度并不是精确的它是一档一档跳的。另外concurrencyLevel 只影响 Segment 的个数不影响每个 Segment 内部的桶数量。每个 Segment 内部数组的初始容量由 initialCapacity / concurrencyLevel 决定。这个细节在做容量预估的时候很容易被忽略你以为给了足够的容量但如果并发度参数不合理单个 Segment 内部仍然会频繁扩容。1.3 为什么 JDK 8 把 Segment 整个删掉了JDK 8 的 ConcurrentHashMap 是推倒重来的Segment 没了取而代之的是 Node 数组加 CAS 加 synchronized锁粒度从一个 Segment 区域缩小到单个桶的链表头或树根并发度从一个固定数字变成由桶数量和哈希分布共同决定。synchronized 在 JDK 6 之后引入了偏向锁、轻量级锁等一系列优化性能已经不输 ReentrantLock。更重要的是在 JDK 8 的设计里锁对象就是桶头节点本身不需要额外维护一套锁结构内存开销降下来了锁粒度也细下去了。这就是为什么 JDK 8 敢用 synchronized。注意一个细节JDK 8 的构造器里其实还保留了 concurrencyLevel 参数但它的含义已经变了。它不再是并发度而是在你没有显式传入 initialCapacity 时用来推算初始容量的一个依据。这是个典型的面试点很多人不知道。对比维度JDK 7JDK 8锁粒度Segment 区域单个桶链表头/树根并发度定义Segment 数组长度默认 16无法用单一数字定义写操作lock Segment 后操作空桶 CAS非空桶 synchronized扩容方式每个 Segment 独立扩容全局数组整体迁移支持多线程协助内存开销较高较低2. JDK 8 的并发度是一套机制不是一个数字2.1 结构决定并发边界JDK 8 的 ConcurrentHashMap 内部是一个 Node 数组。每个桶有两种主要状态要么是 null空桶要么是链表头或红黑树根节点。写一个 key 时哈希定位到某个桶如果这个桶是空的用 CAS 直接把新节点放进去不需要加锁。如果这个桶已经有节点了就对桶头节点执行 synchronized然后进链表或红黑树操作。CAS 是无锁操作不阻塞任何线程synchronized 只锁住单个桶同一时刻其他桶的写操作完全不受影响。所以 JDK 8 的并发度没法用一个数字概括正确的问题是两个线程的哈希恰好落到同一个桶的概率有多高这个概率由桶数量和哈希分布决定而桶数量由初始容量决定。2.2 put 流程里的两阶段设计我把 putVal 的流程简化一下方便理解// 1. 对 key 的 hashCode 做 spread 处理让高位也参与低位计算 int hash spread(key.hashCode()); // 2. 定位桶 index(n - 1) hash // 3. 桶为 nullCAS 放入新节点成功就返回 // 4. 桶头是 ForwardingNode说明正在扩容当前线程协助迁移 // 5. 桶非空synchronized 锁住桶头节点 // - 遍历链表key 存在就覆盖不存在就追加 // - 长度达到 8 且数组容量达到 64 时转为红黑树 // - 长度达到 8 但数组容量不足 64 时先扩容 // 6. 累加元素计数必要时触发扩容这里的重点是第 3 步和第 5 步的配合。为什么桶为空时用 CAS而不是直接 synchronized因为 CAS 能把检查 写入合并成一个原子操作对于大量新 key 写入且哈希分散的场景绝大多数 put 都不需要真正进入锁等待并发能力非常高。CAS 失败说明在 CAS 之前有别的线程抢先放入了节点这时候再去锁住这个桶继续操作。有一点容易被忽略spread 这一步很重要。它把高位哈希扩散到低位而不是简单用 hashCode 的低位取模。否则当 key 的 hashCode 分布规律比较差的时候桶的利用率会很低锁竞争会集中在少数桶上。2.3 无锁读与扩容期间的读get 操作全程不获取锁依赖两个保证Node 数组引用是 volatileNode 的 value 字段是 volatile。volatile 保证了跨线程可见性一个线程写入 value另一个线程能立即看到。扩容期间的 get 稍微特殊一点。JDK 8 扩容时迁移完成的桶会在旧数组里放一个 ForwardingNode指向新数组。此时 get 到这个桶会跟着 ForwardingNode 跳到新数组继续查找。所以读操作既不会阻塞也不会死锁最多出现一瞬间的旧状态读取这是 ConcurrentHashMap 的弱一致性设计允许的范围。2.4 扩容机制决定并发度会动态变化JDK 8 的扩容是支持多线程协助的。触发扩容后会把整个数组按 stride 分成若干段每个参与扩容的线程领取一段进行迁移迁移完的桶放上 ForwardingNode。新来的写线程如果碰到 ForwardingNode也会主动帮一把。这个设计带来的影响是扩容期间部分桶会被持续上锁迁移整体并发度会短时下降。如果扩容频繁发生你观察到的并发度会一直处于波动的状态。所以配置初始容量避免运行期频繁扩容是保证并发度稳定的第一步。我之前写过一个性能测试两个场景对比非常明显一个 ConcurrentHashMap 初始容量按预估元素数配置全程没有扩容另一个初始容量只给了预估值的十分之一运行到一半疯狂扩容。前者吞吐量稳如老狗后者的线程调度和锁竞争开销直接把吞吐量打掉了 30% 以上。这不是危言耸听扩容对并发的冲击比我预想的大得多。3. 实操让并发度可控的几个关键配置3.1 initialCapacity 的估算公式ConcurrentHashMap 的懒加载机制很讨厌构造器传入容量时并不会真正创建数组而是把它存在 sizeCtl 里第一次 put 时才初始化。所以如果你不传容量默认就只有 16 个桶写入一定量数据后必然触发扩容。官方注释里给了明确的容量估算公式// 期望存储的元素数量 int expectedSize 1_000_000; // 按默认负载因子 0.75 计算再加 1 防止取整问题 int initialCapacity (int) (expectedSize / 0.75f) 1; ConcurrentHashMapString, Object map new ConcurrentHashMap(initialCapacity);为什么除以 0.75因为默认负载因子是 0.75元素数量达到容量的 75% 时就会触发扩容。如果你直接把 expectedSize 当容量传进去表面上看够了实际上在存储量达到 75% 时就提前扩容了。加上负载因子的倒数可以保证在元素数到达之前不触发扩容。再补一句ConcurrentHashMap 内部会调用 tableSizeFor 把容量修正为 2 的幂。比如你传 1333334实际桶数量是 2097152也就是 2 的 21 次方。看起来多用了内存但换来的是运行期不扩容写操作尽可能走 CAS。预估元素数直接传 expectedSize按公式计算实际桶数组2 的幂100,000100,000133,334262,1441,000,0001,000,0001,333,3342,097,1523.2 不同场景怎么选容量我常用的经验值分场景说一下已知元素数量的缓存场景直接套公式initialCapacity (expected / 0.75f) 1。比如缓存 10 万条配置就传 133334。写入量波动大的场景比如消息处理元素可能在短时间内从一万涨到百万建议按峰值预估宁可容量多 50%也别让运行期扩容。长期作为已维护集合的场景如果元素只增不减且数量明确不用负载因子反推直接把容量设成元素数的 1.34 倍左右。容量不是越大越好。桶数组越大内存占用越高而且每次扩容扫描的成本也更高。这个要在内存和并发度之间权衡。3.3 那个隐藏参数——concurrencyLevel前面说过 JDK 8 里 concurrencyLevel 已经不决定并发度了。它在构造器里保留下来但只在你没传 initialCapacity 时作为初始容量推算的一个因素存在。有些老项目代码迁移到 JDK 8 时还在 new ConcurrentHashMap(16, 0.75f, 16) 这么写第三个参数现在基本不是你想要的那个含义了。更准确地说JDK 8 中传入的 concurrencyLevel 只会影响初始容量的推算如果你已经显式传了 initialCapacity第三个参数几乎等于被忽略。翻译成实际建议在 JDK 8 里初始化 ConcurrentHashMap最值得关注的参数就是 initialCapacityconcurrencyLevel 参数留着兼容但它已经不是并发度了。4. 常见并发度问题与排查实录4.1 热点 key 会让并发度瞬间失效最典型的案例是秒杀场景里的库存 key。假设 ConcurrentHashMap 里有一个 key 被所有线程读写那么无论你桶的数量是 64 还是 65536所有线程都会锁在同一个桶头上整体并发表现和全局锁几乎没有区别。排查方法先用 jstack 抓线程栈看大量线程是不是阻塞在同一个 Monitor 上再用火焰图看锁竞争热点集中在哪个桶。定位到热点 key 后解决思路一般有两个一是从业务上把 key 拆散比如按用户 ID 分段写入时分别累加读取时再汇总二是用一个专门的分段计数器做统计而不是频繁更新同一个 key 的 value。4.2 扩容导致的性能抖动扩容抖动在上线后的运行期很容易遇到现象是系统跑了一会儿突然吞吐下降然后慢慢恢复。这个一会儿通常就是扩容正在发生的窗口。扩容要迁移整个桶数组迁移过程会申请新数组、搬运节点、还要处理锁开销非常大。解决方式有两个方向。第一从源头避免用 3.1 的公式预估容量让你的数据量永远到不了扩容阈值。第二如果实在避免不了就尽量把扩容安排在业务低峰期预写数据触发扩容把数据量冲到目标水位让线上运行期稳定。4.3 弱一致性导致读旧值不是 BugConcurrentHashMap 保证的是并发安全不是强一致性。get 在扩容瞬间可能读到旧值size() 在并发写入时也可能返回近似值而不是精确值。因为 size() 的实现用了 baseCount 加 CounterCell 分段计数并且不会锁全表。这个特性在缓存场景完全没问题但如果你拿它当数据库一样的强一致结构用就会踩坑。一个很好的例子是计数器如果你依赖 size() 判断是否到达某个阈值在高并发写入下可能误判。换成业务自己维护一个 LongAdder 做计数会比依赖 size() 靠谱得多。4.4 并发度与 CPU、内存的实际关系最后聊个很多人理解偏了的点并发度高的前提是线程能被 CPU 调度而不是无限增大桶数量。锁竞争严重时线程会让出 CPU、触发上下文切换当线程数远大于 CPU 核数时再多线程也只是排队。所以实际工程里桶数量不需要盲目追求大。只要桶数量远大于活跃写线程数哈希分布均匀并发度已经足够了。真正让并发度失效的往往不是桶不够多而是哈希分布不均匀、热点 key、扩容频繁这三个问题。我个人做高并发存储这块的经验是别再去背并发度16这种标准答案了面试里能答出来加分但工程里真正决定并发能力的是容量配置、哈希分布、热点控制和扩容策略。用好 initialCapacity 的估算公式、避开热点 key、预判扩容窗口你的 ConcurrentHashMap 在绝大多数场景下都能跑得很稳。最后一个小建议每次上线前都花十分钟用多线程压一下 put 的吞吐和锁竞争数据会告诉你并发度够不够。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑