JDK 8 废弃 Segment 是因分段锁粒度粗、争用高、内存开销大;改用 CAS + synchronized 锁单个桶头节点,提升吞吐、降低内存占用,并通过 baseCount + CounterCell[] 实现弱一致性 size()。

ConcurrentHashMap JDK 8 为什么不用 Segment 了
因为分段锁(Segment)在高并发写场景下成了瓶颈,而 JDK 8 改用 CAS + synchronized 锁单个桶(bin),实际吞吐更高、内存更省。
分段锁本质是把哈希表切成 16 个(默认)独立锁,但随着 CPU 核心数增加和写操作变多,Segment 的粒度太粗——多个线程仍可能争抢同一个 Segment;同时每个 Segment 都要维护自己的计数器、扩容逻辑,冗余开销大。
- JDK 7 中
ConcurrentHashMap的put()会先定位到Segment,再在其内部加锁,锁的是整个段,不是具体 key - JDK 8 中
put()定位到数组索引后,若桶为空则 CAS 插入;若桶有值且未加锁,就用synchronized锁住该Node头节点(即锁单个链表/红黑树头) -
size()在 JDK 7 是遍历所有Segment求和,JDK 8 改用baseCount+CounterCell[]分散计数,避免读时锁竞争
ConcurrentHashMap put() 在 JDK 8 中到底锁谁
只锁发生冲突的那个桶的头节点(Node 或 TreeBin),不是锁整个 table,也不是锁 key 所在的链表全部节点。
这个设计让多线程往不同桶写几乎不互斥,只有哈希碰撞严重、多个 key 落到同一桶时才可能排队。但注意:锁的是头节点引用,不是内容,所以插入、扩容、树化都靠它串行化。
立即学习“Java免费学习笔记(深入)”;
- 空桶:直接 CAS 设置
tab[i],无锁 - 非空桶且未扩容:
synchronized (f),其中f = tab[i]是该桶第一个Node - 正在扩容:
ForwardingNode会引导线程协助迁移,此时锁的是扩容控制节点transferIndex相关的共享状态,不是数据桶 - 红黑树桶:锁的是
TreeBin对象(包装了红黑树根节点),不是TreeNode本身
从 JDK 7 升级到 JDK 8 后 size() 变慢了?
不会变慢,反而更稳定;但语义变了——JDK 8 的 size() 是弱一致性计数,不保证实时精确,尤其在高并发写时可能滞后几毫秒。
这是因为 JDK 8 放弃了全局加锁统计,改用分散更新:baseCount 记录基础值,冲突时写入 CounterCell[] 数组,size() 要遍历数组累加。这避免了读写互斥,但代价是「最终一致」。
- 如果业务依赖强一致的大小判断(比如“必须等 size == 100 才触发动作”),不能直接用
size(),得配合get()或自定义计数器 -
mappingCount()和size()行为一致,只是返回long,推荐新代码用它 - 频繁调用
size()不会阻塞写操作,但极端情况下(如大量线程同时调用)可能触发CounterCell数组扩容,有轻微开销
ConcurrentHashMap 在 JDK 8 中扩容时怎么保证线程安全
靠 ForwardingNode 标记 + 多线程协作迁移,不阻塞读写,也不用全局锁。
当某个线程发现容量不足,会尝试设置 sizeCtl 为负数(表示扩容中),然后带头迁移;其他线程遇到 ForwardingNode(hash 值为 -1)就知道该桶已迁移或正在迁移,会去帮着搬别的桶,而不是等待。
- 每个线程通过
transferIndex原子减量领取待迁移的桶区间,避免重复搬、漏搬 - 读操作(
get())遇到ForwardingNode会直接跳转到新表查询,不影响正确性 - 写操作(
put())遇到ForwardingNode也会先协助迁移,再重试插入,确保新旧表状态收敛 - 扩容不是一步到位,而是渐进式,所以单次操作延迟可控,但整个扩容过程可能持续多个 CPU 时间片
真正容易被忽略的是:扩容期间 size() 的结果可能包含新旧两表的计数混合,且 containsKey() 或 get() 虽然能正确返回,但中间态可能导致某次遍历(如 keySet().iterator())看到部分旧数据、部分新数据——这不是 bug,是设计使然。



















