ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

"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. 桶为 null:CAS 放入新节点,成功就返回 // 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 数组引用是 volatile,Node 的 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; ConcurrentHashMap<String, 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,144
1,000,0001,000,0001,333,3342,097,152

3.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,最值得关注的参数就是 initialCapacity,concurrencyLevel 参数留着兼容,但它已经不是"并发度"了。

4. 常见并发度问题与排查实录

4.1 热点 key 会让并发度瞬间失效

最典型的案例是秒杀场景里的库存 key。假设 ConcurrentHashMap 里有一个 key 被所有线程读写,那么无论你桶的数量是 64 还是 65536,所有线程都会锁在同一个桶头上,整体并发表现和全局锁几乎没有区别。

排查方法:先用 jstack 抓线程栈,看大量线程是不是阻塞在同一个 Monitor 上;再用火焰图看锁竞争热点集中在哪个桶。定位到热点 key 后,解决思路一般有两个:一是从业务上把 key 拆散,比如按用户 ID 分段,写入时分别累加,读取时再汇总;二是用一个专门的分段计数器做统计,而不是频繁更新同一个 key 的 value。

4.2 扩容导致的性能抖动

扩容抖动在上线后的运行期很容易遇到,现象是:系统跑了一会儿,突然吞吐下降,然后慢慢恢复。这个"一会儿"通常就是扩容正在发生的窗口。扩容要迁移整个桶数组,迁移过程会申请新数组、搬运节点、还要处理锁,开销非常大。

解决方式有两个方向。第一,从源头避免:用 3.1 的公式预估容量,让你的数据量永远到不了扩容阈值。第二,如果实在避免不了,就尽量把扩容安排在业务低峰期:预写数据触发扩容,把数据量冲到目标水位,让线上运行期稳定。

4.3 弱一致性导致"读旧值",不是 Bug

ConcurrentHashMap 保证的是并发安全,不是强一致性。get 在扩容瞬间可能读到旧值;size() 在并发写入时也可能返回近似值而不是精确值。因为 size() 的实现用了 baseCount 加 CounterCell 分段计数,并且不会锁全表。

这个特性在缓存场景完全没问题,但如果你拿它当数据库一样的强一致结构用,就会踩坑。一个很好的例子是计数器:如果你依赖 size() 判断是否到达某个阈值,在高并发写入下可能误判。换成业务自己维护一个 LongAdder 做计数,会比依赖 size() 靠谱得多。

4.4 并发度与 CPU、内存的实际关系

最后聊个很多人理解偏了的点:并发度高的前提是线程能被 CPU 调度,而不是无限增大桶数量。锁竞争严重时,线程会让出 CPU、触发上下文切换;当线程数远大于 CPU 核数时,再多线程也只是排队。

所以实际工程里,桶数量不需要盲目追求大。只要桶数量远大于活跃写线程数,哈希分布均匀,并发度已经足够了。真正让并发度失效的,往往不是桶不够多,而是哈希分布不均匀、热点 key、扩容频繁这三个问题。

我个人做高并发存储这块的经验是:别再去背"并发度=16"这种标准答案了,面试里能答出来加分,但工程里真正决定并发能力的是容量配置、哈希分布、热点控制和扩容策略。用好 initialCapacity 的估算公式、避开热点 key、预判扩容窗口,你的 ConcurrentHashMap 在绝大多数场景下都能跑得很稳。最后一个小建议:每次上线前都花十分钟,用多线程压一下 put 的吞吐和锁竞争,数据会告诉你并发度够不够。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进