【多线程】---ConcurrentHashMap 源码解析

基于 :OpenJDK JDK 8u 的 java.util.concurrent.ConcurrentHashMap(对照 JDK 7 分段锁实现与 JDK 9+ 演进)

本文以源码解析为主线,逐段拆解 JDK 8 版 ConcurrentHashMap 的核心实现:字段与 sizeCtl 语义、putVal 全链路、多线程协作扩容、无锁读、基于 CounterCell 的计数、红黑树化与 TreeBin,配合数据布局总图、类关系图、可运行示例与面试 FAQ。解决「源码读了记不住、面试讲不清 put 之后发生了什么」的问题。


目录

  1. 定位与历史演进
    • [1.1 一句话定位](#1.1 一句话定位)
    • [1.2 线程安全容器定位表](#1.2 线程安全容器定位表)
    • [1.3 从 JDK 7 到 JDK 8 的演进](#1.3 从 JDK 7 到 JDK 8 的演进)
  2. 整体设计总览
    • [2.1 数据布局](#2.1 数据布局)
    • [2.2 put 主流程总览](#2.2 put 主流程总览)
    • [2.3 核心字段表](#2.3 核心字段表)
    • [2.4 sizeCtl 的语义](#2.4 sizeCtl 的语义)
  3. 入口与构造
    • [3.1 类声明与重要常量](#3.1 类声明与重要常量)
    • [3.2 构造器与懒初始化](#3.2 构造器与懒初始化)
    • [3.3 Node 内部类体系](#3.3 Node 内部类体系)
  4. [putVal 分步详解](#putVal 分步详解)
    • [4.1 定位与空桶 CAS](#4.1 定位与空桶 CAS)
    • [4.2 MOVED 与协助扩容](#4.2 MOVED 与协助扩容)
    • [4.3 桶头加锁写入](#4.3 桶头加锁写入)
    • [4.4 树化触发](#4.4 树化触发)
    • [4.5 计数与阈值检查](#4.5 计数与阈值检查)
    • [4.6 put 全流程决策图](#4.6 put 全流程决策图)
  5. [扩容机制 transfer](#扩容机制 transfer)
    • [5.1 扩容触发点](#5.1 扩容触发点)
    • [5.2 多线程分工与区间领取](#5.2 多线程分工与区间领取)
    • [5.3 桶迁移与高低位拆分](#5.3 桶迁移与高低位拆分)
    • [5.4 ForwardingNode 与无锁读](#5.4 ForwardingNode 与无锁读)
  6. 计数机制
    • [6.1 CounterCell 与伪共享](#6.1 CounterCell 与伪共享)
    • [6.2 addCount 与 fullAddCount](#6.2 addCount 与 fullAddCount)
    • [6.3 size 与 mappingCount](#6.3 size 与 mappingCount)
  7. 读操作与遍历
    • [7.1 get 为什么可以无锁](#7.1 get 为什么可以无锁)
    • [7.2 remove 与 replace](#7.2 remove 与 replace)
    • [7.3 弱一致性迭代器](#7.3 弱一致性迭代器)
    • [7.4 复合操作 compute 系列](#7.4 复合操作 compute 系列)
  8. [树化与 TreeBin](#树化与 TreeBin)
    • [8.1 8 与 6 两个阈值](#8.1 8 与 6 两个阈值)
    • [8.2 TreeBin 的读写锁思路](#8.2 TreeBin 的读写锁思路)
    • [8.3 树与链表的双向转换](#8.3 树与链表的双向转换)
  9. 关键类关系图
  10. 可运行示例
    • [10.1 并发读写与遍历 demo](#10.1 并发读写与遍历 demo)
    • [10.2 简易缓存 demo](#10.2 简易缓存 demo)
  11. 横向对比总表
  12. [FAQ 与扩展点](#FAQ 与扩展点)
  13. 参考资料与延伸

1. 定位与历史演进

1.1 一句话定位

关键认知 :ConcurrentHashMap 是 JUC 里「读多写少 + 高并发写」场景的线程安全 HashMap,JDK 8 版本以 CAS + 桶头 synchronized + 红黑树 为核心:把锁粒度细化到每个哈希桶,无竞争写入只花一次 CAS,读操作完全无锁,扩容支持多线程协作------在保证线程安全的同时,把并发度从 JDK 7 的「16 个分段」提升到「桶个数」级别。

1.2 线程安全容器定位表

容器 线程安全方式 锁粒度 读性能 写性能(竞争) 适用判断
Hashtable 全表一把锁(synchronized 方法) 整个表 锁内读 很差 历史遗留,勿用
Collections.synchronizedMap(map) 包装器锁(默认锁整个 map 对象) 整个表 锁内读 很差 仅小规模/兜底场景
ConcurrentHashMap(JDK 8+) CAS + 桶头锁 + volatile 单个桶 无锁 好(分摊到桶) 默认并发首选
HashMap 无(依赖外部同步) --- 无锁最快 无锁最快 线程私有 / 外部已加锁
ConcurrentSkipListMap CAS + 跳表 节点级 无锁 好 需要有序的并发 Map

一句话概括:凡是「一个 HashMap 被多个线程共享且需要增删」,默认答案就是 ConcurrentHashMap;Hashtable 与 synchronizedMap 的全局锁会让所有操作串行化,写入一多就退化。

1.3 从 JDK 7 到 JDK 8 的演进

维度 JDK 7 ConcurrentHashMap JDK 8 ConcurrentHashMap
数据结构 Segment 数组(分段锁)+ 每个 Segment 内 HashEntry 链 直接是 Node 桶数组,桶内链表或红黑树
锁实现 继承 ReentrantLock 的 Segment,写某段锁某段 空桶 CAS 写入;非空桶 synchronized 锁桶头节点
锁粒度 16 个段(默认并发度 16,并发度上限 16) 桶数(可达 1<<30),并发度随扩容提升
读操作 需经过 volatile 读,不加锁 完全无锁(volatile + final + 不变性)
扩容 段内扩容,一次搬一段,段间互不影响 全表扩容,但多线程协同迁移(旧桶逐个搬)
计数 段内 count 相加 baseCount + CounterCell 分片(LongAdder 思想)
key/value 均不允许 null 均不允许 null
size() 依次加段 count,可能锁各段重算 遍历 CounterCell 求和,弱一致

为什么 JDK 8 敢放弃成熟的分段锁模型?核心原因:

  1. 锁粒度更细:分段锁粒度固定 16 段,热点段照样打架;桶锁粒度 = 桶数,且只在哈希冲突(桶非空)时才上锁,无冲突写入退化为一次 CAS;
  2. 读彻底无锁 :JDK 7 读也要碰分段锁的 volatile 域;JDK 8 借助 volatile 桶引用 + Node 字段不可变/volatile 语义做到无锁读,读是并发场景最大头;
  3. 工程顺势而为:JDK 6 之后 synchronized 已被 JVM 全面优化(偏向锁/轻量级锁/锁消除),桶头加 synchronized 的成本低于 JDK 5 时代,代码还更简洁;官方解释也直言「synchronized 在 JDK 6 后性能已与 ReentrantLock 相当,而锁粒度细化后 synchronized 足以胜任」。

2. 整体设计总览

2.1 数据布局

JDK 8 的物理结构(桶数组里放四种节点之一,渲染版结构图):

桶内节点的 hash 字段被复用为类型标记(这是理解一切分派逻辑的钥匙):

hash 值 节点类型 含义
≥ 0(真实哈希) Node / TreeNode 链表 普通键值节点
-1(MOVED) ForwardingNode 该桶已迁移完成,指向新表对应桶
-2(TREEBIN) TreeBin 桶内是红黑树(TreeBin 是树根容器)
-3(RESERVED) ReservationNode computeIfAbsent 的占位节点

2.2 put 主流程总览

先建立全局视角,4~5 章再逐段对照源码:

text 复制代码
put(key, value)
 └─ putVal(key, value, onlyIfAbsent=false)
     ├─ ① key/value 判空(null 直接 NPE)
     ├─ ② table 为 null?──► initTable() 初始化(CAS 抢初始化权,见 3.2)
     ├─ ③ 定位桶:i = (table.length-1) & spread(hash)
     ├─ ④ 桶为 null?──► casTabAt 直接放新 Node(原子),成功即收工
     ├─ ⑤ 桶头 hash == MOVED?──► helpTransfer():放下自己的写,先帮忙扩容
     ├─ ⑥ 否则 synchronized(桶头节点 f):
     │     ├─ 再次校验桶头没变(double-check)
     │     ├─ f 是普通 Node:遍历链表
     │     │     ├─ 找到同 key(equals)──► 覆盖 value(binCount 记录位置)
     │     │     └─ 没找到 ──► 链表尾部插入新 Node
     │     ├─ f 是 TreeBin:调用 putTreeVal 插入/覆盖红黑树
     │     └─ 记录 binCount(链表长度,用于触发树化)
     ├─ ⑦ binCount ≥ 8(TREEIFY_THRESHOLD)──► treeifyBin() 尝试树化
     │     (注意:桶数组 < 64 时先扩容而非树化)
     └─ ⑧ addCount(1L, binCount) 计数,可能触发扩容(见第 5、6 章)

2.3 核心字段表

java 复制代码
// JDK 8 实际字段(节选自 ConcurrentHashMap.java)
transient volatile Node<K,V>[] table;        // 桶数组,懒初始化
private transient volatile Node<K,V>[] nextTable;  // 扩容中的新表(只有扩容期非 null)

private transient volatile long baseCount;   // 基础计数(无竞争时直接 CAS 更新)
private transient volatile int sizeCtl;      // 并发控制总开关(见 2.4 表)
private transient volatile int transferIndex;// 扩容区间游标:下一个待迁移桶的起点
private transient volatile int cellsBusy;    // CounterCell 数组扩容/初始化的锁(0/1)
private transient volatile CounterCell[] counterCells;  // 计数分片(争用分散)
字段 类型 职责
table Node[] 哈希桶数组,volatile 保证扩容后新表立即可见
nextTable Node[] 扩容目标表(容量翻倍),仅扩容期间非 null
baseCount long 无竞争时的计数累加基底
sizeCtl int 状态机核心:初始容量/扩容阈值/初始化标记/扩容线程数,见下
transferIndex int 扩容时从 table.length 递减,多线程从这里领取迁移区间
cellsBusy int CounterCell 表初始化的自旋锁(0=空闲,1=被占用)
counterCells CounterCell[] 计数分片数组,降低热点竞争

2.4 sizeCtl 的语义

sizeCtl 是 CHM 的「大脑」,一个 int 在不同阶段表达四种含义------面试必考点:

sizeCtl 取值 含义
0 表未初始化,且构造时未指定容量(用默认 16)
> 0 表未初始化 → 初始容量;表已初始化 → 扩容阈值 (0.75 * 容量)
-1 有线程正在进行 initTable 初始化(只有一个线程能 CAS 成功)
< -1(如 -2) 正在扩容:-(1 + 参与扩容的线程数),如 -2 表示已有 1 个线程在迁移
java 复制代码
// initTable 里抢初始化权的核心 CAS(节选)
if ((sc = sizeCtl) < 0)                       // sizeCtl 为负 = 别人在初始化/扩容
    Thread.yield();
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {   // 把 0/容量 换成 -1,抢权成功
    try {
        if (tab == null || tab.length == 0) {
            int n = (sc > 0) ? sc : DEFAULT_CAPACITY;     // 容量取自构造参数
            ...
            tab = new Node[n];
            sizeCtl = n - (n >>> 2);                      // 写回扩容阈值 0.75n
        }
    } finally {
        // 失败则恢复
    }
}

关键认知 :CHM 的所有并发协作(初始化、扩容认领、计数分片)都围绕「对一个共享 int 的 CAS 」展开,没有一把全局锁------这就是它高并发的根源。凡是看到对 sizeCtl/cellsBusy/transferIndex 的 compareAndSwapInt,都是「分布式协作点」。

3. 入口与构造

3.1 类声明与重要常量

java 复制代码
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
    implements ConcurrentMap<K,V>, Serializable {

    // ── 容量与阈值常量 ─────────────────────────────
    private static final int DEFAULT_CAPACITY = 16;
    private static final int MAXIMUM_CAPACITY = 1 << 30;
    private static final float LOAD_FACTOR = 0.75f;      // 仅构造参数兼容用(JDK8 内部不用它算阈值)
    static final int TREEIFY_THRESHOLD = 8;              // 桶内链表长度达到 8 → 尝试树化
    static final int UNTREEIFY_THRESHOLD = 6;            // 扩容拆桶后树节点 ≤ 6 → 退化为链表
    static final int MIN_TREEIFY_CAPACITY = 64;          // 桶数组 < 64 时先扩容,不树化

    // ── 桶头节点 hash 字段的特殊标记 ────────────────
    static final int MOVED     = -1;   // ForwardingNode:桶已迁移
    static final int TREEBIN   = -2;   // TreeBin:桶内是红黑树
    static final int RESERVED  = -3;   // ReservationNode:computeIfAbsent 占位
    static final int HASH_BITS = 0x7fffffff;  // 符号位清零,保证 hash 非负

    static final int NCPU = Runtime.getRuntime().availableProcessors();
}

两个反直觉点先记住:

  1. LOAD_FACTOR = 0.75f 在 JDK 8 里只是构造器参数兼容 (把容量换算成立即初始化的 n),扩容阈值一律写成 sizeCtl = n - (n >>> 2),即 0.75n 的位运算写法;
  2. 普通键值节点的 hash 字段存放 spread() 之后的值,必然 ≥ 0------所以判断桶头类型只需看符号位。

3.2 构造器与懒初始化

java 复制代码
// 无参构造:什么都不做(关键!表是懒初始化的)
public ConcurrentHashMap() { }

// 指定容量:只把它换算成 2 的幂存进 sizeCtl,仍然不建表
public ConcurrentHashMap(int initialCapacity) {
    if (initialCapacity < 0) throw new IllegalArgumentException();
    int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
               MAXIMUM_CAPACITY :
               tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
    this.sizeCtl = cap;          // 此时 sizeCtl = 目标容量(正数),不是阈值
}

// 表真正建立发生在第一次 put 的 initTable()

initTable 的并发正确性 (见 2.4 代码):多个线程同时首次 put,都发现 table == null,但只有一个能把 sizeCtl 从 0 CAS 成 -1------抢到者建表,其余线程 Thread.yield() 自旋等待,等 sizeCtl 变成正的阈值后退出。

关键认知 :CHM 没有「构造即分配」的传统,内存占用与启动成本都被推迟到第一次写入。构造传入的 initialCapacity 会被当作「预期含元素数」,内部先加 50% 再取 2 的幂,所以 new ConcurrentHashMap<>(100) 实际容量 256------避免中途扩容。

3.3 Node 内部类体系

五个内部类分工明确(类关系图见第 9 章):

java 复制代码
// 1. 普通节点:桶内链表元素,val/next 都是 volatile
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
    ...
}

// 2. 链表转树时的中间节点(先成双向链表,再搭红黑树)
static final class TreeNode<K,V> extends Node<K,V> {
    TreeNode<K,V> parent;
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;      // 双向链表指针(树退化成链表要靠它)
    boolean red;
}

// 3. 树的桶容器(hash 固定 -2),持有红黑树根 + 首节点链表 + 读写锁状态
static final class TreeBin<K,V> extends Node<K,V> {
    TreeNode<K,V> root;
    volatile TreeNode<K,V> first;   // 链头:写锁竞争时读退化为线性扫描
    volatile int lockState;         // 简化读写锁,见 8.2
    ...
}

// 4. 扩容转发节点(hash 固定 -1):旧桶迁移完成后占位,指向新表
static final class ForwardingNode<K,V> extends Node<K,V> {
    final Node<K,V>[] nextTable;
    ForwardingNode(Node<K,V>[] tab) { super(MOVED, null, null, null); this.nextTable = tab; }
}

// 5. computeIfAbsent 的占位节点(hash 固定 -3),防止递归计算同一 key
static final class ReservationNode<K,V> extends Node<K,V> {
    ReservationNode() { super(RESERVED, null, null, null); }
}

4. putVal 分步详解

put 最终调用 putVal(key, value, onlyIfAbsent),下面按源码顺序分步拆解。

4.1 定位与空桶 CAS

java 复制代码
public V put(K key, V value) { return putVal(key, value, false); }

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();   // ① 双 null 拒绝
    int hash = spread(key.hashCode());                                    // ② 扰动哈希

    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {                                     // ③ 自旋(CAS 失败会重来)
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();                                            // ④ 懒建表(3.2)
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {          // ⑤ 定位桶且桶空
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))          // ⑥ 空桶 CAS 直放
                break;                                                    // ✅ 成功即完成(无锁!)
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);                                   // ⑦ 桶已迁移 → 帮忙扩容
        else {
            ...                                                           // ⑧ 桶非空:桶头加锁(4.3)
        }
    }
    addCount(1L, binCount);                                               // ⑨ 计数
    return null;
}

要点:

  • spread():(h ^ (h >>> 16)) & HASH_BITS------高位参与低位计算(hashCode 低位相同的 key 不再扎堆),同时把符号位抹掉保证 hash ≥ 0(否则会与 MOVED 等负数标记冲突);
  • tabAt / casTabAt:对桶数组元素的 volatile 读写与 CAS,通过 Unsafe.getObjectVolatile 实现(数组元素无法直接用 volatile 修饰);
  • 空桶写入全程无锁:多线程各写各的空桶,各自 CAS 成功,互不干扰------这是 JDK 8 并发度的天花板(= 桶数)。

4.2 MOVED 与协助扩容

f.hash == MOVED 说明这个桶已被迁移走,桶里放的是 ForwardingNode:

java 复制代码
// ForwardingNode 的 find 会把查询转发到新表
static final class ForwardingNode<K,V> extends Node<K,V> {
    final Node<K,V>[] nextTable;
    Node<K,V> find(int h, Object k) {
        outer: for (Node<K,V>[] tab = nextTable;;) {   // 读操作跳到新表继续找
            ...
        }
    }
}

写入方看到 MOVED 不会傻等,而是加入迁移队伍 (helpTransfer,5.2 详述)------写线程在旧表继续写可能丢失新插入的数据,所以必须先把旧桶搬完。这也是 CHM 的一个设计哲学:每个操作者都有义务推进全局状态,而不是旁观。

4.3 桶头加锁写入

桶非空且非 MOVED 时进入核心分支------锁桶头节点:

java 复制代码
else {
    V oldVal = null;
    synchronized (f) {                        // ① 锁粒度 = 单个桶的桶头节点
        if (tabAt(tab, i) == f) {             // ② double-check:等锁期间桶头可能已被搬走/替换
            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;       // ④ 找到同 key → 覆盖
                        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) {  // ⑥ 红黑树桶:交给树去插(8.3)
                Node<K,V> p;
                binCount = 2;
                if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
                    oldVal = p.val;
                    if (!onlyIfAbsent) p.val = value;
                }
            }
        }
    }
    if (binCount != 0) {
        if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i);  // ⑦ 树化判定
        if (oldVal != null) return oldVal;
        break;
    }
}

设计要点逐条说清:

设计点 说明
为什么锁桶头而不是锁数组 桶头是桶内链表的「入口」,锁住它 = 锁住整条链的写入口;不同桶的头节点不同 → 互不阻塞;锁对象无需自建,每个 Node 天然可当锁
double-check 的意义 等锁期间可能发生扩容(本桶被搬走并放上 ForwardingNode),醒来后必须确认桶头仍是 f,否则锁的是「旧世界的空头」
链表插入在锁内 链表尾插 + 计数都必须在锁内完成,保证同桶写入串行
覆盖与返回 put 返回旧值;putIfAbsent(onlyIfAbsent=true)则发现存在就跳过------注意它拿到的也是锁,所以是原子的「不存在才写」
TreeBin 分支 树化后桶头是 TreeBin,插入改走树算法,链表计数直接记 2(避免树内计数)

4.4 树化触发

binCount ≥ 8 时调用 treeifyBin,但并不一定真的树化:

java 复制代码
private final void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b; int n, sc;
    if (tab != null) {
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)   // ① 桶数组 < 64
            tryPresize(n << 1);                        //    → 先扩容到 2n(稀释冲突)
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            synchronized (b) {                         // ② 锁桶头,把链表建成树
                if (tabAt(tab, index) == b) {
                    TreeNode<K,V> hd = null, tl = null;
                    for (Node<K,V> e = b; e != null; e = e.next) {
                        TreeNode<K,V> p = new TreeNode<K,V>(e.hash, e.key, e.val, null, null);
                        if ((p.prev = tl) == null) hd = p;   // 先串成双向链表
                        else tl.next = p;
                        tl = p;
                    }
                    setTabAt(tab, index, new TreeBin<K,V>(hd));  // 桶头换成 TreeBin(-2)
                }
            }
        }
    }
}

关键认知 :桶数组不足 64 时,hash 冲突再严重也不树化,而是先扩容------因为此时树化的收益(log n 查找)敌不过「桶太少、容量马上要翻倍」的浪费;树化是「扩容也救不了的单桶冲突」的兜底。

4.5 计数与阈值检查

putVal 收尾调用 addCount(1L, binCount),计数与扩容在此联动(第 5、6 章详述):

java 复制代码
private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        // 竞争激烈 → 走 counterCells 分片计数(6.2)
        CounterCell a; long v; int m;
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            fullAddCount(x, uncontended);       // 初始化/扩容分片数组
            return;                             // 计数路径在此结束
        }
        if (check <= 1) return;                 // 桶链很短,不用检查扩容
        s = sumCount();
    }
    // check ≥ 0 且元素数超阈值 → 扩容(5.1)
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            int rs = resizeStamp(n);
            if (sc < 0) {
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;                      // 扩容已在进行/已结束,让位
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))   // 登记「我来帮忙」
                    transfer(tab, nt);
            }
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))  // 成为第一个扩容者
                transfer(tab, null);
            s = sumCount();
        }
    }
}

4.6 put 全流程决策图

#mermaid-svg-IOXZnQiGl3ClkheL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IOXZnQiGl3ClkheL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IOXZnQiGl3ClkheL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IOXZnQiGl3ClkheL .error-icon{fill:#552222;}#mermaid-svg-IOXZnQiGl3ClkheL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IOXZnQiGl3ClkheL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IOXZnQiGl3ClkheL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IOXZnQiGl3ClkheL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IOXZnQiGl3ClkheL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IOXZnQiGl3ClkheL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IOXZnQiGl3ClkheL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IOXZnQiGl3ClkheL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IOXZnQiGl3ClkheL .marker.cross{stroke:#333333;}#mermaid-svg-IOXZnQiGl3ClkheL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IOXZnQiGl3ClkheL p{margin:0;}#mermaid-svg-IOXZnQiGl3ClkheL .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-IOXZnQiGl3ClkheL .cluster-label text{fill:#333;}#mermaid-svg-IOXZnQiGl3ClkheL .cluster-label span{color:#333;}#mermaid-svg-IOXZnQiGl3ClkheL .cluster-label span p{background-color:transparent;}#mermaid-svg-IOXZnQiGl3ClkheL .label text,#mermaid-svg-IOXZnQiGl3ClkheL span{fill:#333;color:#333;}#mermaid-svg-IOXZnQiGl3ClkheL .node rect,#mermaid-svg-IOXZnQiGl3ClkheL .node circle,#mermaid-svg-IOXZnQiGl3ClkheL .node ellipse,#mermaid-svg-IOXZnQiGl3ClkheL .node polygon,#mermaid-svg-IOXZnQiGl3ClkheL .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IOXZnQiGl3ClkheL .rough-node .label text,#mermaid-svg-IOXZnQiGl3ClkheL .node .label text,#mermaid-svg-IOXZnQiGl3ClkheL .image-shape .label,#mermaid-svg-IOXZnQiGl3ClkheL .icon-shape .label{text-anchor:middle;}#mermaid-svg-IOXZnQiGl3ClkheL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IOXZnQiGl3ClkheL .rough-node .label,#mermaid-svg-IOXZnQiGl3ClkheL .node .label,#mermaid-svg-IOXZnQiGl3ClkheL .image-shape .label,#mermaid-svg-IOXZnQiGl3ClkheL .icon-shape .label{text-align:center;}#mermaid-svg-IOXZnQiGl3ClkheL .node.clickable{cursor:pointer;}#mermaid-svg-IOXZnQiGl3ClkheL .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IOXZnQiGl3ClkheL .arrowheadPath{fill:#333333;}#mermaid-svg-IOXZnQiGl3ClkheL .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IOXZnQiGl3ClkheL .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IOXZnQiGl3ClkheL .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IOXZnQiGl3ClkheL .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IOXZnQiGl3ClkheL .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IOXZnQiGl3ClkheL .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IOXZnQiGl3ClkheL .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IOXZnQiGl3ClkheL .cluster text{fill:#333;}#mermaid-svg-IOXZnQiGl3ClkheL .cluster span{color:#333;}#mermaid-svg-IOXZnQiGl3ClkheL div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-IOXZnQiGl3ClkheL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IOXZnQiGl3ClkheL rect.text{fill:none;stroke-width:0;}#mermaid-svg-IOXZnQiGl3ClkheL .icon-shape,#mermaid-svg-IOXZnQiGl3ClkheL .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IOXZnQiGl3ClkheL .icon-shape p,#mermaid-svg-IOXZnQiGl3ClkheL .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IOXZnQiGl3ClkheL .icon-shape .label rect,#mermaid-svg-IOXZnQiGl3ClkheL .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IOXZnQiGl3ClkheL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IOXZnQiGl3ClkheL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IOXZnQiGl3ClkheL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
是
成功
失败 有人抢先
否
是
否
是
是
否
否 是 TreeBin
是
否
是
否
是
否
put key value
key 或 value 为 null
抛 NullPointerException
spread 扰动哈希

定位桶 i
table 未初始化
initTable 抢初始化权
桶 i 为空
CAS 放入新 Node
完成
桶头 hash == MOVED
helpTransfer 协助扩容
synchronized 桶头节点
桶头是普通 Node
遍历链表
找到同 key
覆盖 value
尾部插入
putTreeVal 树内插入或覆盖
记录 binCount
binCount ≥ 8
treeifyBin
桶数组 ≥ 64
先扩容 2n
链表转红黑树
addCount 计数
元素数超阈值
触发或加入扩容


5. 扩容机制 transfer

扩容是 CHM 源码里最复杂的部分,也是面试的深水区。JDK 8 的扩容与 JDK 7 最大的不同:全表扩容 + 多线程认领迁移。

5.1 扩容触发点

触发入口 场景
addCount(4.5) 每次 put 后检查元素数 ≥ sizeCtl 阈值
tryPresize putAll、treeifyBin 遇桶数组 < 64 时主动扩
helpTransfer 写入时遇到 MOVED 桶头,加入正在进行的扩容

扩容目标 nextTable = new Node[n << 1](容量翻倍,仍是 2 的幂)。

5.2 多线程分工与区间领取

java 复制代码
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    // ① 每个线程最少搬 stride 个桶:容量 / 8 / CPU 核数,下限 16
    if ((stride = (n >>> 3) / NCPU) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE;

    if (nextTab == null) {              // ② 第一个扩容者负责建新表
        try {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) { ... }
        nextTable = nextTab;
        transferIndex = n;              // ③ 认领游标从最后一个桶开始
    }
    int nextn = nextTab.length;

    // ④ 主循环:CAS 领取一段区间 [bound, i],从高到低迁移
    while (advance < 0) { ... }         // 简化示意
    for (int i = 0, bound = 0;;) {
        // 领取区间:transferIndex 是全局唯一游标,
        // 线程通过 CAS transferIndex -= stride 拿走「从 transferIndex 往前的一段」
        ...
        if (i < 0 || i >= n || i + n >= nextn) { finish = true; break; }
        else if ((f = tabAt(tab, i)) == null) {
            advance = casTabAt(tab, i, null, fwd);   // 空桶直接放 ForwardingNode
        }
        else if ((fh = f.hash) == MOVED) advance = true;  // 别人搬完了
        else {
            synchronized (f) { ... 迁移单个桶(见 5.3)... }
        }
    }
}

分工模型(务必理解「游标领取」而不是「均分」):

text 复制代码
transferIndex 初始 = n(旧表桶数)
                                ┌─ 线程 A 认领 [n-16, n) 这批桶 ─┐
        transferIndex ──CAS──▶  │
   n ──────────────────────────┤ 线程 B 接着认领 [n-32, n-16)
                                │ 线程 C 认领 [n-48, n-32)
                                ▼
  高索引桶 ←───────── 迁移方向 ←───────── 低索引桶
  • 桶数大于 stride × 线程数 时每个线程搬一块;桶少时一个线程也能搬完;
  • 速度快/后来的线程发现 transferIndex ≤ 0(没有剩余桶)就退出,再帮 finish;
  • 空桶迁移零成本:直接 CAS 放一个 ForwardingNode 占位,不用加锁。

5.3 桶迁移与高低位拆分

单个桶的迁移是 putVal 的镜像操作:先 synchronized(f) 锁桶头,再决定桶内元素去新表的哪个位置:

java 复制代码
synchronized (f) {
    if (tabAt(tab, i) == f) {
        Node<K,V> ln, hn;                    // 低位链 / 高位链
        if (fh >= 0) {                       // 普通链表
            int runBit = fh & n;
            // ① 先找到「最后一段 runBit 相同的子链」,直接整段复用
            Node<K,V> lastRun = f;
            for (Node<K,V> p = f.next; p != null; p = p.next) {
                int b = p.hash & n;
                if (b != runBit) { runBit = b; lastRun = p; }
            }
            if (runBit == 0) { ln = lastRun; hn = null; }
            else { hn = lastRun; ln = null; }

            // ② 其余节点按 (hash & n) 拆成高低两条链
            for (Node<K,V> p = f; p != lastRun; p = p.next) {
                int ph = p.hash; K pk = p.key; V pv = p.val;
                if ((ph & n) == 0) ln = new Node<K,V>(ph, pk, pv, ln);
                else              hn = new Node<K,V>(ph, pk, pv, hn);
            }
            setTabAt(nextTab, i, ln);        // ③ 低位链 → 新表原下标 i
            setTabAt(nextTab, i + n, hn);    // ④ 高位链 → 新表下标 i+n
            setTabAt(tab, i, fwd);           // ⑤ 旧桶放 ForwardingNode 收工
            advance = true;
        }
        else if (f instanceof TreeBin) {
            // 树桶:遍历双向链表同样拆成 lo/hi 两条链,
            // 若任一条 ≤ UNTREEIFY_THRESHOLD(6) 则退化成普通 Node 链,
            // 否则各自 new TreeBin 保持树结构
            ...
        }
    }
}

为什么只看 hash & n 一位就能拆分?因为旧表长度 n 是 2 的幂:

关键认知(扩容数学) :桶下标 = (n-1) & hash。扩容到 2n 后,新下标 = (2n-1) & hash = 原下标 或 原下标 + n------多出来的是 hash 二进制里的第 log2(n) 位。所以迁移只需按这一位把一条链拆成「低位链 ln」和「高位链 hn」,分别放到新表的 i 与 i+n 两个桶,节点在新表内不再需要重新哈希(这也是 HashMap 同款优化)。

5.4 ForwardingNode 与无锁读

扩容期间旧表的每个已迁移桶里都放一个 ForwardingNode(-1),它同时承担三个职责:

职责 机制
写保护 写线程定位到该桶 → 知道桶已搬走 → helpTransfer 加入迁移,避免数据写进旧桶丢失
读转发 读线程 get 到 MOVED 桶 → 顺着 ForwardingNode 里保存的 nextTable 引用去新表找(见 7.1)
迁移进度 桶逐个被替换为 ForwardingNode,等于可并行的迁移进度条

所有桶搬完后的收尾(最后一个完成的线程):

java 复制代码
if (finish) {
    nextTable = null;                      // 新表转正
    table = nextTab;
    sizeCtl = (n << 1) - (n >>> 1);        // sizeCtl = 新容量 2n 的 0.75 = 1.5n
    return;
}

关键认知 :扩容期间读线程永不阻塞------要么读旧桶(没搬走),要么经 ForwardingNode 转发到新表(搬走了),新表内部元素的 next 引用链也是完整的;最多看到「新旧交替瞬间的中间状态」,这就是弱一致性。代价是:扩容期间 size() 等统计是近似值。

6. 计数机制

6.1 CounterCell 与伪共享

JDK 7 的 size() 要把各段 count 加起来,竞争激烈时还不准;JDK 8 借鉴 LongAdder 思路:一个全局 baseCount + 一组分片 CounterCell[],写多线程时各自 CAS 自己的分片,把计数器热点打散。

java 复制代码
// 每个分片一个 long,@sun.misc.Contended 注解让每个 Cell 独占一个缓存行(128 字节填充)
@sun.misc.Contended
static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

关键认知 :多个线程频繁写不同 Cell 时,若它们落在同一 CPU 缓存行(64 字节),会互相使对方缓存失效(伪共享 False Sharing),性能断崖。@Contended 注解强制 Cell 之间间隔一个缓存行大小,从物理上隔离热点。

6.2 addCount 与 fullAddCount

计数主路径(源码逻辑见 4.5 节代码):

java 复制代码
// addCount(x, check) 的逻辑:
// ① counterCells 未初始化 且 baseCount CAS 成功 → 完事(无竞争快路径)
// ② 否则按线程探针(ThreadLocalRandom.getProbe)哈希到某个 Cell,CAS 该 Cell
// ③ 该 Cell 也 CAS 失败 → fullAddCount:扩容 Cell 数组(翻倍)或换个 Cell 重试
java 复制代码
// fullAddCount 核心(节选语义):初始化 counterCells 用 cellsBusy 自旋锁保护
private final void fullAddCount(long x, boolean wasUncontended) {
    int h;
    if ((h = ThreadLocalRandom.getProbe()) == 0) {   // 线程探针 = 随机数种子
        ThreadLocalRandom.localInit();
        h = ThreadLocalRandom.getProbe();
        wasUncontended = true;
    }
    boolean collide = false;
    for (;;) {
        CounterCell[] as; CounterCell a; int n; long v;
        if ((as = counterCells) != null && (n = as.length) > 0) {
            // 随机选中一个 Cell,CAS 累加;失败则考虑扩容数组或重哈希
            ...
            else if (cellsBusy == 0 && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                // 拿到 cellsBusy 锁 → counterCells 翻倍扩容
                ...
            }
        }
        else if (cellsBusy == 0 && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
            // counterCells 为 null:初始化 2 个 Cell
        }
        ...
    }
}

6.3 size 与 mappingCount

java 复制代码
public int size() { long n = sumCount(); return (n < 0L) ? 0 : (n > Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n; }

public long mappingCount() {          // JDK8 新增:size 溢出风险的正确打开方式
    long n = sumCount();
    return (n < 0L) ? 0L : n;         // 需要精确值请配 sizeCtl/锁场景自行取舍
}

final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;              // 基数 + 所有分片
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null) sum += a.value;
        }
    }
    return sum;                        // ⚠️ 遍历期间并发写仍在发生 → 弱一致近似值
}

一句话概括 :size() 不做任何加锁与快照,只是「baseCount + 各分片」快速求和,返回的是某一瞬间的近似值;元素数可能超 int 范围时请用 mappingCount()(返回 long)。JDK 文档明确说了「用于监控与统计,不用于精确决策」。


7. 读操作与遍历

7.1 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) {        // ① volatile 读桶头
        if ((eh = e.hash) == h) {                       // ② 桶头就命中
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        else if (eh < 0)                                // ③ hash<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;
}

无锁读的安全性来自三个层次:

层次 保证
tabAt volatile 读 桶数组元素读取一定看到「最近的可见状态」,扩容换表后读到的是新表引用
链表节点的 next 是 volatile 迁移中的链表在锁内被「复制」到新表而非原地改动(5.3 用 new Node 重建),旧链一旦被读线程引用就不会被改写------不变性:已发布的链表只读不写
特殊节点的 find 多态 ForwardingNode → 转发新表;TreeBin → 树查找或退化为链表扫描(8.2)

关键认知 :扩容迁移是「复制式」而非「移动式」------旧桶的节点链在迁移时被新建成两条链放进新表,旧链表结构从不被原地修改(除了桶头换成 ForwardingNode 这一步 CAS)。所以读线程无论拿到旧还是新结构都安全,这就是无锁读的根基。

7.2 remove 与 replace

java 复制代码
public V remove(Object key) { return replaceNode(key, null, null); }

final V replaceNode(Object key, V value, Object cv) {
    int hash = spread(key.hashCode());
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || ... || (f = tabAt(tab, i = (n-1)&hash)) == null)
            break;                                  // 桶不存在 → 无需删
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);             // 扩容中先帮忙
        else {
            boolean validated = false; boolean deleted = false;
            synchronized (f) {                      // 同样锁桶头
                if (tabAt(tab, i) == f) {
                    validated = true;
                    if (fh >= 0) {                  // 链表删除:prev.next = e.next
                        ...
                    }
                    else if (f instanceof TreeBin) {
                        // 树删除:removeTreeNode(会维护双向链表 + 红黑树平衡),
                        // 桶内若只剩少量节点不主动转链表(等扩容时再退化)
                    }
                }
            }
        }
    }
    // 删除成功 → addCount(-1L, -1) 回退计数
}

replace(key, oldValue, newValue) 走同一个 replaceNode(key, newValue, cv=oldValue) 重载:在锁内校验旧值 ,实现原子的条件替换。remove/replace 与 put 共用同一套桶锁,所以「读-改-写」组合(如 containsKey 后 put)之间没有原子性------需要原子复合请用 7.4 的 compute 系或 putIfAbsent。

7.3 弱一致性迭代器

JDK 8 的迭代器(KeyIterator/ValueIterator/EntryIterator)基于 Traverser 设计,遍历规则:

java 复制代码
// 语义:逐桶前进;遇到 ForwardingNode 则顺着 nextTable 继续找该桶在新表的位置
// 特点:全程无锁、不抛 ConcurrentModificationException、遍历期间的新增/删除"部分可见"
static final class Traverser<K,V> {
    Node<K,V>[] tab;        // 当前正在读的表
    ...
    Node<K,V> advance() {
        // 桶内走完 → 下一个桶;桶头是 ForwardingNode → 进入 nextTable 对应桶继续
    }
}

弱一致性的具体表现:

场景 遍历结果
遍历开始后其他线程 put 新 key 新元素可能出现在「还没遍历到的桶」,会被看到;已遍历过的桶则看不到
遍历期间删除元素 已被迭代器持引用的旧节点仍能读到(不抛异常)
遍历期间扩容 迭代器自动追到新表继续;可能读到同一 key 的新旧两个值(旧桶节点被持有引用时)

一句话概括:CHM 迭代器是「无锁 + 尽力而为」的快照,迭代期间容器可写、不抛 CME,但不保证看到迭代开始后的全部变化------这正是为了性能付出的语义代价(适合统计/拷贝/转移等批量操作)。

7.4 复合操作 compute 系列

computeIfAbsent / compute / merge 都拿到桶锁后执行,保证原子性;其中 computeIfAbsent 需要占位节点防止递归计算:

java 复制代码
public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) {
    ...
    for (Node<K,V>[] tab = table;;) {
        ...
        else if (fh == RESERVED)               // 槽位被本线程自己占着?
            throw new IllegalStateException("Recursive update");   // JDK9+:递归检测
        ...
        // 没找到 → synchronized(f) 内在桶尾插入一个 ReservationNode(RESERVED) 占位
        // 释放锁后调用 mappingFunction.apply(key) 计算(慢操作不在锁内做!)
        // 算完带着结果再回来把占位节点替换成真实节点;算出来 null 则删除占位
    }
}

两个设计细节很值钱:

  1. 计算函数在锁外执行 :占位后先释放锁再调 mappingFunction,避免把用户代码锁在桶上(若在锁内计算,用户函数里再碰这个 CHM 就直接死锁);
  2. 递归检测 :若用户函数内部又对同一个 key 调 computeIfAbsent,会发现占位节点并抛 IllegalStateException("Recursive update")------⚠️ 这个保护是 JDK 9+ 才补全的 (JDK-8160404/8180352):JDK 8 的 computeIfAbsent 遇到递归更新会死循环或内存溢出,经典线上事故(如 Spring 早期某些嵌套缓存加载)。线上跑 JDK 8 的同学留意:computeIfAbsent 的回调里不要再查同一个 key。

8. 树化与 TreeBin

8.1 8 与 6 两个阈值

阈值 值 触发方向
TREEIFY_THRESHOLD 8 桶内链表 ≥ 8 且桶数组 ≥ 64 → 链表转红黑树
UNTREEIFY_THRESHOLD 6 扩容拆桶后,树桶内节点 ≤ 6 → 红黑树退化链表
MIN_TREEIFY_CAPACITY 64 桶数组长度不足时先扩容不树化

为什么是 8?官方注释给了依据------泊松分布 :在理想随机哈希下,单个桶内节点数 k 的概率约为 (0.5)^k / k!(负载因子 0.75 时到达次数 λ≈0.5):

桶内节点数 k 概率
8 约 0.00000006(千万分之六)
10 约 1.8e-13

链表到树要到 8 才触发,说明这是「哈希已经严重失守(或有人恶意构造冲突)」的异常信号;反过来退化阈值取 6 而非 8,是为了避免元素数在 7 附近震荡时链表/树反复横跳 (8 与 6 之间留了 1 的滞回带)。这两个数字同时暗示了:正常数据下红黑树几乎不会出现,树化主要是「反哈希碰撞攻击」的防御(如让 HashMap 退化成链表做 DoS)。

8.2 TreeBin 的读写锁思路

TreeBin 本身是个 Node(hash=-2),它作为桶头被 put/remove 的 synchronized 锁住(写互斥已由桶锁保证)。但 get 是无锁的 ,它怎么安全地并发读树?答案是 TreeBin 内部的 lockState 简化读写锁:

java 复制代码
static final int WRITER = 1;   // bit0:写者锁(持有者 = 树结构调整方)
static final int WAITER = 2;   // bit1:有写者在等锁
static final int READER = 4;   // bit2+:读者计数(每次 +4)

// 树查找(get 路径)------对读者友好
final Node<K,V> find(int h, Object k) {
    ...
    for (Node<K,V> e = first; e != null; ) {
        int s; K ek;
        if (((s = lockState) & (WAITER | WRITER)) != 0) {
            // ① 有写者在场 → 直接沿 first 双向链表线性扫描(绝不阻塞读)
        }
        else if (U.compareAndSwapInt(this, LOCKSTATE, s, s + READER)) {
            // ② 无写者 → CAS 把 reader 计数 +1,然后放心走红黑树查找
            TreeNode<K,V> r, p; ... return p;   // finally 中 CAS 回退 reader 计数
        }
        // CAS 失败(与写者竞争瞬间)→ 退化为链表扫描
    }
}
操作 锁 读是否阻塞
桶头 put/remove(先于树操作) synchronized(TreeBin) 写写互斥
树查找 get lockState 读者计数(CAS) 不阻塞,最坏线性扫描
树结构调整(删除节点后的平衡) lockRoot():CAS lockState → WRITER 读线程不退让就置 WAITER 标记自旋/park 等

树维护两个结构:红黑树(查) + first 串起的双向链表(写者在场时的兜底查 + 扩容拆桶的遍历依据)------双结构是 TreeBin 优雅并发读的基础。

关键认知 :JDK 8 的 CHM 树化后读写并发策略是「读者绝不等待写者,写者尽量不打扰读者」:树内查找走 CAS 读者计数;一有写者(删除后平衡树)在读,新读者直接降级走链表。这也是 get 在树桶上依旧无锁的原因。

8.3 树与链表的双向转换

text 复制代码
链表 ──(桶内节点≥8 且 table≥64, 锁桶头)──▶  TreeBin(双向链表 → 平衡成红黑树)
 TreeBin ──(扩容拆桶后 lo/hi 任一条 ≤6)──▶  普通 Node 链表
 TreeBin ──(桶内节点仍多)─────────────────▶  两个新 TreeBin 各走一边
  • 链表→树:4.4 的 treeifyBin:先在锁内把 Node 链改建成 TreeNode 双向链表,再交给 TreeBin 构造时做红黑树平衡(balanceInsertion);
  • 树→链表 :只发生在扩容拆桶(5.3)时------拆出的高低两条链若 ≤6 节点,直接退化;桶内删除(removeTreeNode)不主动退化,避免「删一个就重构」;
  • TreeNode 继承 Node 且持 prev 指针,拆桶时可以像链表一样遍历拆分,无需在树上做 split 算法。

9. 关键类关系图

#mermaid-svg-s4IneM1vkb6mLYx8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-s4IneM1vkb6mLYx8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-s4IneM1vkb6mLYx8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-s4IneM1vkb6mLYx8 .error-icon{fill:#552222;}#mermaid-svg-s4IneM1vkb6mLYx8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-s4IneM1vkb6mLYx8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-s4IneM1vkb6mLYx8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-s4IneM1vkb6mLYx8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-s4IneM1vkb6mLYx8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-s4IneM1vkb6mLYx8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-s4IneM1vkb6mLYx8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-s4IneM1vkb6mLYx8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-s4IneM1vkb6mLYx8 .marker.cross{stroke:#333333;}#mermaid-svg-s4IneM1vkb6mLYx8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-s4IneM1vkb6mLYx8 p{margin:0;}#mermaid-svg-s4IneM1vkb6mLYx8 g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-s4IneM1vkb6mLYx8 g.classGroup text .title{font-weight:bolder;}#mermaid-svg-s4IneM1vkb6mLYx8 .cluster-label text{fill:#333;}#mermaid-svg-s4IneM1vkb6mLYx8 .cluster-label span{color:#333;}#mermaid-svg-s4IneM1vkb6mLYx8 .cluster-label span p{background-color:transparent;}#mermaid-svg-s4IneM1vkb6mLYx8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-s4IneM1vkb6mLYx8 .cluster text{fill:#333;}#mermaid-svg-s4IneM1vkb6mLYx8 .cluster span{color:#333;}#mermaid-svg-s4IneM1vkb6mLYx8 .nodeLabel,#mermaid-svg-s4IneM1vkb6mLYx8 .edgeLabel{color:#131300;}#mermaid-svg-s4IneM1vkb6mLYx8 .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-s4IneM1vkb6mLYx8 .label text{fill:#131300;}#mermaid-svg-s4IneM1vkb6mLYx8 .labelBkg{background:#ECECFF;}#mermaid-svg-s4IneM1vkb6mLYx8 .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-s4IneM1vkb6mLYx8 .classTitle{font-weight:bolder;}#mermaid-svg-s4IneM1vkb6mLYx8 .node rect,#mermaid-svg-s4IneM1vkb6mLYx8 .node circle,#mermaid-svg-s4IneM1vkb6mLYx8 .node ellipse,#mermaid-svg-s4IneM1vkb6mLYx8 .node polygon,#mermaid-svg-s4IneM1vkb6mLYx8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-s4IneM1vkb6mLYx8 .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-s4IneM1vkb6mLYx8 g.clickable{cursor:pointer;}#mermaid-svg-s4IneM1vkb6mLYx8 g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-s4IneM1vkb6mLYx8 g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-s4IneM1vkb6mLYx8 .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-s4IneM1vkb6mLYx8 .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-s4IneM1vkb6mLYx8 .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-s4IneM1vkb6mLYx8 .dashed-line{stroke-dasharray:3;}#mermaid-svg-s4IneM1vkb6mLYx8 .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-s4IneM1vkb6mLYx8 #compositionStart,#mermaid-svg-s4IneM1vkb6mLYx8 .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-s4IneM1vkb6mLYx8 #compositionEnd,#mermaid-svg-s4IneM1vkb6mLYx8 .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-s4IneM1vkb6mLYx8 #dependencyStart,#mermaid-svg-s4IneM1vkb6mLYx8 .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-s4IneM1vkb6mLYx8 #dependencyStart,#mermaid-svg-s4IneM1vkb6mLYx8 .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-s4IneM1vkb6mLYx8 #extensionStart,#mermaid-svg-s4IneM1vkb6mLYx8 .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-s4IneM1vkb6mLYx8 #extensionEnd,#mermaid-svg-s4IneM1vkb6mLYx8 .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-s4IneM1vkb6mLYx8 #aggregationStart,#mermaid-svg-s4IneM1vkb6mLYx8 .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-s4IneM1vkb6mLYx8 #aggregationEnd,#mermaid-svg-s4IneM1vkb6mLYx8 .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-s4IneM1vkb6mLYx8 #lollipopStart,#mermaid-svg-s4IneM1vkb6mLYx8 .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-s4IneM1vkb6mLYx8 #lollipopEnd,#mermaid-svg-s4IneM1vkb6mLYx8 .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-s4IneM1vkb6mLYx8 .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-s4IneM1vkb6mLYx8 .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-s4IneM1vkb6mLYx8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-s4IneM1vkb6mLYx8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-s4IneM1vkb6mLYx8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 继承
table 桶数组元素
桶内树容器 hash=-2
分片计数
红黑树根与链表
ConcurrentHashMap
-table Node\[\]
-nextTable Node\[\]
-baseCount long
-sizeCtl int
-transferIndex int
-counterCells CounterCell\[\]
+put(key, value)
+get(key)
+remove(key)
+size()
+putIfAbsent(...)
Node
#hash int
#key K
#val V volatile
#next Node volatile
TreeNode
-parent TreeNode
-left TreeNode
-right TreeNode
-prev TreeNode 双向链表
-red boolean
TreeBin
-root TreeNode
-first TreeNode 链头
-lockState int 读写锁
+putTreeVal(...)
+find(h, k)
ForwardingNode
-nextTable Node\[\]
+find(h, k) : 转发到新表
ReservationNode
占位 computeIfAbsent
CounterCell
-value long Contended
Map

读图要点:所有「特殊桶头」都是 Node 的子类,table[i] 这一格子里永远放 Node------put/get/remove 拿到桶头后先看 hash 符号与类型再分派,这是理解整份源码的「总开关」;CounterCell 与主表无继承关系,只服务于计数。

10. 可运行示例

10.1 并发读写与遍历 demo

演示多线程写入、复合操作与弱一致遍历(JDK 8+,直接 javac/java 可运行):

java 复制代码
// ChmConcurrentDemo.java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;

public class ChmConcurrentDemo {
    public static void main(String[] args) throws Exception {
        final ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        final int threads = 8, each = 10_000;
        CountDownLatch latch = new CountDownLatch(threads);

        // 8 个线程各写各的 key 区间:几乎无桶冲突 → 大部分写入是空桶 CAS,零加锁
        for (int t = 0; t < threads; t++) {
            final int id = t;
            new Thread(() -> {
                for (int i = 0; i < each; i++) map.put("k-" + id + "-" + i, i);
                latch.countDown();
            }, "writer-" + t).start();
        }
        latch.await();
        System.out.println("写入完成后 size        = " + map.size());      // 80000
        System.out.println("mappingCount           = " + map.mappingCount());

        // 读:无锁;key 不存在返回 null(CHM 本身不允许存 null,无二义性)
        System.out.println("get 不存在的 key       = " + map.get("nope"));

        // putIfAbsent:已存在则返回旧值不覆盖(原子的「不存在才写」)
        Integer old = map.putIfAbsent("k-0-1", -1);
        System.out.println("putIfAbsent 返回旧值    = " + old);            // 1

        // computeIfAbsent:mappingFunction 只对缺席的 key 执行一次
        map.computeIfAbsent("hot-key", k -> k.length() * 100);
        map.computeIfAbsent("hot-key", k -> k.length() * 100);
        System.out.println("computeIfAbsent 结果    = " + map.get("hot-key"));  // 700

        // 弱一致性迭代:遍历期间另一个线程持续写入 → 不抛 CME
        final AtomicLong seen = new AtomicLong();
        Thread mutator = new Thread(() -> {
            for (int i = 0; i < 100_000; i++) map.put("mut-" + i, i);
        });
        mutator.start();
        for (Map.Entry<String, Integer> e : map.entrySet()) seen.incrementAndGet();
        mutator.join();
        System.out.println("迭代期间看到的条目 >= " + seen.get());          // 不一定 80000+,不抛异常即可
        System.out.println("迭代结束后 size     = " + map.size());
    }
}
bash 复制代码
$ javac ChmConcurrentDemo.java && java ChmConcurrentDemo
写入完成后 size        = 80000
mappingCount           = 80000
get 不存在的 key       = null
putIfAbsent 返回旧值    = 1
computeIfAbsent 结果    = 700
迭代期间看到的条目 >= 80000   # 数值不确定(弱一致),但不抛 ConcurrentModificationException
迭代结束后 size     = 180000

10.2 简易缓存 demo

用 CHM 实现线程安全、惰性过期、并发加载只执行一次的本地缓存(模拟常见中间件缓存层写法):

java 复制代码
// SimpleCache.java
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;

public class SimpleCache {
    private static class Entry {
        final Object value;
        final long expireAt;
        Entry(Object value, long ttlMillis) {
            this.value = value;
            this.expireAt = System.currentTimeMillis() + ttlMillis;
        }
        boolean expired() { return System.currentTimeMillis() > expireAt; }
    }

    private final Map<String, Entry> store = new ConcurrentHashMap<>();

    /** 读取:无锁 get + 惰性过期(过期不主动清理,读到才丢弃) */
    public Object get(String key) {
        Entry e = store.get(key);
        return (e == null || e.expired()) ? null : e.value;
    }

    /** 写入:带 TTL 的 put */
    public void put(String key, Object value, long ttlMillis) {
        store.put(key, new Entry(value, ttlMillis));
    }

    /** 缓存加载器:多线程同时 miss 同一 key 时,loader 只执行一次(computeIfAbsent 语义) */
    public Object getOrLoad(String key, long ttlMillis, Function<String, Object> loader) {
        Object hit = get(key);
        if (hit != null) return hit;
        // 返回 null 表示不缓存该 key;mappingFunction 在桶锁保护下只执行一次
        Entry loaded = store.computeIfAbsent(key,
                k -> { Object v = loader.apply(k);
                       return v == null ? null : new Entry(v, ttlMillis); });
        return loaded == null ? null : loaded.value;
    }

    public static void main(String[] args) throws Exception {
        SimpleCache cache = new SimpleCache();
        Thread[] ts = new Thread[10];
        for (int t = 0; t < ts.length; t++) {
            final int id = t;
            ts[t] = new Thread(() -> {
                // 10 个线程同时打同一个 key:期望 [loader] 只打印一次
                Object v = cache.getOrLoad("user:10086", 5_000,
                        k -> { System.out.println("  [loader] 加载 " + k);
                               return "value-of-" + k; });
                System.out.println("线程 " + id + " 拿到 " + v);
            });
            ts[t].start();
        }
        for (Thread t : ts) t.join();

        System.out.println("TTL 内直接命中: " + cache.get("user:10086"));
        Thread.sleep(5_200);                       // 等 TTL(5s) 过期
        System.out.println("TTL 过期后再读: " + cache.get("user:10086"));   // null
    }
}
bash 复制代码
$ javac SimpleCache.java && java SimpleCache
  [loader] 加载 user:10086          # 只出现一次 → 加载逻辑只执行了一次
线程 0 拿到 value-of-user:10086
线程 1 拿到 value-of-user:10086
...(10 行,值相同)
TTL 内直接命中: value-of-user:10086
TTL 过期后再读: null

⚠️ 扩展思考:上述 getOrLoad 存在「读到了刚过期但尚未被替换的 Entry」的极小窗口(先 get 后 computeIfAbsent 不是原子操作)------生产级缓存请用 compute 系列在锁内完成过期判断,或用 Caffeine/Guava Cache 这种成熟实现。


11. 横向对比总表

维度 Hashtable Collections.synchronizedMap CHM JDK 7 CHM JDK 8+ HashMap(线程私有)
锁粒度 整表 整表 16 分段 单桶(冲突时) 无
无冲突写入 全程锁 全程锁 锁分段 一次 CAS CAS 都不用(单线程)
读 锁 锁 无锁(volatile) 无锁 无锁
扩容 全表锁后搬 全表锁后搬 段内搬 多线程协作搬 单线程搬
计数 size 变量 --- 段 count 和 CounterCell 分片 modCount+size
null 不允许 取决于包装的 Map 不允许 不允许 key/value 均可
迭代 fail-fast fail-fast 弱一致 弱一致 fail-fast
适用 遗留代码 快速小规模包装 已被 8 取代 默认选择 无线程共享

决策建议:

  1. 无并发共享 → HashMap;
  2. 多线程共享、需要并发读写 → ConcurrentHashMap(JDK 8+);
  3. 需要有序/范围查询 → ConcurrentSkipListMap;
  4. Hashtable / synchronizedMap 只出现在「无法改代码」的遗留场景。

12. FAQ 与扩展点

Q1:为什么 JDK 8 放弃分段锁,改用 CAS + synchronized 锁桶头?

见 1.3 的三条原因,浓缩为一句:分段锁把并发度锁死在 16,且无论冲突与否写入都要碰锁;桶锁粒度细到「桶数」、无冲突写入退化为 CAS、读完全无锁,加上 JDK 6 后 synchronized 已被 JVM 深度优化,官方注释直言新方案的吞吐与内存占用都优于分段锁。

Q2:CHM 为什么不允许 null key / null value?HashMap 却允许?

get 返回 null 有两种可能:key 不存在、或映射到 null。单线程 HashMap 可以再调一次 containsKey 消除歧义;而 CHM 要在并发下维持这个歧义判断,会让无锁读的语义变得复杂且易误导调用方(也影响 get/putIfAbsent/compute 等复合操作的原子语义),因此设计上直接拒绝 null(put 即抛 NPE)。这是 Doug Lea 在源码注释里点明的刻意取舍。

Q3:桶数组长度为什么必须保持 2 的幂?

两处依赖:① 下标计算用位运算 (n-1) & hash 替代取模;② 扩容时 hash & n 一位即可判定节点去新表的 i 还是 i+n(5.3),整条链高低拆分、无需重哈希。

Q4:sizeCtl 负数两种含义如何区分?

-1 专指「初始化中」(initTable 的 CAS 目标);小于 -1 表示「扩容中」,且 -(1 + 线程数) 编码了参与者数量------首个扩容线程把 sizeCtl CAS 成 (resizeStamp << 16) + 2(即 -(1+1)),后续每加入一个线程 +1。观察 jstack 或调 sizeCtl(反射)可确认是否在扩容。

Q5:扩容期间正在读的线程安全吗?会不会读到丢失或半截数据?

安全。迁移是「复制式」的:桶内链表在桶锁保护下被复制重建进新表,旧链表除桶头被 CAS 换成 ForwardingNode 外不再被修改;读线程要么读旧桶残留链(完整),要么经 ForwardingNode 转发到新表(完整)。两个结构各自一致,读线程只是可能看到「搬走前/后」的其中一个,绝不会看到撕裂的数据------代价是弱一致性。

Q6:树化阈值为什么是 8?退化为什么是 6?

泊松分布下理想哈希的桶内节点数 ≥8 的概率约千万分之六,达到 8 说明哈希已严重退化(或遭恶意冲突攻击),此时树化作为兜底;退化取 6 是为了在 7 附近留滞回带,避免频繁「树化↔退化」抖动。MIN_TREEIFY_CAPACITY=64 保证桶足够多时树化才有意义。

Q7:size() 返回的值精确吗?

不保证。sumCount() 不加锁地累加 baseCount + 各 CounterCell,统计瞬间并发写仍在进行,返回值是近似值;文档定位为监控统计用途。需要精确容量(如做淘汰、配额判断)应在业务层自行同步或接受近似。

Q8:JDK 8 的 computeIfAbsent 有什么著名大坑?

回调内递归更新同一个 key 会死循环 (链表越插越长 / 或行为异常),该问题在 JDK 9+ 通过 ReservationNode 占位检测修复------递归更新会抛 IllegalStateException("Recursive update")。JDK 8 上线的系统要警惕:mappingFunction 里若触发了对同一 CHM 同 key 的读写(如回调里查缓存、Spring 早期嵌套 bean 加载),可能直接打满 CPU。规避:回调内绝不访问同一个 key。

Q9:CHM 的迭代器与 HashMap 的有什么本质区别?

HashMap 迭代器是 fail-fast:遍历期间结构性修改立刻抛 ConcurrentModificationException(靠 modCount 检测);CHM 迭代器是弱一致:无锁、不抛异常、尽力看到遍历开始时的快照并容忍并发修改(7.3)。批量拷贝/转移/聚合请用 CHM 迭代器或 forEach,别担心遍历中被改。

Q10:JDK 9~17 里 CHM 结构有变化吗?

核心结构(桶数组 + Node + TreeBin + 扩容协作 + CounterCell)自 JDK 8 定型后保持稳定;后续主要是行为修复与增强:computeIfAbsent 递归检测(JDK 9)、remove 相关边缘并发修复、文档与 Javadoc 完善等。分析 JDK 8 源码的结论在当代版本依然成立------这也是它值得精读的原因。


13. 参考资料与延伸

相关推荐
一 乐32 分钟前
动漫书销售商城|基于springboot + vue动漫书销售商城(源码+数据库+文档)
java·数据库·vue.js·spring boot·毕业设计
卓怡学长1 小时前
w214基于jsp知道特产网
java·intellij-idea
步行cgn1 小时前
Spring 注解使用详解
java·spring
一条小小yu1 小时前
Spring IoC的理解
java·后端·spring
落魄实习生2 小时前
Agent Scope Java 2.x 系列【7】工具使用
java·开发语言·ai
旺仔学长 哈哈2 小时前
springboot钓鱼爱好者交流平台APP设计与实现
java·spring boot·mysql·充电桩管理系统
自强的小白3 小时前
核心功能(Service接口)
java·mybatis
乌暮4 小时前
深入理解 Java 泛型:把「万能盒子」用对、用稳
java·开发语言·后端·学习
大猫和小黄4 小时前
深入理解 Java Optional:从设计哲学到工程实战
java