ConcurrentHashMap 为什么在 JDK 1.8 中废弃分段锁?

#Java#面试八股

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 数组 + HashEntryNode 数组,无额外段对象

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 支持协作扩容,把并发度与表长度解耦,同时顺手引入红黑树。回答时抓住“固定段数的上限”和“桶级锁的细粒度”这两个关键词,再补上序列化兼容这一细节即可。

笔记加载中…