Skip to content

ConcurrentHashMap 1.7 vs 1.8 演进

为什么需要 ConcurrentHashMap?

HashMap 在并发场景下会出三个问题:

  • 死循环(JDK 1.7 头插法导致扩容时环形链表)—— 线上真实烧过 CPU,后面细说
  • 数据丢失(并发 put 覆盖)
  • 数据不一致(get 读到中间状态)

Hashtable 用 synchronized 锁住整个方法,并发直接退化到串行。我见过一个老项目,线程池 200 个线程,Hashtable 一压,CPU 跑不满,TPS 只有 200/s,因为 199 个线程在等锁。

ConcurrentHashMap 填补了这段空白:高并发 + 高性能 + 线程安全

JDK 1.7 实现:Segment 分段锁

整体结构

ConcurrentHashMap
├── Segment[]          ← 默认 16 个,继承 ReentrantLock
│   ├── HashEntry[]    ← 每个 Segment 内部维护一个哈希表
│   │   ├── HashEntry(key, value, hash, next)
│   │   ├── HashEntry(...)
│   │   └── ...
│   ├── HashEntry[]
│   │   └── ...
│   └── ...

关键点:

  • Segment 继承 ReentrantLock,写操作先获取 Segment 的锁
  • 默认 16 个 Segment,理论最大并发度 = 16(实际受哈希分布影响,远低于 16)
  • HashEntryvaluenextvolatile 修饰,保证读的可见性

put 流程

java
// JDK 1.7 ConcurrentHashMap.put()
public V put(K key, V value) {
    Segment<K,V> s = segmentForHash(hash(key));  // 定位到哪个 Segment
    return s.put(key, hash, value, false);
}

// Segment.put()
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    // 先尝试获取锁,自旋 + 阻塞
    HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
    try {
        // 已获取锁,操作 Segment 内部的 HashEntry 数组
        int index = hash & (table.length - 1);
        HashEntry<K,V> first = table[index];
        // 遍历链表,找到则替换,否则插入
        // ...
    } finally {
        unlock();
    }
}

scanAndLockForPut 是 1.7 的优化:先自旋尝试获取锁,如果自旋次数超过阈值(MAX_SCAN_RETRIES,单核 1 次/多核 64 次),才进入阻塞等待。这种自旋 + 阻塞的锁策略在低竞争时性能更好。

get 流程

java
public V get(Object key) {
    int hash = hash(key);
    Segment<K,V> s = segmentForHash(hash);
    HashEntry<K,V>[] tab = s.table;
    int index = hash & (tab.length - 1);
    HashEntry<K,V> e = tab[index];
    while (e != null) {
        if (e.hash == hash && key.equals(e.key)) {
            return e.value;
        }
        e = e.next;
    }
    return null;
}

get 全程无锁。依靠 HashEntry.valueHashEntry.nextvolatile 语义保证可见性。

扩容

java
// Segment.rehash() — 每个 Segment 独立扩容
private void rehash(HashEntry<K,V> node) {
    HashEntry<K,V>[] oldTable = table;
    int oldCapacity = oldTable.length;
    int newCapacity = oldCapacity << 1;  // 翻倍
    // 重建新数组,重新散列所有元素
    HashEntry<K,V>[] newTable = HashEntry.newArray(newCapacity);
    // ...
}

1.7 的扩容是每个 Segment 独立进行,不会影响其他 Segment 的读写。但扩容时该 Segment 被锁住,其他线程对该 Segment 的写操作会阻塞。

1.7 的问题

  • 并发度上限固定为 16(Segment 数组长度)
  • 小 Segment 内哈希冲突严重时,链表遍历 O(n)
  • 锁力度还是偏粗,一个 Segment 下所有桶共享一把锁
  • 扩容时该 Segment 不可写,如果某个热点 key 正好在那个 Segment 里,写入直接卡住

JDK 1.8 实现:Node + CAS + synchronized

整体结构

ConcurrentHashMap
├── Node[]            ← 直接数组,不再有 Segment
│   ├── Node(key, value, hash, next)    ← 链表节点
│   ├── TreeNode(key, ..., parent, left, right)
│   │   └── TreeBin(root, waiter)        ← 红黑树封装
│   └── ForwardingNode                   ← 扩容时的转发节点(hash == MOVED)

