Skip to content

ConcurrentHashMap 全过程原理

ConcurrentHashMap 是 Java 并发集合里最常考、也最容易被讲混的类。它不是“给 HashMap 加 synchronized”这么简单。JDK 7 和 JDK 8 的结构完全不同:JDK 7 重点是 Segment 分段锁,JDK 8 重点是数组 + 链表 + 红黑树、volatile、CAS、桶级 synchronized、多线程协助扩容。

学习目标

目标要能说清楚
为什么需要HashMap 多线程写为什么不安全
JDK 7 原理Segment 分段锁、HashEntry、concurrencyLevel
JDK 8 原理Node 数组、CAS、桶锁、红黑树、ForwardingNode
put 流程初始化、定位桶、空桶 CAS、冲突加锁、树化、计数、扩容
get 流程为什么大多数读操作不加锁
resize 流程sizeCtl、transferIndex、多线程协助迁移怎么配合
原子复合操作为什么 getput 不安全,merge/compute 怎么用
生产风险computeIfAbsent 慢逻辑、热 key、size 判断、value 非线程安全
面试闭环能把 JDK7/JDK8 区别、原理和项目用法讲清楚

HashMap 多线程写为什么不安全

HashMap 在单线程下很好用,但它没有并发保护。多线程同时 put 时,可能出现:

问题原因
数据覆盖两个线程同时读到旧桶,再分别写回
计数不准size++ 不是原子操作
扩容混乱多线程同时迁移桶结构
结构异常链表、树节点修改没有互斥保护

错误示例:

java
import java.util.HashMap;
import java.util.Map;

public class HashMapUnsafeCounter {
    private static final Map<String, Integer> MAP = new HashMap<>();

    public static void main(String[] args) throws Exception {
        Thread t1 = new Thread(HashMapUnsafeCounter::add);
        Thread t2 = new Thread(HashMapUnsafeCounter::add);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(MAP.get("order"));
    }

    private static void add() {
        for (int i = 0; i < 10000; i++) {
            Integer old = MAP.get("order");
            MAP.put("order", old == null ? 1 : old + 1);
        }
    }
}

它可能输出小于 20000,甚至在更复杂场景里出现结构问题。正确方向是使用 ConcurrentHashMap,但也要注意复合操作必须用原子方法。

JDK 7:Segment 分段锁

JDK 7 的 ConcurrentHashMap 可以理解为很多个小 HashMap 拼起来,每个小 HashMap 由一个 Segment 保护。

mermaid
flowchart TD
    A["ConcurrentHashMap"] --> B["Segment 0"]
    A --> C["Segment 1"]
    A --> D["Segment 2"]
    B --> E["HashEntry[]"]
    C --> F["HashEntry[]"]
    D --> G["HashEntry[]"]
    E --> H["链表节点"]

结构特点:

组件说明
Segment[]分段数组,每个 Segment 是一个可重入锁
HashEntry[]每个 Segment 内部自己的桶数组
HashEntrykey、hash、value、next
concurrencyLevel期望并发级别,大致影响 Segment 数量

JDK 7 写入流程:

mermaid
flowchart TD
    A["put key/value"] --> B["计算 hash"]
    B --> C["定位 Segment"]
    C --> D["锁住 Segment"]
    D --> E["定位 Segment 内桶"]
    E --> F["插入或更新链表"]
    F --> G["必要时扩容该 Segment"]
    G --> H["释放 Segment 锁"]

优点:不同 Segment 可以并发写。

缺点:

缺点说明
锁粒度仍偏大同一个 Segment 内不同桶写入也互斥
结构复杂Segment + HashEntry 双层结构
并发度受 Segment 限制Segment 数量固定后不容易更细
没有红黑树极端 hash 冲突下链表查询慢

JDK 8:数组、CAS、桶锁、红黑树

JDK 8 去掉 Segment,结构更像 HashMap:

mermaid
flowchart TD
    A["ConcurrentHashMap"] --> B["Node[] table"]
    B --> C["空桶"]
    B --> D["链表桶"]
    B --> E["红黑树桶"]
    B --> F["ForwardingNode 扩容迁移标记"]

JDK 8 主要机制:

机制用途
volatile Node<K,V>[] table保证其他线程看到最新数组
volatile V val保证 value 更新可见
volatile Node<K,V> next保证链表节点发布可见
CAS初始化 table、空桶插入、计数、扩容任务领取
synchronized 桶头桶内冲突写入时锁一个桶
红黑树单桶冲突过多时降低查找成本
ForwardingNode扩容迁移完成的桶用它标记并引导访问新表

为什么 JDK 8 还用 synchronized

JDK 6 以后 synchronized 已经有偏向锁、轻量级锁、自旋等优化。在 ConcurrentHashMap 中它只锁桶头节点,锁粒度很小,比维护 Segment 结构更简单。

先把“锁的到底是谁”讲清楚

这是理解 JDK 8 ConcurrentHashMap 最关键的一点。它没有一个覆盖整个 Map 的全局写锁,也没有 JDK 7 的固定 Segment 锁数组。发生哈希冲突时,源码把当前桶的首节点引用保存到局部变量 f,然后执行:

java
synchronized (f) {
    // 修改这个桶里的链表或红黑树
}

所以常说“锁桶”,准确说法其实是:

使用当前桶头节点对象 f 作为 synchronized 的监视器,互斥修改该桶当前对应的链表或树结构。

数组槽位本身不是 Java 对象,不能写 synchronized(tab[i]) 后又假设槽位永远不变。源码先用 volatile 语义读出桶头节点 f,锁住 f 后还会再次检查数组的第 i 个槽位当前是否仍然等于 f

mermaid
flowchart TD
    A["线程计算桶下标 i"] --> B["读取 f = tabAt table i"]
    B --> C{"f 是否为空"}
    C -- "是" --> D["CAS 写入,不加 synchronized"]
    C -- "否" --> E{"f 是否为迁移节点"}
    E -- "是" --> F["协助扩容,不锁普通链表"]
    E -- "否" --> G["synchronized 锁住对象 f"]
    G --> H{"tabAt table i 仍等于 f 吗"}
    H -- "否" --> I["桶已变化,本轮不修改并重新循环"]
    H -- "是" --> J["在锁内修改链表或 TreeBin"]

不同桶为什么可以并发写

假设 table 长度为 16:

text
线程 T1:keyA -> 下标 3 -> 桶头对象 Node-A
线程 T2:keyB -> 下标 11 -> 桶头对象 Node-B

T1 锁的是 Node-A,T2 锁的是 Node-B。两个监视器不是同一个对象,因此可以同时进入各自的临界区。

如果两个 key 都落到下标 3:

text
线程 T1:synchronized(Node-A)
线程 T2:synchronized(Node-A)

二者竞争同一个对象监视器,同一时刻只有一个线程能修改该桶。锁粒度因此是“发生冲突的桶”,不是一个 key 一把锁,也不是整个 Map 一把锁。

为什么不是严格的“每个 key 一把锁”

两个不相等的 key 只要 hash 后落到同一个桶,也会竞争同一个桶头锁。于是并发性能不仅取决于线程数,还取决于:

  • table 容量是否合理;
  • key 的 hashCode() 分布是否均匀;
  • 是否存在热 key;
  • 是否在 compute 等锁内逻辑中执行慢操作。

这就是为什么糟糕的 hashCode() 不只会让链表变长,还会把原本可并行的写操作挤到同一把桶锁上。

JDK 7 源码:Segment 为什么是一把锁

下面是 OpenJDK 7 源码结构的教学化摘取,省略了与当前问题无关的字段:

java
static final class Segment<K,V>
        extends ReentrantLock
        implements Serializable {

    transient volatile HashEntry<K,V>[] table;
    transient int count;
    transient int modCount;
    transient int threshold;
}

Segment 直接继承 ReentrantLock,所以 Segment 自己就是锁。put 会先尝试获取当前 Segment 的锁,再修改这个 Segment 内的桶:

java
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
    HashEntry<K,V> node = tryLock() ? null :
            scanAndLockForPut(key, hash, value);
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        int index = (tab.length - 1) & hash;
        HashEntry<K,V> first = entryAt(tab, index);

        // 在当前 Segment 锁内遍历链表并更新或插入
        // 必要时扩容的也是当前 Segment 自己的 table
    } finally {
        unlock();
    }
    return oldValue;
}

这意味着 JDK 7 中,即使两个 key 位于同一个 Segment 的不同桶,也必须竞争同一把 Segment 锁:

text
Segment 2
├── 桶 1:T1 要写
└── 桶 9:T2 要写

T1、T2 仍然竞争 Segment 2 这一把 ReentrantLock

concurrencyLevel 会影响 Segment 数量,但不是说设置为 16 后系统永远只能有 16 个线程。它表达的是写入锁分片数量大致为 16;落在不同 Segment 的写可以并行,落在同一 Segment 的写互斥。Segment 数量创建后基本固定,这也是 JDK 8 改为桶级控制的重要原因之一。

JDK 8 putVal 源码逐行分析

以下代码以 OpenJDK 8 的 putVal 为主线,删除了少量非核心细节,但保留了锁语义和关键判断。先看整体,再逐段解释:

java
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) {
        throw new NullPointerException();
    }

    int hash = spread(key.hashCode());
    int binCount = 0;

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

        if (tab == null || (n = tab.length) == 0) {
            tab = initTable();
        } else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            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 (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f; ; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                    ((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<K,V>(
                                        hash, key, value, null);
                                break;
                            }
                        }
                    } else if (f instanceof TreeBin) {
                        binCount = 2;
                        // 调用 TreeBin.putTreeVal 插入或找到树节点
                    }
                }
            }

            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD) {
                    treeifyBin(tab, i);
                }
                if (oldVal != null) {
                    return oldVal;
                }
                break;
            }
        }
    }

    addCount(1L, binCount);
    return null;
}

第一段:空桶为什么不用锁

java
if (casTabAt(tab, i, null, new Node<>(hash, key, value, null))) {
    break;
}

桶为空时,只需要保证“只有一个线程能把 null 改成新节点”。CAS 正好能原子完成比较与写入:

text
T1 期望槽位为 null,准备写 Node-A
T2 期望槽位为 null,准备写 Node-B

T1 CAS 成功:null -> Node-A
T2 CAS 失败:实际值已经是 Node-A
T2 不覆盖 T1,而是回到 for 循环,按非空桶流程处理

如果这里使用普通赋值,T1、T2 都可能先看到 null,然后后写入者覆盖先写入者。使用 CAS 后,空桶的第一次插入没有锁竞争,又不会丢数据。

第二段:f 就是准备锁住的桶头对象

java
f = tabAt(tab, i);
synchronized (f) {
    // ...
}

tabAt 不是普通数组读取。JDK 8 源码借助 Unsafe.getObjectVolatile 读取槽位,保证线程能观察到并发写入或迁移后的桶状态。对应的 casTabAtsetTabAt 也使用具有并发语义的底层操作。

教学化表示如下:

java
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);
}

第三段:为什么锁内必须再判断 tabAt(tab, i) == f

这是很多讲解遗漏的一行:

java
synchronized (f) {
    if (tabAt(tab, i) == f) {
        // 才能安全修改
    }
}

线程读取到 f 和真正获得 f 的监视器之间存在时间窗口。在这个窗口里,其他线程可能已经迁移或替换了该桶。当前线程虽然成功锁住旧对象 f,但旧对象可能已经不是数组槽位里的有效桶头。如果不校验,就可能修改一个已经脱离 table 的旧链表,修改结果不会进入当前 Map。

mermaid
sequenceDiagram
    participant T1 as "线程 T1"
    participant T2 as "线程 T2"
    participant TAB as "table 第 i 个槽位"

    T1->>TAB: "读取桶头 f"
    T2->>TAB: "完成迁移或替换桶头"
    T1->>T1: "获得旧对象 f 的监视器"
    T1->>TAB: "重新读取并比较是否仍为 f"
    TAB-->>T1: "不相等"
    T1->>T1: "不修改旧桶,退出锁后重试"

这条二次检查体现的是典型并发原则:

在锁外观察到的条件,等真正拿到锁后必须重新验证,因为等待锁期间共享状态可能已经变化。

第四段:同一桶的链表更新为什么安全

所有准备修改同一个非空普通桶的线程,读取到的有效桶头都是同一个 f,因此竞争同一个监视器。拿到锁的线程在链表中做两类操作:

  1. 找到相同 key,更新 e.val
  2. 没找到相同 key,在尾节点执行 pred.next = new Node(...)

