ConcurrentHashMap 为什么在 JDK 1.8 中废弃分段锁?
结论先行:分段锁把表切成固定数量的 Segment,每段一把 ReentrantLock,写并发度被锁死在段数上(默认 16),段对象本身还有额外内存与两次下标寻址的开销。JDK 1.8 改成“桶级锁”:空桶 CAS、冲突桶 synchronized 锁桶头,并发度随表长度增长,还能多线程协助扩容,因此 Segment 被废弃。
1. JDK 1.7 的分段结构
ConcurrentHashMap (1.7)
┌─────────────────────────────────────────────────┐
│ Segment[0] Segment[1] ... Segment[15] │ 每段一把 ReentrantLock
│ ├ HashEntry[] ├ HashEntry[] ├ HashEntry[] │
│ └ lock() └ lock() └ lock() │
└─────────────────────────────────────────────────┘
一次 put:hash → 段下标 → 段内桶下标 → lock 整段 → 写 → unlock
2. 两代实现的对比
| 维度 | 1.7 分段锁 | 1.8 桶级锁 |
|---|---|---|
| 锁对象 | Segment(继承 ReentrantLock) | 桶头 Node(synchronized) |
| 并发写度 | 最多 16,构造后固定 | 约等于桶数量,随扩容增长 |
| 空桶写入 | 也要锁整段 | CAS,完全无锁 |
| 读取 | volatile,无锁 | volatile,无锁 |
| 计数 | 段内 count + 重试 | baseCount + CounterCell |
| 扩容 | 段内 rehash,单线程 | 全表 transfer,多线程协助 |
| 冲突结构 | 纯链表 | 链表 + 红黑树 |
| 内存开销 | Segment 数组 + HashEntry | Node 数组,无额外段对象 |
3. 固定段数的三个硬伤
第一,并发上限被写死。 concurrencyLevel 默认 16,段数在构造时就确定,之后不能变。表扩容到 4096 个桶,写并发仍然只有 16;32 个线程同时写,平均每段两个线程排队。
第二,锁粒度粗。 同一段内不同桶的写也要串行,热点 key 落在哪一段,哪一段就成为瓶颈;段内还要维护 count、modCount,进一步限制扩展性。
第三,多一次间接寻址和额外对象。 每次读写先算段下标、再算段内下标,多一次数组跳转;每个 Segment 自己持有锁状态、计数、阈值,内存占用比纯 Node 数组高。
1.7:16 个段,32 个写线程 → 平均每段 2 个线程排队
1.8:容量 1024 的表,32 个写线程
→ hash 分散时几乎不排队,只有撞到同一个桶才互斥
4. 1.8 的替代方案
// 空桶:CAS 一步放入,不产生锁
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}
// 冲突桶:只锁桶头,锁内再检查一次桶头是否被替换
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
// 链表尾插或树插入
}
}
}
- 锁对象从“段”降到“桶头节点”,不同桶完全独立。
- 空桶连 CAS 都省了锁,冲突桶才进入同步块,临界区极短。
- 扩容时用
ForwardingNode标记已迁移的桶,其他线程可以helpTransfer一起搬。 - 树化后锁
TreeBin头节点,读路径仍无锁。
5. 代码:并发写入不再受段数限制
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
public class ChmConcurrency {
public static void main(String[] args) throws InterruptedException {
ConcurrentHashMap<Integer, Integer> map = new ConcurrentHashMap<>(1 << 12);
LongAdder ops = new LongAdder();
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
map.put(i, i); // 不同线程写不同 key,落在不同桶
ops.increment();
}
};
Thread a = new Thread(task);
Thread b = new Thread(task);
a.start();
b.start();
a.join();
b.join();
System.out.println(map.size() + " / " + ops.sum()); // 输出:100000 / 200000
}
}
两个线程写同样的 key 区间,最终 size 仍是 100000,说明并发写入没有丢数据;ops 用 LongAdder 统计总操作数,避免引入新的计数瓶颈。
6. 为什么还保留 Segment 类
只为兼容旧版本的序列化格式:readObject 里仍按段读取老数据,再转换到新结构;运行时的锁机制已经与它无关。源码注释也说明它仅用于序列化兼容,不要在新代码中引用。
常见追问
- 1.8 还用 ReentrantLock 吗? 不用。空桶 CAS、冲突桶
synchronized;TreeBin内部另有一套读写状态位。 - 分段锁的思想过时了吗? 没有,
LongAdder的 Cell 数组、数据库分库分表都是分段思路;只是 CHM 这个场景桶级锁更合适。 - 为什么
synchronized比ReentrantLock合适? 锁对象是桶头、临界区极短,JVM 内建锁经过优化后更轻,且代码更简洁。 - 并发度还能通过构造参数控制吗? 1.8 的
concurrencyLevel只用于估算初始容量,不再决定锁的数量。 - 表很大时扩容会不会很慢? 多线程协助迁移,单线程只搬自己认领的那部分桶。
- 所有 key 都撞同一个桶会怎样? 退化为锁同一个桶头,但红黑树保证单桶操作 O(log n),不会像链表那样线性退化。
小结
废弃分段锁的根本原因是“锁的粒度跟不上表的规模”:段数固定、并发上限固定、还有额外对象开销。1.8 用 CAS 处理空桶、用桶头锁处理冲突、用 ForwardingNode 支持协作扩容,把并发度与表长度解耦,同时顺手引入红黑树。回答时抓住“固定段数的上限”和“桶级锁的细粒度”这两个关键词,再补上序列化兼容这一细节即可。