1.8 直接使用 Node 数组,锁粒度从 Segment 降到单个桶

putVal 流程

java
final V putVal(K key, V value, boolean onlyIfAbsent) {
    int hash = spread(key.hashCode());
    int binCount = 0;

    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;

        if (tab == null || (n = tab.length) == 0)
            // ① 懒初始化:CAS 设置 sizeCtl,避免锁
            tab = initTable();

        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // ② 桶为空:CAS 直接写入,无锁!
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                break;
        }

        else if ((fh = f.hash) == MOVED)
            // ③ 正在扩容:帮助迁移,不等待
            tab = helpTransfer(tab, f);

        else {
            V oldVal = null;
            // ④ 桶不为空:synchronized 锁住头节点
            synchronized (f) {
                if (tabAt(tab, i) == f) {  // double-check
                    if (fh >= 0) {
                        // 链表遍历
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            // ...
                        }
                    } else if (f instanceof TreeBin) {
                        // 红黑树插入
                        // ...
                    }
                }
            }
        }
    }

    // ⑤ 计数:用 CounterCell 热点分离
    addCount(1L, binCount);
    return null;
}

putVal 时序图(正常写入 + 扩容协助两种场景):

text
场景一:空桶 CAS 写入
┌─────────┐          ┌──────────────┐
│ Thread A │          │ ConcurrentHashMap │
├─────────┤          ├──────────────┤
│ put(K,V) │          │               │
│  spread hash ├─────►│ tabAt 读桶    │
│          │◄─────────│ 桶 == null    │
│          ├─────►───│ casTabAt 写入 │
│          │◄─────────│ CAS 成功 ✅   │
│          │          │               │
│  break   │          │               │
└─────────┘          └──────────────┘

场景二:扩容中,当前线程协助
┌─────────┐          ┌──────────────┐
│ Thread B │          │ ConcurrentHashMap │
├─────────┤          ├──────────────┤
│ put(K,V) │          │               │
│    ├─────►───────│ tabAt 读桶     │
│    │     │◄─────────│ ForwardingNode │
│    │     │          │ (hash == MOVED)│
│    │     ├─────►───│ helpTransfer  │
│    │     │          │ 协助迁移      │
│    │     │          │ 同步等待      │
│    │     │◄─────────│ 迁移完成      │
│    │     │          │ 继续 put      │
│    │  retry│        │               │
│    │  for  │        │               │
│    │  loop │        │               │
└─────────┘          └──────────────┘

四个关键设计:

① 懒初始化initTable() 通过 CAS 竞争 sizeCtl 变量,只有一个线程能初始化数组,其余线程 yield() 让出 CPU。

② CAS 写空桶:如果桶为空,直接 compareAndSwap 写入,完全无锁。这是最频繁的路径,也是 1.8 性能提升的核心。实测空桶写入占比约 80%+。

③ 协助扩容:如果检测到 ForwardingNode(hash == MOVED),当前线程不等待,而是调用 helpTransfer 参与数据迁移。变被动等待为主动协助,大幅降低扩容时的阻塞时间。

④ synchronized 锁头节点:锁的对象是桶的第一个节点,而非整个 Segment。不同桶的写操作互不干扰。JDK 1.6 优化后的 synchronized 性能已经接近甚至优于 ReentrantLock,且 JVM 能自动进行锁消除、锁粗化等优化。

⑤ CounterCell 计数addCount 采用 LongAdder 思想,将计数分散到多个 CounterCell 中,避免所有线程争抢同一个 baseCount。调用 size() 时累加所有 CounterCell 的值,结果是估算值

get 流程

java
public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        else if (eh < 0)
            // 红黑树查找
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

get 同样全程无锁Node.valNode.nextvolatile 保证可见性。

红黑树优化

当链表长度 ≥ 8 时,转化为红黑树(TreeBin),查找复杂度从 O(n) 降到 O(log n)。