另一个写线程必须等前一个线程退出 synchronized 后才能进入。进入后又会从当前链表重新查找,因此不会基于同一份旧结构并发接链而互相覆盖。

第五段:锁什么时候释放

Java 编译器会为 synchronized 生成监视器进入和退出逻辑。无论正常结束还是抛出异常,离开同步块时都会释放 f 的监视器。源码中的这些工作在锁外执行:

  • treeifyBin 的入口判断;
  • 最终 addCount 计数;
  • 根据计数决定是否触发或协助扩容。

不过 treeifyBin 自身在真正转换桶结构时还会重新读取并同步当前桶头,不能理解成“树化完全无锁”。

红黑树桶到底锁谁

当桶已经树化时,table 槽位存放的首对象不是普通 TreeNode,而是 TreeBin 包装节点:

text
table[i]

TreeBin
   ├── first:用于遍历的链表头
   └── root:红黑树根节点

putVal 中的 f 此时就是 TreeBin,外层仍执行:

java
synchronized (f) {
    if (tabAt(tab, i) == f && f instanceof TreeBin) {
        ((TreeBin<K,V>) f).putTreeVal(hash, key, value);
    }
}

因此树桶的结构性写入仍由 TreeBin 对象监视器串行保护。TreeBin 内部另外还有 lockState、WAITER、WRITER、READER 等状态,用于协调树查找与结构调整。面试中至少要区分:

  • synchronized(TreeBin):保护 put/remove 等树结构写入的外层桶级互斥;
  • TreeBin.lockState:树内部为了在并发读取与树平衡期间协调访问使用的状态机制;
  • 它们不是 JDK 7 的 Segment,作用范围仍然是当前树桶。

扩容时到底锁什么

迁移某个非空普通桶时,transfer 同样会锁住旧表当前桶头 f,再校验它仍是槽位当前值:

java
synchronized (f) {
    if (tabAt(tab, i) == f) {
        // 把旧桶拆成低位链和高位链
        setTabAt(nextTab, i, lowHead);
        setTabAt(nextTab, i + n, highHead);
        setTabAt(tab, i, forwardingNode);
    }
}

这保证迁移线程和正在向该桶 put 的线程不会同时改链表:

  • put 先拿到 f:先完成写入,迁移线程随后迁移包含新节点的完整桶;
  • transfer 先拿到 f:先迁移并把旧槽位改成 ForwardingNode,put 下一轮发现 MOVED 后转去协助扩容并在新表继续;
  • 不同桶仍可以被不同迁移线程并行处理。

ForwardingNodehash 是特殊值 MOVED。它不是业务数据,也不是一把锁,而是“这个旧桶已经迁移,请去 nextTable”的状态标记和跳转入口。

一张时序图看懂两个线程写同一桶

mermaid
sequenceDiagram
    participant T1 as "线程 T1:put keyA"
    participant T2 as "线程 T2:put keyB"
    participant B as "桶头对象 f"

    T1->>B: "读取 f 并进入 synchronized(f)"
    T2->>B: "读取同一个 f,等待监视器"
    T1->>B: "二次校验槽位仍为 f"
    T1->>B: "遍历链表并追加 keyA"
    T1-->>B: "退出同步块,释放监视器"
    T2->>B: "获得监视器"
    T2->>B: "二次校验并读取包含 keyA 的新链表"
    T2->>B: "更新或追加 keyB"
    T2-->>B: "释放监视器"

如果两个线程写不同桶,上图中的 B 是两个不同对象,两个线程不会互相等待。

JDK 8 put 全流程

mermaid
flowchart TD
    A["put(key,value)"] --> B{"key/value 是否为 null"}
    B -->|是| C["抛 NullPointerException"]
    B -->|否| D["计算扰动 hash"]
    D --> E{"table 是否初始化"}
    E -->|否| F["initTable 使用 CAS 初始化"]
    F --> G["重新进入 put 循环"]
    E -->|是| H["定位桶下标"]
    H --> I{"桶是否为空"}
    I -->|是| J["CAS 放入新 Node"]
    I -->|否| K{"桶是否 ForwardingNode"}
    K -->|是| L["helpTransfer 协助扩容"]
    K -->|否| M["synchronized 锁桶头"]
    M --> N["链表或红黑树插入/更新"]
    N --> O["释放桶锁"]
    J --> P["addCount 更新计数"]
    O --> P
    P --> Q{"是否达到扩容阈值"}
    Q -->|是| R["触发或协助扩容"]
    Q -->|否| S["put 完成"]

