个人网站

上一篇文章讲了 ConcurrentHashMap 的底层结构,这篇聚焦一个核心问题:它是如何保证线程安全的? 很多人知道"CAS + synchronized",但具体怎么配合、每种场景用什么策略、为什么这样设计,说不清楚。

今天咱们把 ConcurrentHashMap 的线程安全保证从"为什么"到"怎么做"彻底讲透。

一、先说结论:三层安全保障

层次 机制 保护什么
第一层 volatile 可见性——get 不加锁也能看到最新值
第二层 CAS 原子性——空桶插入、计数更新等轻量操作
第三层 synchronized 互斥性——链表/树的插入、扩容迁移等复杂操作

一句话记住:volatile 保可见、CAS 保轻量原子、synchronized 保复杂互斥——三层配合,安全又高效。

二、第一层:volatile 保证可见性

ConcurrentHashMap 的 Node 中,val 和 next 都是 volatile:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;     // 👈 volatile 保证可见性
    volatile Node<K,V> next;  // 👈 volatile 保证可见性
}

为什么 get 不加锁?

// get 完全不加锁!
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) {  // volatile 读 👈
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;  // volatile 读 👈
        }
        // ...
    }
    return null;
}

volatile 的可见性保证: 线程 A 修改了 val/next,线程 B 读取时一定能看到最新值——这是 JMM 的 happens-before 保证。

tabAt 也用了 volatile 语义:

static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);  // 👈 volatile 读
}

生活类比: volatile 像公告栏——有人更新了公告,所有人立刻看到最新内容,不需要通知每个人。

三、第二层:CAS 保证轻量原子操作

CAS(Compare And Swap)——比较并交换,无锁并发:

CAS 语义:
if (当前值 == 期望值) {
    当前值 = 新值;
    return true;
} else {
    return false;  // 被其他线程改了,重试
}

场景一:空桶插入

else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    if (casTabAt(tab, i, null, new Node<>(hash, key, value, null)))
        break;  // 👈 CAS 成功,插入完成,无锁!
}

为什么空桶用 CAS? 空桶插入只有一个操作——把 null 换成新 Node,CAS 一次就行,不需要加锁。

如果 CAS 失败? 说明其他线程先插入了,进入下一轮 for 循环重新处理。

场景二:计数更新

// addCount 中的 CAS
if (!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
    // CAS 失败 → 分散到 CounterCell
    // 不同线程更新不同的 Cell 👈
}