TreeBin 读写分离:

  • 写操作:互斥锁(lockRoot / unlockRoot
  • 读操作:无锁,通过 volatile 保证可见性,同时检查树结构和当前节点是否被修改

1.8 扩容:多线程协助

java
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    // 每个线程处理的步长,最小 16
    stride = (NCPU > 1) ? (n >>> 3) / NCPU : n;
    if (stride < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE;

    // 初始化 nextTable
    if (nextTab == null) { /* ... */ }

    // 用 ForwardingNode 标记已迁移的桶
    for (int i = 0; i < n; ++i) {
        // 迁移节点到新数组
        // 设置 ForwardingNode
    }
}

多线程扩容时序图:

text
Time  Thread A (首次触发扩容)     Thread B (put 时发现扩容)     Thread C (put 时发现扩容)
│     put → addCount             put → addCount                put → addCount
│     发现 sizeCtl < 0           sizeCtl < 0                   sizeCtl < 0
│     CAS 设置 sizeCtl           transferIndex 分配 stride     transferIndex 分配 stride
│     成功 ✅                    成功 ✅                       成功 ✅
│     new 2倍数组                 stride=16                     stride=16
│     transfer()
│     ├─迁移 0-15 桶             ├─迁移 16-31 桶               ├─迁移 32-47 桶
│     ├─设 ForwardingNode        ├─设 ForwardingNode           ├─设 ForwardingNode
│     ├─迁移 48-63 桶            ├─迁移 64-79 桶               ├─迁移 80-95 桶
│     │  ...                     │  ...                        │  ...
│     └─迁移完,替换 table       └─迁移完,退出                └─迁移完,退出

▼     sizeCtl 恢复为 0.75*newCap

多线程扩容的核心机制:

  1. sizeCtl 记录扩容状态,通过 CAS 分配任务
  2. 每个线程迁移一个 stride 范围的桶
  3. 迁移完的桶设置 ForwardingNode,后续线程可以直接协助
  4. 写线程进来发现 ForwardingNode,调用 helpTransfer 一起搬

1.7 vs 1.8 对比总结

维度JDK 1.7JDK 1.8
数据结构Segment[] + HashEntry[]Node[] + 链表/红黑树
并发策略分段锁(ReentrantLock)CAS + synchronized
锁粒度Segment(默认 16 个)单个桶(链表头/树根)
最大并发度16数组长度(理论上万)
哈希冲突优化链表 O(n)链表 O(n) 转红黑树 O(log n)
扩容单线程、Segment 独立多线程协助
计数单变量 + 锁CounterCell 热点分离
get 无锁✅ volatile✅ volatile
初始化构造时分配懒初始化 CAS
键值允许 null

线上真实踩坑案例

踩坑 1:size() 不准导致监控误报

某次线上监控告警:缓存命中率突降。查日志发现监控系统每隔 30s 调用 ConcurrentHashMap.size() 统计节点数。但 size() 内部是遍历 CounterCell 累加,在百万级并发写入时,size() 持续波动,最小值只有实际值的 10%。

根因size() 返回的是快照值,不是精确值。mappingCount() 返回 long,同样不精确。

修复:改用维护独立 AtomicLong 计数器,只在查询时同步一次 CHM 的实际大小做校准。

踩坑 2:1.8 扩容时 CPU 飙到 100%

某次灰度发布后,一个 16 核机器上部署的缓存服务 CPU 从 30% 飙到 100%。jstack 发现大量线程卡在 helpTransferConcurrentHashMap.putVal 的循环里。

根因:初始容量设太小(new ConcurrentHashMap<>(16)),服务启动后大量写入触发了连续扩容。每次扩容所有写线程都参与 transfersizeCtl 的 CAS 竞争激烈,CPU 全耗在自旋上。

修复new ConcurrentHashMap<>(4096) 预分配足够容量,避免运行时扩容。

踩坑 3:HashMap 死循环线上事故

一个老服务(JDK 1.7 + HashMap 做本地缓存),半夜触发扩容,链表头插法形成环形链表。get 操作陷入死循环,CPU 打满 100%,响应超时从 50ms 膨胀到 30s。

复盘:排查时一台一台重启,每台重启后 10 分钟又烧。最后定位到 HashMap 是单例,重启后触发的仍然是同一个 HashMap 的同一个 bug。

教训:并发本地缓存必须用 ConcurrentHashMap。JDK 1.8 已经修了扩容死循环(改为尾插法),但数据丢失问题还在,所以仍然不能用 HashMap。

面试常见追问

1. 1.8 的 size() 为什么是估算值?

sumCount() 遍历所有 CounterCell 累加,中间可能有其他线程还在写,所以不是精确值。JDK 8 的 mappingCount() 返回 long 类型,比 size() 更推荐。但两者都是近似值,不是精确值。

面试官追问:那我需要精确值怎么办?

  • 场景允许:用 synchronized 包一层 size() 调用,牺牲精度换准确
  • 场景不允许:不要依赖 CHM 的 size 做精确判断,改用独立计数器

2. 为什么链表转红黑树的阈值是 8?

泊松分布计算:当负载因子 0.75 时,链表长度超过 8 的概率极低(约 0.0000006),说明哈希函数已经严重失效,此时用红黑树优化是合理的。

面试官追问:退化为链表的阈值为什么是 6? 答:保留 2 的余量,避免红黑树和链表频繁切换。如果阈值设为 7/8,一个插入/删除操作就会导致频繁转换,性能损耗大。

3. 1.8 为什么用 synchronized 而不用 ReentrantLock?

JDK 1.6 优化后,synchronized 的性能已接近 ReentrantLock。且 synchronized 无需手动释放锁,JVM 可以做锁消除、锁粗化、偏向锁等优化,代码更简洁。

面试官追问:偏向锁在 JDK 15 默认关闭了,这个选择还有意义吗? 答:偏向锁确实在 JDK 15 之后默认关闭,但 synchronized 的轻量级锁(CAS 自旋)和重量级锁(阻塞)机制仍然比 ReentrantLock 少一层内存屏障。而且 CHM 的场景是锁粒度极细(单个桶),synchronized 的锁膨胀策略在这个场景下效果很好。

4. 1.8 的 key 和 value 为什么不能为 null?

ConcurrentHashMap 的 get 方法无锁,无法区分 key 不存在和 value 为 null。如果允许 null,调用 get(key) 返回 null 时,调用方无法判断是 key 不存在还是 value 就是 null。HashMap 允许 null 是因为它是单线程的,可以 containsKey 再判断。

面试官追问:为什么 ConcurrentHashMap 不在 get 后加一个 containsKey 检查? 答:因为无锁环境下,containsKey 和 get 之间可能有其他线程插入/删除,两个操作不是原子性的。如果先做 containsKey 再 get,中间可能已经被删掉了,结果还是 null。

5. 1.8 扩容时为什么其他线程要协助而不是等待?

答:如果扩容时其他线程阻塞等待,那么整个 CHM 的写入能力会骤降——所有写线程被一个扩容线程卡住。协助扩容的设计让写线程把等待时间转化成有用的迁移工作,总吞吐量更高。这是典型的协作式并发思想。

总结

ConcurrentHashMap 1.8 的演进,本质上是锁粒度不断细化的过程:从 Hashtable 的类级别锁,到 1.7 的 Segment 分段锁,再到 1.8 的单桶 CAS+synchronized。每一次细化都意味着更高的并发度和更低的竞争概率。

1.8 的设计思想值得反复品味:

  • CAS 替代锁:空桶写入、计数、初始化,能用 CAS 就用 CAS
  • 细粒度锁:锁定范围越小,并发度越高
  • 读写分离:TreeBin 的读不阻塞,写阻塞
  • 协作而非等待:扩容时让写线程协助迁移
  • 热点分离:CounterCell 把单点计数打散

这些思想不仅适用于 ConcurrentHashMap,在 Netty、Disruptor、Kafka 等高性能组件中都能看到影子。

给祥哥的面试建议:问 CHM 的面试,不只问"1.7 和 1.8 区别",更爱问"线上遇到过什么坑"。把上面三个踩坑案例吃透,比背 API 有说服力。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。