细节解释:

步骤为什么这样做
null 检查禁止 null,避免并发下 get == null 语义不清
hash 扰动让高位信息参与低位下标计算,降低冲突
initTable多线程并发初始化时只允许一个线程成功
空桶 CAS没冲突时无锁插入,提高性能
桶头加锁有冲突时只锁当前桶,不锁整个 Map
treeify冲突过多时转红黑树,降低查询成本
addCount更新计数,并判断是否需要扩容

get 为什么通常不加锁

mermaid
flowchart TD
    A["get(key)"] --> B["计算 hash"]
    B --> C["读取 volatile table"]
    C --> D["定位桶"]
    D --> E{"桶为空吗"}
    E -->|是| F["返回 null"]
    E -->|否| G{"首节点命中吗"}
    G -->|是| H["返回 value"]
    G -->|否| I{"是否特殊节点"}
    I -->|ForwardingNode| J["到新表继续查"]
    I -->|TreeBin| K["红黑树查找"]
    I -->|普通链表| L["遍历链表"]

get 大多无锁依赖的是可见性设计:

字段作用
table volatile读线程能看到最新表引用
Node.val volatile能看到更新后的 value
Node.next volatile能看到链表新节点
CAS 和锁发布写入完成后对读线程可见

读线程不加锁并不表示没有并发控制,而是通过 volatile 和节点不可变/可见字段保证读到的结构是可理解的。

为什么不允许 null

ConcurrentHashMap 禁止 null key 和 null value。

核心原因:并发下 get(key) == null 必须有明确含义。

如果允许 null:

java
V value = map.get(key);
if (value == null) {
    // 是 key 不存在?
    // 还是 key 存在但 value 就是 null?
}

单线程可以再 containsKey 判断,但并发下:

java
if (map.get(key) == null) {
    if (map.containsKey(key)) {
        // 中间可能已经被别的线程修改,判断不稳定。
    }
}

禁止 null 后,get(key) == null 就能明确表示没有取到有效映射。

sizeCtl 是什么

sizeCtl 是 JDK 8 ConcurrentHashMap 里的控制字段,很重要,但面试不用死背所有位运算细节,要理解它的状态含义。

sizeCtl 状态大致含义
0table 还没初始化,使用默认容量
正数下一次扩容阈值,或初始化容量
-1正在初始化 table
小于 -1正在扩容,且有线程参与迁移

它解决的是多线程下“谁初始化、谁扩容、多少线程协助扩容”的协调问题。

扩容全过程

扩容不是简单创建新数组再复制。ConcurrentHashMap 要允许多个线程继续读写,还要让多个线程一起迁移。

mermaid
flowchart TD
    A["addCount 后发现超过阈值"] --> B["CAS 修改 sizeCtl 标记扩容"]
    B --> C["创建 nextTable,容量翻倍"]
    C --> D["多个线程领取迁移区间"]
    D --> E["迁移旧 table 中的一段桶"]
    E --> F["迁移完成的桶放 ForwardingNode"]
    F --> G{"所有桶是否迁移完成"}
    G -->|否| D
    G -->|是| H["table 指向 nextTable"]
    H --> I["更新新的扩容阈值"]

几个关键对象:

名称作用
nextTable扩容期间的新数组
transferIndex记录还有哪些桶区间没迁移
ForwardingNode标记某个旧桶已经迁移,读写遇到它就去新表
helpTransfer其他写线程发现正在扩容后帮忙迁移

为什么要多线程协助扩容?

如果只有一个线程迁移大表,停顿会很明显。多线程协助迁移可以把扩容成本分摊给多个写线程,避免单个线程承担全部迁移压力。

ForwardingNode 是什么

扩容期间,旧表里某个桶迁移完后,会被替换成 ForwardingNode。

mermaid
flowchart TD
    A["线程访问旧 table 某个桶"] --> B{"桶是否 ForwardingNode"}
    B -->|否| C["按普通桶处理"]
    B -->|是| D["说明该桶已迁移"]
    D --> E["根据 ForwardingNode 指向 nextTable"]
    E --> F["到新表继续 get 或协助 put"]