场景三:初始化数组

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        if ((sc = sizeCtl) < 0)
            Thread.yield();  // 其他线程正在初始化,让出 CPU
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {  // 👈 CAS 抢占初始化权
            try {
                if ((tab = table) == null || tab.length == 0) {
                    Node<K,V>[] nt = new Node[sc];  // 只有一个线程能进这里
                    table = tab = nt;
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

CAS 的优势: 不阻塞线程,失败就重试,适合"竞争不激烈"的场景。

四、第三层:synchronized 保证复杂互斥操作

链表/树的插入、扩容迁移等复杂操作用 synchronized:

synchronized (f) {  // f 是头节点 👈
    if (tabAt(tab, i) == f) {  // double-check
        if (fh >= 0) {
            // 链表插入:遍历 → 覆盖 or 尾插
            binCount = 1;
            for (Node<K,V> e = f;; ++binCount) {
                K ek;
                if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) {
                    oldVal = e.val;
                    if (!onlyIfAbsent)
                        e.val = value;
                    break;
                }
                Node<K,V> pred = e;
                if ((e = e.next) == null) {
                    pred.next = new Node<>(hash, key, value, null);  // 尾插 👈
                    break;
                }
            }
        } else if (f instanceof TreeBin) {
            // 红黑树插入
        }
    }
}

为什么用 synchronized 而不是 CAS? 链表插入需要多步操作(遍历 + 判断 + 插入),CAS 无法保证多步的原子性,必须加锁。

为什么锁头节点? 锁的粒度最小——只锁一个桶,其他桶不受影响。

double-check 的意义: 获取锁到进入同步块之间,头节点可能被其他线程修改(如扩容),需要再次确认。

五、三层配合:不同场景的策略选择

场景 策略 原因
空桶插入 CAS 一步操作,CAS 足够
链表插入 synchronized 多步操作,需要互斥
红黑树插入 synchronized 多步操作 + 树旋转
读取操作 无锁(volatile) 只读,可见性足够
初始化数组 CAS 一步操作,CAS 抢占
计数更新 CAS + CounterCell 分散热点
扩容 synchronized + CAS 迁移用 synchronized,状态用 CAS

核心思想:能 CAS 就不加锁,能加轻量锁就不加重锁。

六、和 Hashtable 的对比

维度 Hashtable ConcurrentHashMap
锁粒度 整个 Map 单个桶
锁实现 synchronized 方法 CAS + synchronized 代码块
get 加锁
并发性能 极差 极好

Hashtable 每次操作锁整个 Map,ConcurrentHashMap 只锁一个桶——并发度差距可达数百倍。

ConcurrentHashMap 线程安全 全景

ConcurrentHashMap 线程安全 全景

三层安全保障
├── volatile ── 可见性(get 无锁读)
├── CAS ── 轻量原子(空桶插入、计数、初始化)
└── synchronized ── 互斥性(链表/树插入、扩容)

volatile 的作用
├── Node.val ── get 读到最新值
├── Node.next ── 遍历链表可见
└── table 引用 ── tabAt volatile 读

CAS 的场景
├── 空桶插入 ── casTabAt
├── 初始化 ── CAS sizeCtl
├── 计数 ── CAS baseCount / CounterCell
└── 扩容状态 ── CAS transferIndex

synchronized 的场景
├── 链表插入 ── synchronized(头节点)
├── 红黑树插入 ── synchronized(TreeBin)
└── 扩容迁移 ── synchronized(头节点)

设计原则
├── 能 CAS 就不加锁
├── 能加轻量锁就不加重锁
├── 锁的粒度尽可能小
└── 读操作完全无锁

口诀:volatile 保可见,CAS 做轻量,
      synchronized 保复杂,三层配合安全高效,
      读不加锁写细锁,并发性能无人敌。

回答技巧与点评

标准回答

ConcurrentHashMap 通过三层机制保证线程安全:第一层 volatile——Node 的 val 和 next 用 volatile 修饰,get 操作完全不加锁,依赖 volatile 可见性读到最新值;第二层 CAS——空桶插入用 casTabAt 原子替换、初始化用 CAS 抢占、计数用 CAS + CounterCell 分散更新;第三层 synchronized——链表和红黑树插入锁头节点,保证多步操作的互斥性。核心设计思想是"能 CAS 就不加锁,能加轻量锁就不加重锁,锁的粒度尽可能小"。

加分回答
  1. 锁升级与 synchronized 优化:ConcurrentHashMap 锁的头节点对象不会逃逸出同步块,JIT 可以进行锁消除或锁偏置优化。而且竞争不激烈时,synchronized 只用偏向锁(几乎零开销);竞争中等时用轻量级锁(自旋);竞争激烈时才膨胀为重量级锁。这是 ConcurrentHashMap 选择 synchronized 而非 ReentrantLock 的原因之一
  2. 扩容时的安全性:ConcurrentHashMap 的扩容通过 ForwardingNode(hash = MOVED)标记已迁移的桶,其他线程 put 时发现 ForwardingNode 会协助扩容(helpTransfer)。transferIndex 用 CAS 分配迁移任务,多个线程各负责一段桶的迁移。这种"协作式扩容"比单线程扩容快得多,同时保证了迁移过程的线程安全
  3. LongAdder 的分散热点思想:ConcurrentHashMap 的计数方案借鉴了 LongAdder(Striped64)——将原子计数分散到多个 Cell,不同线程更新不同 Cell,减少 CAS 竞争。这是一种"空间换时间"的策略,在高并发场景下比 AtomicLong 快数倍。Java 8 中 LongAdder 和 ConcurrentHashMap 是同时设计的,共享了同一套思路
面试官点评

这道题考的是你对并发安全机制的深入理解。能说出"volatile + CAS + synchronized"是基本要求,能分别讲清楚每层保护什么、什么场景用哪种策略,才算及格。如果你能从锁升级优化、协作式扩容、LongAdder 分散热点等角度深入分析,面试官会认为你对 ConcurrentHashMap 的线程安全机制理解得非常透彻,达到了源码级和设计级的水平。

原文阅读


内容有帮助?点赞、收藏、关注三连!评论区等你 💪

更多推荐