ConcurrentHashMap 是如何保证线程安全的?

#Java#面试八股

ConcurrentHashMap 是如何保证线程安全的?

结论先行:JDK 1.8 的 CHM 靠三招:空桶用 CAS 直接放入;非空桶用 synchronized 锁住桶头节点再操作链表或红黑树;读操作完全不加锁,靠 volatile 读桶数组元素与节点 value。计数用 baseCount + counterCells 分散热点,扩容支持多线程协作迁移。

1. 关键字段

transient volatile Node<K,V>[] table;    // 桶数组,引用 volatile 保证可见性
private transient volatile int sizeCtl;  // 初始化/扩容的控制位
private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;           // 读线程能看到最新值
    volatile Node<K,V> next;  // 链表指针也带 volatile
}

与 HashMap 不同,CHM 的 Node.val 与 Node.next 都是 volatile;table 的读取通过 tabAt(Unsafe 的 volatile 语义)完成,写入则用 casTabAt 或 setTabAt。

2. 写路径

putVal(key, value)
   │
   ├─ table 未初始化 ──> initTable():CAS 抢 sizeCtl,抢到的线程建表
   │
   ├─ 桶 i 为空 ──> casTabAt(tab, i, null, new Node)   ← 无锁
   │
   ├─ 桶头 hash == MOVED ──> helpTransfer():协助迁移
   │
   └─ 否则 synchronized (桶头 f)
         ├─ if (tabAt(tab, i) == f) 双重检查,防止锁期间桶头被替换
         ├─ 链表尾插 / 树插入 putTreeVal
         ├─ 命中 key:只改 val(volatile 写)
         └─ 链表长度达标:treeifyBin
   │
   └─ addCount(1, binCount):累加计数,可能触发 transfer

锁住的是桶头节点对象,不是整张表,所以不同桶的写可以完全并行;空桶连桶头都没有,于是用 CAS 一步完成“检查 + 放入”。

3. 读路径为什么可以不加锁

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;
}
  • 桶数组引用用 volatile 读,写线程发布的节点对读线程可见。
  • val 是 volatile,覆盖写立刻对其他线程可见。
  • 树桶用 TreeBin 内部的读写状态位协调,读操作不阻塞。
  • 读路径全程没有 synchronized,也不 CAS,因此不会阻塞写线程。

4. 计数与扩容

机制1.8 的做法解决的问题
空桶初始化casTabAt无锁完成“检查 + 写入”
冲突桶写入synchronized (桶头)锁粒度缩到单个桶
读取volatile读不加锁、不阻塞
计数baseCount + counterCells分散 CAS 热点,思路同 LongAdder
扩容transfer + ForwardingNode,其他线程 helpTransfer大表迁移可并行
树化TreeBin + 红黑树单桶最坏 O(log n)

addCount 先尝试 CAS 更新 baseCount,失败则把一个 CounterCell 槽的值加一;size() 是 sumCount() 的近似和,需要精确值用 mappingCount()。扩容时每个线程按步长认领一段桶,迁移完的桶放一个 ForwardingNode(hash = MOVED),其他线程遇到它就加入迁移。

5. 代码:复合操作必须用原子方法

import java.util.concurrent.ConcurrentHashMap;

public class ChmDemo {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

        // 错误:get 后再 put,两步之间可能被其他线程改掉
        Integer old = map.get("k");
        map.put("k", old == null ? 1 : old + 1);

        // 正确:merge 在桶锁内完成读改写
        map.merge("k", 1, Integer::sum);

        // 正确:computeIfAbsent 只计算一次(注意 lambda 里不要再操作同一个 map)
        map.computeIfAbsent("v", k -> 42);

        System.out.println(map); // 输出:{k=2, v=42}
    }
}

6. 常见误区

  • CHM 是“绝对线程安全”吗? 单次操作安全,复合操作仍要 merge、compute、putIfAbsent 这类原子方法。
  • size 精确吗? 不精确,只能当近似值;监控告警不要依赖它做精确判断。
  • 能存 null 吗? key 和 value 都不允许,源码里直接 if (key == null || value == null) throw new NullPointerException()。
  • 迭代器会抛 CME 吗? 不会,弱一致性,可能看不到迭代开始后的修改。
  • get 会不会读到“半插入”的节点? 不会,节点先构造完成再通过 CAS/volatile 写发布。

常见追问

  • CHM 的 size 为什么是近似值? 计数分散在多个 cell 中,求和过程不是原子的。
  • get 会阻塞吗? 不会,1.8 的读路径不需要互斥锁;树桶用 TreeBin 的读写状态位协调,同样不阻塞。
  • 为什么 key/value 不能为 null? 并发下无法区分“不存在”和“值为 null”,containsKey + get 也不是原子组合。
  • 1.7 与 1.8 最大差别? 分段锁变成 CAS + 桶级 synchronized,并引入红黑树。
  • 为什么用 synchronized 而不是 ReentrantLock? 锁对象是桶头,持有时间极短,JVM 内置锁经过偏向锁/轻量级锁优化后更轻,代码也更简单。
  • 扩容期间读写怎么协调? 迁移中的桶放 ForwardingNode,读线程转到新表查,写线程协助迁移。

小结

CHM 的线程安全不是靠“一把大锁”,而是把锁拆到桶级别:空桶 CAS、冲突桶 synchronized、读走 volatile、计数分散、扩容协作。理解这五点时,几乎所有追问——size 为什么不精确、get 为什么不加锁、复合操作为什么要 merge——都能顺着同一套机制推出来。

笔记加载中…