ForwardingNode 的意义:

作用说明
标记迁移完成避免重复迁移同一个桶
引导读请求get 遇到它可以去新表查
引导写请求put 遇到它可以协助扩容
保证并发扩容可进行旧表和新表过渡期间不会乱

计数为什么不是一个 size 变量

如果所有线程都 CAS 同一个 size,高并发写入时竞争会非常激烈。

JDK 8 使用类似 LongAdder 的思路:

mermaid
flowchart TD
    A["写线程增加元素"] --> B{"baseCount CAS 是否成功"}
    B -->|成功| C["更新 baseCount"]
    B -->|失败| D["使用 CounterCell 分散计数"]
    D --> E["sumCount 时汇总 baseCount 和 Cells"]

所以:

方法说明
size()返回 int,可能需要汇总,极大时有上限
mappingCount()返回 long,更适合大 map 估算
并发修改中的 size不是强一致快照

不要用 size() 做强一致业务判断。例如“如果 map.size() < 100 就提交”,并发下不可靠。

树化和退化

JDK 8 HashMap 和 ConcurrentHashMap 都有链表转红黑树的思想,但 ConcurrentHashMap 还要考虑并发。

条件含义
链表长度达到阈值可能尝试树化
table 容量太小优先扩容而不是树化
树节点变少可能退化回链表

为什么容量小时优先扩容?

因为冲突可能只是数组太小导致的。扩容后元素重新分布,链表可能自然变短。只有容量已经足够大但单桶仍然很长,才说明 hash 冲突严重,树化更合适。

原子复合操作

ConcurrentHashMap 的单个方法是线程安全的,但组合操作不一定安全。

错误计数:

java
Integer old = map.get("success");
map.put("success", old == null ? 1 : old + 1);

两个线程可能都读到 1,然后都写回 2

正确写法一:merge

java
map.merge("success", 1, Integer::sum);

正确写法二:compute

java
map.compute("success", (key, oldValue) -> oldValue == null ? 1 : oldValue + 1);

高并发统计推荐:

java
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;

public class RequestCounter {
    private final ConcurrentHashMap<String, LongAdder> counter = new ConcurrentHashMap<>();

    public void record(String api) {
        counter.computeIfAbsent(api, key -> new LongAdder()).increment();
    }

    public long count(String api) {
        LongAdder adder = counter.get(api);
        return adder == null ? 0 : adder.sum();
    }
}

computeIfAbsent 的坑

computeIfAbsent 很好用,但不要在 mapping function 里做慢 IO。

先理解它为什么能保证同一个 key 的计算不会被普通并发 put 随意穿透。JDK 8 对空桶和非空桶采取不同方案。

空桶:先放 ReservationNode,再锁住它计算

下面是 OpenJDK 8 computeIfAbsent 空桶路径的教学化源码:

java
Node<K,V> reservation = new ReservationNode<K,V>();

synchronized (reservation) {
    if (casTabAt(tab, i, null, reservation)) {
        Node<K,V> node = null;
        try {
            V value = mappingFunction.apply(key);
            if (value != null) {
                node = new Node<K,V>(hash, key, value, null);
            }
        } finally {
            setTabAt(tab, i, node);
        }
    }
}

ReservationNode 表示“这个空桶正在为某次计算预留”。第一个线程 CAS 放入预留节点后执行计算;其他线程访问该桶时不会把它当普通空桶再次 CAS 插入。finally 很关键:无论计算成功还是抛异常,都要把预留节点替换成真实节点或恢复为空,避免桶永久停留在预留状态。

非空桶:锁当前桶头后计算

如果桶已经存在普通链表或 TreeBincomputeIfAbsent 会像 putVal 一样锁当前桶头,在确认 key 不存在后执行 mapping function,再把结果插入当前桶。因此 mapping function 可能处于桶级临界区内。

text
同桶线程 T1:computeIfAbsent -> 持有桶头锁 -> 调用远程接口 2 秒
同桶线程 T2:put/compute/merge -> 等待同一个桶头锁

这就是“不要在 computeIfAbsent 中做慢 SQL、HTTP、文件 IO”的源码原因。它不是说整个 Map 被锁了,而是相同桶上的结构写入会被这个慢计算阻塞。热 key 或 hash 冲突严重时,局部阻塞会被放大。

为什么递归更新危险

mapping function 不应该再次更新会落到同一计算路径的 Map 内容,例如:

java
map.computeIfAbsent("A", key ->
        map.computeIfAbsent("A", inner -> "value"));

JDK 8 的实现会使用 ReservationNode 检测部分递归更新并可能抛出 IllegalStateException: Recursive update。即使递归 key 不完全相同,只要业务形成锁内复杂嵌套,也容易产生不可预测的锁等待、长临界区或逻辑循环。mapping function 应当是短小、无副作用、不会递归修改当前 Map 的纯内存计算。

危险写法:

java
cache.computeIfAbsent(userId, id -> remoteUserService.query(id));

为什么危险?

原因后果
计算可能在桶锁内执行同桶其他更新被阻塞
远程调用慢请求堆积,线程池被拖住
远程调用异常缓存没写入,下次继续打下游
热 key 并发可能形成局部热点

更稳妥的做法是:缓存 value 尽量是已准备好的对象,慢 IO 移到锁外,或者使用专门缓存组件如 Caffeine。

不过把慢 IO 简单移到锁外会产生“缓存击穿时多个线程重复加载”的新问题。商业项目通常根据语义选择:

方案优点代价
computeIfAbsent 内直接加载同 key 原子计算语义直观慢加载会延长桶级临界区
锁外加载后 putIfAbsent不长时间占桶锁多个线程可能重复调用下游
CompletableFuture 作为 value同 key 可共享一次异步加载结果要处理超时、异常结果移除和线程池隔离
Caffeine LoadingCache有成熟的并发加载、容量和过期能力增加组件用法和监控要求

不能只为了“少一把锁”牺牲下游稳定性,应结合加载成本、重复加载是否允许、热 key 程度和失败策略选型。

value 线程安全问题

ConcurrentHashMap 只保证 Map 结构并发安全,不保证 value 对象内部安全。

危险示例:

java
ConcurrentHashMap<String, ArrayList<String>> map = new ConcurrentHashMap<>();
map.computeIfAbsent("order", key -> new ArrayList<>()).add("A");

computeIfAbsent 获取列表是线程安全的,但多个线程同时对同一个 ArrayList 调用 add 仍然不安全。

修复方式:

需求方案
value 是集合且多线程写使用线程安全集合或在 value 内加锁
只追加少量监听器CopyOnWriteArrayList
高频计数LongAdder
复杂对象更新不可变对象整体替换或加锁

商业场景:接口访问统计

java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;

public class ApiMetrics {
    private final ConcurrentHashMap<String, LongAdder> apiCounter =
            new ConcurrentHashMap<>();

    public void record(String apiName) {
        apiCounter.computeIfAbsent(apiName, key -> new LongAdder()).increment();
    }

    public Map<String, Long> snapshot() {
        Map<String, Long> result = new java.util.HashMap<>();
        apiCounter.forEach((api, count) -> result.put(api, count.sum()));
        return result;
    }
}

为什么这样设计?

设计原因
ConcurrentHashMap多接口、多线程并发写安全
LongAdder热接口计数竞争少
snapshot 复制对外返回快照,避免暴露内部结构

商业场景:本地配置快照

java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class LocalConfigCenter {
    private final ConcurrentHashMap<String, String> configs = new ConcurrentHashMap<>();

    public String get(String key) {
        return configs.get(key);
    }

    public void refresh(Map<String, String> newConfigs) {
        configs.clear();
        configs.putAll(newConfigs);
    }
}

这个写法虽然线程安全,但有短暂空窗:clear() 后、putAll() 前,读线程可能读不到配置。

更好的快照替换:

java
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;

public class SnapshotConfigCenter {
    private final AtomicReference<Map<String, String>> configs =
            new AtomicReference<>(Collections.emptyMap());

    public String get(String key) {
        return configs.get().get(key);
    }

    public void refresh(Map<String, String> newConfigs) {
        configs.set(Collections.unmodifiableMap(new HashMap<>(newConfigs)));
    }
}

这说明:不是所有并发读写都必须用 ConcurrentHashMap。配置整体刷新更适合不可变快照。

线上排查

CPU 高

mermaid
flowchart TD
    A["CPU 高"] --> B["查看热点线程栈"]
    B --> C{"是否大量 ConcurrentHashMap 操作"}
    C -->|是| D["检查热 key 或 compute 慢逻辑"]
    D --> E["检查是否频繁 resize"]
    E --> F["检查 hashCode 是否质量差"]
    C -->|否| G["继续查业务计算、GC、锁竞争"]

常见原因:

原因处理
热 key 计数LongAdder、分片 key
compute 内慢逻辑慢 IO 移出 compute
初始容量太小创建时预估容量
hashCode 冲突严重修复 key 的 hashCode

接口偶发慢

检查是否在这些方法的 lambda 里做了慢操作:

java
compute()
computeIfAbsent()
computeIfPresent()
merge()

这些方法要保证同一个 key 或桶上的原子更新,内部可能持有桶锁。lambda 里应该只做快速内存计算,不要查数据库、调 HTTP、写文件。

统计不准确

如果使用:

java
map.put(key, map.get(key) + 1);

那不是 ConcurrentHashMap 的问题,是复合操作不原子。改成 mergecomputeLongAdder

常见坑

后果正确做法
认为 CHM 所有组合操作都安全getput 仍丢更新compute/merge
computeIfAbsent 里远程调用桶锁持有时间长慢 IO 移出或用缓存组件
size() 做强一致判断并发下判断不可靠使用独立原子计数或限流器
value 是 ArrayListMap 安全但 value 不安全value 也要线程安全
初始容量太小频繁扩容影响性能根据数据量预估容量
自定义 key 的 hashCode 很差大量冲突,性能下降修复 hashCode/equals

面试标准回答

ConcurrentHashMap 为什么线程安全

JDK 8 的 ConcurrentHashMap 使用 volatile 保证 table、Node value、next 等字段可见;空桶插入、初始化、计数和扩容任务领取使用 CAS;同一个桶发生冲突写入时只 synchronized 锁桶头节点;链表过长会转红黑树;扩容时用 ForwardingNode 和 helpTransfer 支持多线程协助迁移。所以它不是锁整个 Map,而是尽量降低锁粒度。

JDK 7 和 JDK 8 ConcurrentHashMap 区别

JDK 7 使用 Segment 分段锁,每个 Segment 类似一个小 HashMap,写操作锁 Segment。JDK 8 去掉 Segment,使用 Node 数组 + 链表/红黑树,空桶 CAS 插入,冲突时 synchronized 锁桶头,读操作大多无锁,扩容支持多线程协助迁移。JDK 8 锁粒度更细,结构也更贴近 HashMap。

ConcurrentHashMap 的 put 流程

put 时先检查 key/value 不能为 null,然后计算 hash。如果 table 未初始化,就 CAS 初始化;定位桶后,如果桶为空就 CAS 放入新节点;如果桶是 ForwardingNode,说明正在扩容,当前线程会协助迁移;如果桶非空,就 synchronized 锁桶头,在链表或红黑树中插入或更新,最后更新计数并判断是否需要扩容。

get 为什么不用加锁

get 主要依赖 volatile 可见性。table、Node 的 value 和 next 等字段具备可见性保证,写线程通过 CAS 或桶锁发布节点后,读线程能看到可理解的结构。遇到 ForwardingNode 时,get 会去新表继续查。因此大多数读操作不需要加锁。

为什么不允许 null

因为并发环境下 get(key) == null 必须有明确含义。如果允许 null value,就无法区分 key 不存在还是 value 本身就是 null。单线程 HashMap 可以用 containsKey 补充判断,但并发下 get 和 containsKey 之间可能被其他线程修改,判断不稳定。

ConcurrentHashMap 一定适合所有并发场景吗

不一定。它保证 Map 结构线程安全,但不保证 value 内部线程安全;复合操作要用 compute、merge 等原子方法;lambda 里不能做慢 IO;如果是整体配置刷新,不可变快照加 AtomicReference 可能比 ConcurrentHashMap 更合适。

关联知识点

知识点为什么要看
并发集合与阻塞队列并发容器整体导读
集合框架HashMap、equals/hashCode、容量和冲突基础
volatile与Atomic全过程理解 volatile、CAS、LongAdder
synchronized全过程理解 JDK 8 为什么可用 synchronized 锁桶
ThreadLocal全过程对比线程隔离和并发容器的不同问题