首页 / Java 学习笔记 / 05

JAVA · Vol.I · DAY 05 · 集合框架源码

ConcurrentHashMap 深度剖析:put 流程、size 统计与扩容

深度极高#集合#并发#源码
第 1 站

一次线上事故:HashMap 在多线程下「CPU 100%」

凌晨 2 点,监控告警:某用户中心服务的 CPU 冲到 100%,接口 RT 从 30ms 飙到 8s,大量请求超时。运维抓到的线程栈里,所有业务线程都卡在同一个地方——HashMap.get() 内部的 next 指针遍历上,形成一个永不结束的环形链表。原因不复杂:HashMap 被多个线程并发 put,JDK 1.7 的头插法在扩容时让链表成环,读线程从此死循环。

面试追问:如果把这个 HashMap 直接换成 Hashtable 或 Collections.synchronizedMap,问题解决了吗?代价是什么?如果换成 ConcurrentHashMap,它的 put / size / 扩容又是怎么做到既线程安全又高性能的?

解决方案当然是换成 ConcurrentHashMap(下文简称 CHM)。但「换一个类」背后是一整套并发设计:无锁读、CAS 插入、桶级 synchronized、分段计数、多线程协作扩容。本文就把 JDK 1.8 的 CHM 从数据结构到方法级源码拆给你看——这也是 Java 后端面试里出现频率最高、最能拉开差距的考点之一。

后端类比:锁的粒度就是并发的上限

Hashtable 相当于给整张表加了一把大锁——任何读写都串行,等同「数据库表锁」;1.7 的 Segment 分段锁相当于「分区表 + 每分区一把锁」,并发度固定为分区数;1.8 的桶级锁相当于「行锁 + 索引定位」,锁只落在命中的那一行上。并发设计的第一性原理:锁的粒度越细,并发上限越高

核心要点
  • HashMap 线程不安全:1.7 头插法在并发扩容时形成环形链表,get 死循环;1.8 尾插法修复了环,但 put 覆盖、size 计数仍会丢数据。
  • CHM 的目标:并发场景下既保证线程安全,又让读操作尽量无锁、写操作只锁最小范围。
  • 本文主线:数据结构 → put 流程 → addCount 计数 → size 弱一致 → transfer 协作扩容,全部落到 JDK 1.8 源码。
第 2 站

演进总览:从一把大锁到桶级细粒度并发

三代实现,三个时代

版本并发控制数据结构锁粒度并发上限
JDK 1.0 ~ 1.4Hashtable:整表 synchronizedEntry 数组 + 链表整张表1
JDK 1.5 ~ 1.7Segment 分段锁(继承 ReentrantLock)Segment[] → HashEntry[] 链表Segment(默认 16 个)16(固定)
JDK 1.8CAS + synchronized(锁桶头节点)Node[] 数组 + 链表 + 红黑树单个桶(数组元素)随数组长度线性扩展
JDK 1.7:Segment 分段锁(并发度 = Segment 数,默认 16) Segment[] 每个元素是一把独立的 ReentrantLock 锁 Segment[0] Segment[1] Segment[...] HashEntry[] HashEntry[] 每个 Segment 内再按 HashEntry 链表寻址 JDK 1.8:Node[] + CAS + synchronized(锁粒度 = 单个桶) null 链表 Node Node Node volatile Node[] table Node Node 链表(volatile next 串联) T TreeBin 红黑树(链表 ≥ 8 且数组 ≥ 64)
图 11.7 与 1.8 结构对比:锁从「段级」细化到「桶级」,读完全无锁
一句话点破 1.8 的设计核心

1.8 的 CHM 与 1.8 的 HashMap 结构完全一致(数组 + 链表 + 红黑树),唯一的区别是:写操作在定位到桶之后,对桶头节点加 synchronized;而读操作利用 volatile 语义完全不加锁。你可以把 1.8 的 CHM 理解为「HashMap 的每个桶都戴了一把小锁」。

第 3 站

JDK 1.7 分段锁:Segment 结构与 put 流程

结构:Segment 继承 ReentrantLock

1.7 的 CHM 是一个 Segment[] 数组。构造时把「并发级别」concurrencyLevel(默认 16)向上取整到 2 的幂得到 ssize,作为 Segment 数组长度;再把「总容量」均分到每个 Segment。每个 Segment 继承 ReentrantLock,内部持有一个 HashEntry[] 桶数组——锁和数据结构是合体的

JDK 1.7 ConcurrentHashMap 核心结构 · 简化示意
// 常量:默认初始容量 16,负载因子 0.75,并发级别 16,最大段数 1 << 16
static final int DEFAULT_CONCURRENCY_LEVEL = 16;
final Segment<K,V>[] segments;

static class Segment<K,V> extends ReentrantLock {
    transient volatile HashEntry<K,V>[] table; // 本段桶数组
    transient int count;    // 本段元素个数(size 统计用)
    transient int modCount; // 本段修改次数(一致性校验用)
    transient int threshold; // 本段扩容阈值 = 容量 × 0.75
}

static class HashEntry<K,V> {
    final int hash;
    final K key;
    volatile V value;          // volatile 保证读可见性
    volatile HashEntry<K,V> next; // volatile 且用 putOrderedObject 安全发布
}

注意一个常被误解的点:1.7 的 HashEntry.nextvolatile 的,写链表时用 Unsafe.putOrderedObject(释放写)发布,配合 key/hash 的 final 不可变性,才让链表读可以不加锁。它不是「不可变字段」,而是「volatile + 安全发布」。

put 流程:tryLock 自旋 + 头插法

JDK 1.7 Segment.put · 关键步骤
V put(K key, int hash, V value, boolean onlyIfAbsent) {
    HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
    // ① tryLock 成功直接往下走;失败则 scanAndLockForPut:
    //    在锁外先定位桶、顺带构造好新节点,自旋 retries 次后 lock() 阻塞等待
    V oldValue;
    try {
        HashEntry<K,V>[] tab = table;
        int index = (tab.length - 1) & hash;
        HashEntry<K,V> first = entryAt(tab, index);
        for (HashEntry<K,V> e = first;;) {
            if (e != null) {
                if (key 已存在) { 覆盖 value; break; }
                e = e.next;
            } else {
                // ② 头插法:新节点插到链表头部
                node.setNext(first);
                if (++count > threshold && tab.length < MAXIMUM_CAPACITY)
                    rehash(node);        // ③ 本段独立扩容(2 倍)
                else
                    setEntryAt(tab, index, node);
                break;
            }
        }
    } finally { unlock(); }
    return oldValue;
}
追问:1.7 的 put 为什么用 tryLock + scanAndLockForPut 自旋,而不是直接 lock()?

为了减少锁竞争等待:tryLock() 失败说明段锁被占,此时锁外先干能干的活——定位桶、构造好新节点,再自旋 MAX_SCAN_RETRIES(单核 1、多核 64)次;自旋期间若抢到锁就直接用,抢不到才 lock() 挂起。这是典型的「锁外准备 + 短临界区」优化。但注意:rehash 是单线程做的(持有段锁),大 Segment 扩容会阻塞同段所有写线程。

1.7 的三个硬伤

  • 并发度封顶:最大并发 = Segment 数(默认 16),即使 128 核也只并行 16 路写。
  • size() 昂贵且不精确:先无锁遍历两遍各段 count 并比对 modCount 总和,不一致就 tryLock 全部段再数,仍不行就全部加锁重数——大表下开销极大。
  • 无红黑树:hash 冲突多时链表 O(n),且扩容是段内单线程 2 倍。
核心要点
  • 1.7 = Segment(继承 ReentrantLock,锁与桶数组合体)+ HashEntry 链表 + 头插法。
  • put 路径:tryLock → scanAndLockForPut 自旋 → 锁内定位桶 → 头插 → 段内 rehash。
  • 局限:并发度固定 16、size 要锁全段、链表无树化——这三个问题 1.8 全部重写。
第 4 站

JDK 1.8 数据结构与字段全景

四个节点类型,四种语义

JDK 1.8 节点类 · 字段级
// 普通链表节点:注意字段名是 val,不是 value(与 HashMap 不同)
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;   // 经 spread() 处理后的散列值
    final K key;       // key 不可变 → 读不用锁
    volatile V val;   // volatile 保证读可见性
    volatile Node<K,V> next;
}

// 红黑树桶的根包装:hash 固定为 TREEBIN(-2)
static final class TreeBin<K,V> extends Node<K,V> {
    volatile TreeNode<K,V> root;  // 树根
    volatile TreeNode<K,V> first; // 链表头(保留链表便于遍历)
    volatile Thread waiter;
    volatile int lockState;        // 0 空闲 / WRITER=1 / WAITER=2 / READER=4(按位叠加)
}

// 扩容占位节点:hash 固定为 MOVED(-1),指向新数组
static final class ForwardingNode<K,V> extends Node<K,V> {
    final Node<K,V>[] nextTable;
}

// computeIfAbsent 的临时占位节点:hash 固定为 RESERVED(-3)
static final class ReservationNode<K,V> extends Node<K,V> {}

关键字段与常量

字段 / 常量含义
transient volatile Node[] table主桶数组,volatile 引用;数组元素本身无 volatile 语义(要靠 Unsafe 读)
transient volatile Node[] nextTable扩容时的新数组,仅扩容期间非 null
transient volatile long baseCount基础计数,无竞争时直接 CAS 累加
transient volatile CounterCell[] counterCells高竞争时的分段计数单元(仿 LongAdder)
transient volatile int sizeCtl「状态 + 阈值」双用:负数表示初始化/扩容中,正数表示下次扩容阈值
transient volatile int transferIndex扩容时待迁移桶区间的全局分配指针
static final int MOVED = -1ForwardingNode 的 hash,表示该桶已迁移
static final int TREEBIN = -2TreeBin 的 hash,表示该桶是红黑树
static final int RESERVED = -3ReservationNode 的 hash,计算中的占位
static final int HASH_BITS = 0x7fffffffspread() 后与运算,保证普通节点 hash 恒为非负
追问:为什么普通节点的 hash 必须是非负的?

因为 MOVED(-1) / TREEBIN(-2) / RESERVED(-3) 三个特殊值全为负。读代码时到处都是 fh >= 0(普通链表)与 fh < 0(特殊节点)的分支判断——负数 hash 就是「这不是普通节点」的哨兵。所以 spread() 在高低 16 位异或之后还要 & HASH_BITS 把符号位清零。

spread() · 高低位混合,消除 hashCode 低位的规律性
static final int spread(int h) {
    return (h ^ (h >>> 16)) & HASH_BITS; // 高 16 位参与散列,并保证结果非负
}
第 5 站

tabAt / casTabAt / setTabAt:数组元素的 volatile 化

CHM 全篇几乎不直接写 tab[i],而是通过 Unsafe 的三个静态方法读写数组元素。为什么?这是 CHM 最容易被忽略却最本质的一个点:Java 数组本身不是 volatile 的,数组元素也不具备 volatile 语义——即使 table 字段是 volatile,普通读 tab[i]可能读到过期值(对 JMM 来说,数组元素读写是普通操作,无法借助 table 字段的 volatile 建立 happens-before)。

ConcurrentHashMap 静态工具 · Unsafe 内存操作
// ① 按 volatile 语义读数组元素 —— get 无锁的基石
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);
}

// ② 原子 CAS:空桶插入、初始化置位、transferIndex 分配全靠它
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                        Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

// ③ 释放写(lazy set):写链表/替换桶头后安全发布
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
    U.putOrderedVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
方法底层指令语义典型用途
tabAtgetObjectVolatilevolatile 读(全屏障)get/put 定位桶头、锁内二次校验 tabAt(tab,i)==f
casTabAtcompareAndSwapObject原子比较并交换空桶 CAS 插入、sizeCtl 置位、transferIndex 分片
setTabAtputOrderedVolatile释放写(不强制立即可见)扩容迁移后写新表桶位、替换桶头
后端类比:volatile 数组元素 = 数据库的当前读

普通读 tab[i] 类似 MySQL 的「快照读」——可能读到旧值;tabAt 的 volatile 读类似「当前读」——一定读到最新提交值。CHM 的 get 之所以无锁还安全,就是因为每个桶头都是用 tabAt(当前读)拿到的,配合节点自身 volatile 字段,构成了整条无锁读链。

核心要点
  • 数组元素没有 volatile 语义,必须用 Unsafe.getObjectVolatile 按地址读——这是 CHM 依赖 Unsafe 的根本原因。
  • 锁内写完后要二次校验 tabAt(tab, i) == f,防止锁等待期间桶头已被替换(如被扩容迁移)。
  • setTabAt 用的是 putOrderedVolatile(释放写)而非 putVolatile:写链表场景下保证「先置好 next 再发布桶头」即可,不需要全屏障,性能更优。
第 6 站

put 全流程:CAS 空桶插入 → synchronized 锁头遍历

这是全篇最核心的一站。先看完整源码(JDK 1.8 putVal),再逐行拆解。

ConcurrentHashMap.putVal · JDK 1.8 完整逻辑
final V putVal(K key, V value, boolean onlyIfAbsent) {
    // ① CHM 不允许 null key/value(HashMap 允许,这是二者的一个重要差异)
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());   // ② 高低 16 位混合,保证非负
    int binCount = 0;                  // 记录桶内节点数,用于树化判断
    for (Node<K,V>[] tab = table;;) {   // ③ 自旋:CAS 失败 / 帮助扩容后重试
        Node<K,V> f; int n, i, fh;
        // ④ table 未初始化 → initTable()(sizeCtl CAS 置 -1)
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        // ⑤ 桶头为 null:CAS 无锁插入,成功即退出(全程不加锁!)
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                break;   // 无锁插入成功
        }
        // ⑥ 桶头是 ForwardingNode(hash == MOVED)→ 帮别人扩容,然后重试
        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;      // ⑩ 覆盖已有 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;   // ⑪ 尾插法追加(1.7 是头插,1.8 改尾插)
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {  // ⑫ 红黑树桶
                        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) {
                // ⑬ 链表节点数 ≥ 8 → treeifyBin(数组 < 64 则先扩容)
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null) return oldVal; // 覆盖返回旧值
                break;
            }
        }
    }
        addCount(1L, binCount);   // ⑭ 计数 +1,并顺带检查是否需要扩容
    return null;   // 新插入返回 null
}

流程拆解:哪一步加锁,哪一步不加?

步骤行为是否加锁
④ 初始化initTable(),CAS 抢初始化权无锁(CAS)
⑤ 空桶插入casTabAt(tab, i, null, newNode)无锁(CAS)
⑥ 遇到 MOVEDhelpTransfer 帮助扩容后重试无锁(CAS 记账)
⑦ 锁头遍历synchronized(f) 锁桶头,链表尾插/覆盖或树插入有锁(桶级)
⑬ 树化检查treeifyBin(内部再锁桶头一次)有锁(仅树化时)
⑭ 计数addCount:baseCount CAS / CounterCell 分散无锁(CAS)
追问 1:为什么空桶插入可以用 CAS 而不用锁?追问 2:为什么锁的是「桶头节点对象」而不是「数组下标」或「整张表」?

空桶插入只有一个前提——「该位置还是 null」,这是典型的 CAS 适用场景(compare-and-swap 一步完成检查与写入),失败就重试,无需阻塞。而锁桶头对象 f 的精妙之处在于:锁对象与数据绑定——所有要修改这个桶的线程,都必须先拿到同一个 f 的监视器;而不同桶的 f 不同,互不阻塞。为什么能锁「节点」?因为桶头一旦被替换(扩容迁移成 FWD),旧节点对象仍安全(不再被修改),新来的线程看到 FWD 会转去 helpTransfer,不会有人再等旧锁——锁的归属自然切换,不需要「全局锁表」。

后端类比:锁桶头 ≈ 乐观锁 + 行锁的组合拳

空桶 CAS 插入像「乐观锁」(版本号=null 才更新);非空桶 synchronized 像「数据库行锁」(只锁命中行)。而 1.7 的 Segment 锁相当于「表分区锁」,Hashtable 相当于「整表锁」。同一条 put 路径,三代实现的锁成本递减。

核心要点
  • put 的四种分支:未初始化 → initTable空桶 → CAS 插入MOVED → helpTransfer非空 → synchronized(f) 遍历
  • 只有「锁头遍历」和「树化」两步加锁,其余全部无锁;锁的粒度是桶头节点对象。
  • 1.8 链表是尾插法(1.7 头插),配合 1.8 无环扩容,杜绝了死循环问题。
  • 新 key 插入返回 null,覆盖返回旧值;putIfAbsent 就是 putVal(key, value, true)
第 7 站

initTable 与 sizeCtl:一个字段的三副面孔

sizeCtl 是 CHM 里信息密度最高的一个字段,不同取值代表完全不同的含义。很多源码读不懂,都是卡在这个字段上。

sizeCtl 取值含义
0默认值:table 尚未初始化
正数下一个扩容阈值(≈ 0.75 × 容量);构造器里则暂存初始容量
-1有线程正在执行 initTable 初始化
-(1 + n)正在扩容,低 16 位记录「参与扩容的线程数 + 1」;高 16 位是本次扩容的 resizeStamp(与数组长度 n 绑定)
initTable() · 多线程抢初始化权
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        // ① 别人正在初始化:让出 CPU 自旋等待(不阻塞)
        if ((sc = sizeCtl) < 0)
            Thread.yield();
        // ② 抢初始化权:CAS 把 sizeCtl 从当前值改为 -1,成功者才干活
                else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    // ③ 初始容量:构造器传了就用,否则默认 16
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    Node<K,V>[] nt = (Node<K,V>[]) new Node<?,?>[n];
                    table = tab = nt;
                    // ④ 阈值 = n - n/4 = 0.75n(等价于 容量 × 负载因子 0.75)
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;   // ⑤ 释放初始化权,同时写入阈值
            }
            break;
        }
    }
    return tab;
}
一句话点破:为什么初始化要 CAS 置 -1 而不是直接赋值

并发初始化时,多个线程同时进入 initTable。如果直接 sizeCtl = -1,两个线程可能同时通过 if (sc < 0) 的判断、同时建数组——浪费内存且结果不确定。CAS 保证只有一个线程能把 sizeCtl 改成 -1,其余线程看到负数后 yield 自旋等待,谁先抢到谁干活。

注意细节:n >>> 2 是无符号右移两位 = n/4,所以阈值 n - n/4 = 0.75n——负载因子 0.75 在 CHM 里被写死为位运算,与 HashMap 的 DEFAULT_LOAD_FACTOR = 0.75f 等价但省一次浮点乘法。扩容的触发条件也是这个阈值:元素数 >= sizeCtl

追问:为什么初始容量必须是 2 的幂?这和定位公式 (n - 1) & hash 有什么关系?

索引定位用的是 (n - 1) & hash(位与代替取模)。只有当 n 是 2 的幂时,n - 1 的低位才全是 1,位与结果才等价于 hash % n 且均匀分布。这也直接决定了扩容必须 2 倍——后面 transfer 站会看到,2 倍扩容让「旧桶 i 的节点只可能去新表 i 或 i+n」,不需要重新散列。

第 8 站

树化与退化:8 / 64 / 6 三个数字的由来

treeifyBin() · 数组太小先扩容,够了才树化
private final void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b; int n, sc;
    if (tab != null) {
        // ① 数组长度 < 64:不树化,直接预扩容 2 倍(节点分散后冲突自然缓解)
                if ((n = tab.length) < MIN_TREEIFY_CAPACITY)   // 64
            tryPresize(n << 1);
        // ② 数组 ≥ 64 且桶头是普通链表(b.hash >= 0,排除已树化/迁移中)
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            synchronized (b) {               // 锁桶头,防止并发树化
                if (tabAt(tab, index) == b) {   // 二次校验
                    // ③ 把链表节点逐个转成 TreeNode,串成双向链表
                    TreeNode<K,V> hd = null, tl = null;
                    for (Node<K,V> e = b; e != null; e = e.next) { ... }
                    // ④ 用 TreeBin 包装树根,替换桶头(setTabAt 发布)
                    setTabAt(tab, index, new TreeBin<K,V>(hd));
                }
            }
        }
    }
}

为什么树化阈值是 8?——泊松分布的数学背书

这个论证写在 HashMap 的源码注释里,CHM 沿用了同一套数字:在负载因子 0.75、随机 hashCode 的理想假设下,桶中链表长度服从泊松分布(均值为 0.5),各长度的出现概率约为:

链表长度 k出现概率
0≈ 0.6065
1≈ 0.3033
2≈ 0.0758
3≈ 0.0126
4≈ 0.0016
5≈ 0.00016
6≈ 0.000013
7≈ 0.00000094
8≈ 0.00000006(千万分之六)

也就是说:只要 hashCode 均匀,链表长到 8 的概率只有千万分之六。一旦真的出现,几乎可以断定是 hashCode 分布出了问题(比如 hashCode 恒为常数),此时链表 O(n) 会拖垮性能,不如花代价转树 O(log n)。所以 8 是「正常分布下几乎不会误触、异常分布下必须兜底」的平衡点。而 UNTREEIFY_THRESHOLD = 6 比 8 小 2,留出 1 个元素的缓冲,避免树和链表在 7/8 之间来回切换的抖动

追问 1:链表达到 8 就一定会转树吗?追问 2:红黑树什么时候退化成链表?
  • 不一定:如果数组长度 < 64,先走 tryPresize(n << 1) 扩容——扩容后节点重新分布,冲突可能自然消失。只有数组 ≥ 64 才真正树化。所以完整条件是「链表 ≥ 8 且数组 ≥ 64」。
  • 退化发生在两处:一是扩容迁移时,树按 hash & n 拆成 lo/hi 两半,若某半节点数 ≤ 6 就 untreeify() 还原成链表;二是删除节点后树过小(根为 null 或右链过短),removeTreeNode 返回 true,调用方执行 untreeify(t.first)
核心要点
  • 树化触发:binCount >= 8(桶内链表达到 8 个节点)且数组 ≥ 64;数组 < 64 时先扩容不树化。
  • 8 来自泊松分布论证(0.75 负载下概率千万分之六);退化阈值 6 是为了防止树/链表频繁切换的 1 元素缓冲。
  • 树化过程:锁桶头 → 链表转 TreeNode 双向链 → new TreeBin(hd) 替换桶头;树桶 hash 为 TREEBIN(-2)。
第 9 站

addCount 与 CounterCell:并发计数如何不抢一把锁

put 之后要维护元素个数。最朴素的做法是给一个 size 字段加锁累加——但那样每次写操作都串行化。CHM 的做法是分段计数:先把计数 CAS 到 baseCount;竞争激烈就分散到多个 CounterCell 里各自累加;统计时把 baseCount 与所有 cell 相加。这正是 LongAdder 的思想,源码注释原话就是 "Adaptation of LongAdder and Striped64"

CounterCell · 防伪共享的计数单元
// @Contended 注解:把该对象填充到独立的 CPU 缓存行,避免伪共享(false sharing)
@sun.misc.Contended
static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}
后端类比:CounterCell ≈ 分库分表后的「分片计数」

所有线程抢一个 baseCount,就像所有请求打一个计数器(热点写);分散到 N 个 cell,就像分库分表后每个分片各自计数,报表时再 SUM 汇总。热点被摊平,吞吐线性上升。而 @Contended 解决的是 CPU 层面的问题:相邻 cell 若落在同一缓存行,一个线程改 cell[0] 会导致持有 cell[1] 的其他线程缓存行失效——伪共享。填充后各 cell 独占缓存行。

addCount() · 计数 + 顺带触发扩容检查
private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    // ① 快速路径:无 cell 数组(低竞争)时直接 CAS baseCount
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell a; long v; int m;
        boolean uncontended = true;
        // ② 慢路径:按线程探针值选一个 cell,CAS 累加
        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);  // ③ cell 数组初始化/扩容/换 cell 重试,全在这
            return;
        }
        if (check <= 1) return;   // ④ 链表短(binCount ≤ 1)就不查扩容,省一次 sumCount
        s = sumCount();
    }
    // ⑤ check >= 0(put 场景)且 s >= sizeCtl → 进入扩容逻辑(下一站展开)
    if (check >= 0) { ... resizeStamp / transfer 分支 ... }
}

几个值得记住的细节:

  • ThreadLocalRandom.getProbe() 返回线程自身的随机「探针值」,probe & (len-1) 选定 cell——同一线程稳定命中同一 cell,减少跨线程的缓存行竞争。
  • fullAddCount 负责:cell 数组为 null 时 CAS 初始化(初始 2 个);目标 cell 为 null 时 CAS 创建;目标 cell 竞争失败时换随机 cell 重试;重试仍失败则把 cell 数组 2 倍扩容;最后兜底 CAS baseCount
  • sumCount() 无锁遍历:sum = baseCount; for (cell : counterCells) sum += cell.value;——这也是后面 size() 弱一致性的根源。
  • check 参数:put 传的是 binCount(链表短则跳过扩容检查);remove 传 -1(不做扩容检查)。
高竞争下:计数分散到多个 CounterCell,避免所有线程抢一个 baseCount T1 T2 T3 T4 T5 baseCount (long) CounterCell[0] @Contended CounterCell[1] @Contended sumCount() = baseCount + Σ counterCells(无锁求和)
图 2分段计数:写入分散、统计汇总,全程无锁
第 10 站

size():为什么它只是一个「弱一致性近似值」

size() / mappingCount() · JDK 1.8
public int size() {
    long n = sumCount();   // 无锁遍历求和,不重试、不加锁
    return (n < 0L) ? 0 : (n > (Integer.MAX_VALUE)) ? Integer.MAX_VALUE : (int)n;
}

public long mappingCount() {   // 推荐用这个:long 精度,避免 int 溢出截断
    long n = sumCount();
    return (n < 0L) ? 0L : n;
}

JDK 1.8 的 size() 就是一次裸的 sumCount():把 baseCount 和各 CounterCell 加起来。求和期间别的线程可能正在 put/remove,甚至正在 fullAddCount 里扩容 cell 数组——所以结果只是一个时间点的近似值,既不保证实时,也不保证自洽(可能比真实值略大或略小)。这正是 CHM 官方的设计取舍:size() 从来不是强一致操作

1.7 的 size() 反而「更努力」但更贵

JDK 1.7 Segment 统计 · 三级策略
// ① 无锁遍历两遍:若两次 modCount 总和一致 → 返回(快路径)
// ② 不一致 → tryLock 所有 Segment 再数一遍
// ③ 还不一致 → 全部加锁(lock())重数,然后解锁
// 结论:1.7 的 size() 可能阻塞整表,1.8 的 size() 永远不阻塞——但都不保证精确
面试追问:既然 size() 不精确,那我想精确统计元素个数怎么办?

三条路:

  • 业务侧自维护计数器:写操作后同步更新自己的 AtomicLong / LongAdder,读它做判断——代价是要和 put/remove 逻辑耦合。
  • 能接受近似就用 size()/mappingCount():适合监控告警、分页总数展示、缓存容量提示等场景。
  • 极端精确场景用外部一致性手段(如分布式锁包住写操作 + 快照),但那样 CHM 的并发优势就没了——通常不值得。
一句话点破:为什么弱一致性在这里是「特性」而不是「缺陷」

size() 如果要做强一致,就必须在统计瞬间「冻结」整个结构——要么加全局锁(退化回 Hashtable),要么等所有写线程停下(不可行)。CHM 的选择是:放弃 size 的强一致,换取写路径的全程无锁。这是并发容器「按需一致性」的典型设计:每个 API 提供与其成本匹配的一致性等级。

核心要点
  • JDK 1.8 size() = 一次无锁 sumCount(),弱一致、永不阻塞、无重试机制。
  • JDK 1.7 size() = 两遍无锁统计 → tryLock 全段 → 全段加锁,可能阻塞但同样不保证精确。
  • 大数据量用 mappingCount()(long),避免 size() 的 int 截断。
  • 生产上别拿 size() 做精确阈值判断(如「缓存达到 100 万就淘汰」),要自己维护计数器。
第 11 站

transfer:多线程协作扩容,一个桶都不浪费

当元素数 ≥ sizeCtl(0.75 × 容量)时触发扩容。与 1.7 的「单段单线程 rehash」不同,1.8 的扩容是全表协作的:发起线程把待迁移桶区间按 stride 分成多片,其他线程发现 MOVED 桶后也能加入帮忙。核心是三个机制:stride 分片、transferIndex 全局指针、高低位拆分

① stride:按 CPU 数算每片桶数

transfer() 开头 · 分片步长
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    // ① 每个线程认领的桶数:多核时 = 容量/8/核数,最少 16 个
        if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)  // 16
        stride = MIN_TRANSFER_STRIDE;
    // ② 发起者创建 2 倍长的新数组,并初始化 transferIndex = n
    if (nextTab == null) {
        Node<K,V>[] nt = (Node<K,V>[]) new Node<?,?>[n << 1];  // 2 倍
        nextTable = nextTab = nt;
        transferIndex = n;   // 待分配区间 [0, n)
    }
    int nextn = nextTab.length;
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab); // 迁移完成桶的占位
    ...

② transferIndex:CAS 认领一片区间

每个参与线程进入循环后,通过 CAS 把 transferIndex 往下挪一个 stride,认领 [nextBound, nextIndex) 这一片,然后从高索引往低索引迁移:

transfer() 认领逻辑 · 从尾部往前分片
while (advance) {
    int nextIndex, nextBound;
    if (--i >= bound || finishing)      // 本片还没干完
        advance = false;
    else if ((nextIndex = transferIndex) <= 0) {  // 没有剩余区间了
        i = -1; advance = false;
    }
        else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex,
                             nextBound = (nextIndex > stride ? nextIndex - stride : 0))) {
        bound = nextBound;     // 认领成功:本片 [bound, nextIndex)
        i = nextIndex - 1;
        advance = false;
    }
}

③ 高低位拆分:为什么 2 倍扩容不用重新散列

旧索引 i = (n-1) & hash,新容量 2n 后索引 (2n-1) & hash。因为 2n-1 比 n-1 多出第 n 位(bit n),所以新索引只可能是 ii+n,取决于 hash & n 是 0 还是 1。因此不需要重新散列,只要按 hash 的第 n 位把链表劈成两半

transfer() 链表迁移 · lastRun 优化 + 高低位两条链
// 只针对普通链表桶(fh >= 0),synchronized(f) 锁桶头后:
int runBit = fh & n;              // 桶头 hash 的第 n 位
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; }  // 找最后一个分界点
}
// lastRun 优化:lastRun 及其之后的节点位相同,整段复用,不用逐个 new
Node<K,V> ln = (runBit == 0) ? lastRun : null;   // 低位链(留在 i)
Node<K,V> hn = (runBit == 0) ? null : lastRun;   // 高位链(去 i+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;
2 倍扩容:旧桶 i 的节点按 hash & n 拆成两半,不用重新散列 旧表 n = 8,桶 i = 3: i=3 A (h&8=0) B (h&8=8) C (h&8=0) 新表 2n = 16: 3 11 A → C (ln) B (hn) 旧桶 i=3 迁移完 → 放 ForwardingNode hash & n == 0 → 留在 i(低位) hash & n == n → 移到 i+n(高位)
图 3扩容拆分:A、C 归低位链去新表 3,B 归高位链去新表 11

④ sizeCtl 记账与扫尾:最后一个线程负责收工

transfer() 收尾 · 完成判断与提交
if (i < 0 || i >= n || i + n >= nextn) {   // 自己认领的区间干完了
    int sc;
    if (finishing) {                          // ③ 扫尾确认全表迁移完
        nextTable = null;
        table = nextTab;                     // 正式切换主数组引用
        sizeCtl = (n << 1) - (n >>> 1);      // 新阈值 = 1.5n = 0.75 × 2n
        return;
    }
    // ① 退出前 CAS sizeCtl - 1(自己不再是活跃迁移线程)
    if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
        // ② 若减完后不等于 (rs << 16) + 1,说明还有别的线程在干,直接走人
        if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
            return;
        finishing = advance = true;   // 自己是最后一个 → 再扫一遍全表
        i = n;
    }
}

扩容线程数的记账逻辑:发起时 sizeCtl = (rs << 16) + 2(1 个活跃线程,+2 而不是 +1,是为了让「全部完成」哨兵值 (rs << 16) + 1 与「只剩发起者」区分开)。每加入一个线程 sizeCtl + 1,每完成一个 - 1。当某线程发现减完等于 (rs << 16) + 1,说明自己是最后一个,负责把 table 切换成新数组并更新阈值。

追问:树桶迁移时如果拆出来的半边 ≤ 6 个节点怎么办?

树桶同样按 hash & n 拆成 lo/hi 两条 TreeNode 链,若某条节点数 ≤ UNTREEIFY_THRESHOLD(6),就调用 untreeify() 还原成普通链表再放入新表——小树退化,避免红黑树在小数据量下的常数开销。

核心要点
  • stride = (NCPU > 1) ? (n >>> 3) / NCPU : n,下限 16——每个线程每次认领一片。
  • transferIndex 是全局分片指针,CAS 认领,从高索引往低索引迁移。
  • 2 倍扩容 + (hash & n) 高低位拆分 = 免重新散列;lastRun 优化整段复用节点。
  • 旧桶迁移完立即放 ForwardingNode,读写遇到它就知道「这个桶在扩容」。
  • sizeCtl 低 16 位记账线程数,最后一个线程扫尾:切换 table、写入新阈值 1.5n。
第 12 站

helpTransfer:普通线程如何「顺手」帮扩容

扩容不是发起线程一个人的事。任何 put / remove / compute 线程,只要定位桶时发现桶头是 ForwardingNode(hash == MOVED),就会调用 helpTransfer 加入战斗——这就是「多线程协助扩容」的入口。

helpTransfer() · 校验合法性后 CAS 记账并加入
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
    Node<K,V>[] nextTab; int sc;
    // ① 三重校验:确实是 FWD、nextTable 已建好、table 还是旧的
    if (tab != null && (f instanceof ForwardingNode) &&
        (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
        int rs = resizeStamp(tab.length);   // 由旧表长度算出本次扩容的 stamp
        while (nextTab == nextTable && table == tab && (sc = sizeCtl) < 0) {
            // ② 以下情况不参与:stamp 不匹配(扩容已换代)/ 只剩最后线程 / 满员 / 没有剩余区间
            if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                sc == rs + MAX_RESIZERS || transferIndex <= 0)
                break;
            // ③ CAS sizeCtl + 1 记账,然后真正进 transfer 干活
                        if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
                transfer(tab, nextTab);
                break;
            }
        }
        return nextTab;
    }
    return table;
}

resizeStamp:给「这一次扩容」发身份证

resizeStamp() · 由数组长度唯一推导
static final int resizeStamp(int n) {
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1)); // 高位置 1
}

resizeStamp(n) 由数组长度 n 的「前导零个数」计算而来——n 是 2 的幂,所以不同的 n 一定对应不同的 stamp。发起扩容时把它放到 sizeCtl 的高 16 位(rs << 16),后续任何线程想加入,都要先拿当前旧表长度算 stamp 对比:不一致说明扩容已经换了一代(比如又触发了一次新的扩容),绝不能加入上一代的收尾。这就是「协助扩容」的合法性校验,防止新线程帮旧扩容、旧线程污染新表。

后端类比:helpTransfer ≈ 大促时的弹性扩容

就像大促时应用服务自动扩容——不是只有运维(发起线程)在加机器,任何一个路过的请求(普通线程)发现负载高(MOVED)也会顺手注册一台新实例(CAS sizeCtl+1)加入处理。而 resizeStamp 相当于「本次扩容的版本号」,防止旧版本的 worker 混进新版本的任务。

追问:扩容期间 put 一个 key,最终它落到旧表还是新表?读呢?

put 遇到 MOVED 会先 helpTransfer 把该桶迁移完,再重新走循环——此时桶位已指向新表,新节点必然写进新表;即使没遇到 MOVED,迁移中的桶也是「迁移完一个换一个 FWD」,写旧表中未迁移的桶也安全(迁移时锁桶头,写线程也会拿到同一把锁)。读则更简单:get 遇到 FWD 会顺着 nextTable 去新表找(见下一站),所以扩容期间读写都不会丢数据、不会阻塞。

第 13 站

get 为什么无锁:volatile 读链 + 特殊节点转发

get() · JDK 1.8 完整逻辑
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;
        }
        // ③ hash < 0:FWD(扩容转发) / TreeBin(树查) / Reservation(占位,返回 null)
                else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        // ④ 普通链表:顺着 volatile next 遍历
        while ((e = e.next) != null) {
            if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

为什么 get 全程无锁还安全?——三层保证

  • 引用级:桶头用 tabAt(getObjectVolatile)读,等价 volatile 读——一定看到最新发布的桶头。
  • 字段级key/hash final(不可变),val/next volatile——读到节点后,节点内容要么不可变要么可见。
  • 语义级:写线程都是「先建好节点、再发布」(尾插法先连 next 再挂链表、setTabAt 释放写),读线程不会看到半成品。

特殊节点的 find 转发:扩容期间读不丢数据

桶头类型hashfind 行为
Node(普通链表)≥ 0沿 next 遍历(get 主循环)
ForwardingNodeMOVED(-1)转发到 nextTable 对应桶继续找——扩容期间读到新表
TreeBinTREEBIN(-2)走红黑树查找,内部用 lockState 加「读锁」(READER=4,CAS 累加,不阻塞写)
ReservationNodeRESERVED(-3)计算尚未完成,返回 null
追问:get 无锁 + 弱一致,会不会读到「过期的旧值」?这算不算 bug?

可能读到「正在被覆盖前的旧值」(val 是 volatile,读到的要么是旧值要么是新值,不会读到脏的中间态),也可能读不到刚 put 的值(无 happens-before)。这是 CHM 明确定义的弱一致性语义:get 保证「不抛异常、不阻塞、不读脏数据」,但不保证「读到最新」。对绝大多数读多写少场景足够;需要强一致的读后写场景,请用 compute/merge 这类原子操作。

remove:锁桶头 + 链表摘除 / 树删除

replaceNode() · remove 的底层实现要点
final V replaceNode(Object key, V value, Object cv) {
    // 与 putVal 同构:定位 → MOVED 则 helpTransfer → 否则 synchronized(f) 锁桶头
    for (Node<K,V>[] tab = table;;) {
        // 桶空 / 桶头为 null → 直接 break(不存在)
        if (tab == null || ... || (f = tabAt(tab, i = (n - 1) & hash)) == null) break;
        else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f);
        else {
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {   // 链表:摘除节点
                        for (Node<K,V> e = f, pred = null;;) {
                            if (命中 key) {
                                if (pred == null) setTabAt(tab, i, e.next); // 删桶头
                                else pred.next = e.next;                    // 摘中间节点
                                break;
                            }
                            pred = e; if ((e = e.next) == null) break;
                        }
                    }
                    else if (f instanceof TreeBin) {   // 树:删除并可能退化
                        if (t.removeTreeNode(p))
                            setTabAt(tab, i, untreeify(t.first));  // 树过小 → 还原链表
                    }
                }
            }
            if (validated) {
                if (oldVal != null) {
                    if (value == null)
                        addCount(-1L, -1);   // 计数 -1,check=-1 跳过扩容检查
                    return oldVal;
                }
                break;
            }
        }
    }
    return null;
}

树桶的并发读写还有一层保护:TreeBinlockState 实现读写锁语义——写操作(putTreeVal/removeTreeNode)先把 lockState CAS 成 WRITER(1);读操作(find)CAS 累加 READER(4);写线程发现读者在树上会记录 waiter 并等待。这样红黑树旋转时不至于让无锁读者看到中间态。

核心要点
  • get 无锁的根基:volatile 读桶头(tabAt)+ final key/hash + volatile val/next + 写端「先构建后发布」。
  • 遇到 hash < 0 走 find 多态:FWD 转发新表、TreeBin 树查(读锁)、Reservation 返回 null。
  • remove 与 put 同构(定位 → helpTransfer → 锁桶头),删除后树过小会 untreeify 退化回链表。
  • 弱一致性是设计语义而非缺陷:不阻塞、不读脏、不抛 CME;强一致操作请用 compute/merge。
第 14 站

全维度对比:1.7 / 1.8 / Hashtable / synchronizedMap

一张表讲清四个容器

维度HashtablesynchronizedMapCHM 1.7CHM 1.8
锁粒度整表整表Segment(默认 16)单个桶
并发控制synchronized 方法synchronized 包装ReentrantLock(继承)CAS + synchronized
读操作加锁加锁无锁(volatile 链)无锁(volatile + find 转发)
数据结构Entry 链表同被包装容器HashEntry 链表Node 链表 + 红黑树
扩容全表 2 倍同左段内独立 2 倍全表多线程协作 2 倍
size()加锁遍历加锁遍历无锁两遍 + 锁全段重试sumCount 弱一致
迭代器fail-fast同左弱一致弱一致
null key/value不允许取决于容器不允许不允许
最大并发写1116(固定)≈ 桶数量级

为什么 1.8 从 ReentrantLock 换回 synchronized?

锁技术选型 · 面试标准答案骨架
// ① 场景变了:1.7 锁粒度是 Segment,竞争相对激烈,需要 tryLock 无阻塞尝试 +
//    scanAndLockForPut 自旋,这是 ReentrantLock 的强项(lock() 可中断/可超时)
// ② 1.8 锁粒度细到桶,临界区极短、竞争极低:synchronized 的偏向/轻量级锁
//    在低竞争下就是一次 CAS 或几条指令,性能不输 ReentrantLock
// ③ synchronized 是 JVM 内建:锁消除、锁粗化等优化开箱即用;代码也更简洁
// ④ 结论不是「synchronized 比 ReentrantLock 强」,而是「桶级低竞争下 synchronized 够用且更省」
追问:那现在(JDK 15+ 移除了偏向锁)CHM 还成立吗?

成立。JDK 15 起偏向锁废弃,但轻量级锁(CAS 自旋)仍然在低竞争下非常高效,synchronized 的整体成本没有本质回升;而且 CHM 的绝大多数 put 走的是「空桶 CAS」路径,根本不进锁。这个追问考的是你对 JVM 锁演进是否有持续跟踪——答「偏向锁移除不影响 CHM 的设计合理性,因为主要成本在 CAS 与轻量级锁」即可。

后端类比:三代并发容器 = 数据库锁的演进史

Hashtable 像 MySQL 默认的「表锁」(MyISAM);1.7 分段锁像「分区表 + 每分区一把锁」;1.8 桶级锁像 InnoDB 的「索引 + 行锁」。你会发现并发演进的主线永远一致:把锁从「整块资源」拆到「最小冲突单位」,再用无锁读把读路径彻底解放。

核心要点
  • 选型口诀:读多写少高并发用 CHM;需要严格快照遍历用 CopyOnWriteArrayList(但写成本高);并发度要求低图省事才考虑同步包装。
  • 1.8 换 synchronized 是「场景适配」而非「技术倒退」:细粒度锁 + 低竞争下 JVM 锁足够高效。
  • CHM 扩容是 2 倍(配合位运算免重散列);对比记忆:HashMap 也是 2 倍,ArrayList 是 1.5 倍。
第 15 站

生产实践与高频踩坑:从理论到上线

七个高频坑,每个都是面试题

坑 1:null 引发的 NPE · HashMap 迁移代码的经典事故
// HashMap 允许 null key/value,CHM 不允许 —— 老代码直接换容器会莫名 NPE
map.put(null, "x");   // HashMap OK;CHM → NullPointerException
// 排查:查 putVal 第一行 if (key == null || value == null) throw new NullPointerException();
坑 2:computeIfAbsent 的递归与长函数 · 锁内干重活
// ① 递归:mappingFunction 里再对同一个 key 调 computeIfAbsent → IllegalStateException("Recursive update")
map.computeIfAbsent(key, k -> map.computeIfAbsent(k, ...));  // 递归更新,直接抛异常

// ② 重活:mappingFunction 内部做 RPC/DB 查询(耗时 100ms)→ 持有桶锁,同桶线程全部阻塞
map.computeIfAbsent(userId, k -> userService.query(k));  // 桶锁被占 100ms,吞吐骤降
// 正确姿势:先 get 快速路径;mappingFunction 只做轻量构造;重活放锁外再 putIfAbsent
V v = map.get(key);
if (v == null) { v = expensiveLoad(key); map.putIfAbsent(key, v); }
  • 坑 3:size()/mappingCount() 弱一致——别拿它做「达到上限就淘汰」的精确判断;要精确就自己维护 LongAdder
  • 坑 4:迭代器弱一致——不会抛 ConcurrentModificationException,但遍历期间可能看到新增/删除,别当快照用;大批量遍历要注意「遍历期间扩容」导致的重复或遗漏元素。
  • 坑 5:hashCode 质量差——所有 key 的 hashCode 相同 → 全挤一个桶 → 树化后也是 O(log n) 且常数大;更隐蔽的是用可变对象做 key,hash 字段随对象状态变化,改完就找不到了。
  • 坑 6:没有容量上限与淘汰策略——CHM 不是缓存,只增不减会内存泄漏;要配 Caffeine/Guava 这类带淘汰策略的缓存,CHM 只做底层存储。
  • 坑 7:扩容抖动——大表扩容(transfer)期间写路径要额外帮忙迁移,可能观察到 RT 尖刺;可以预估容量(new ConcurrentHashMap(预估 / 0.75f))减少扩容次数。

回到第 1 站的线上事故:完整复盘

环节问题修复
根因多线程并发 put 普通 HashMap,扩容成环换成 CHM,靠 1.8 尾插 + CAS + 桶锁天然免疫成环
诱因单例缓存被全服务共享,读多写多评估读写比:读多写少用 CHM 缓存 + 预热;写多考虑读写分离
放大无容量上限,缓存只增不减外层套 Caffeine(LRU/W-TinyLFU 淘汰),CHM 当内部存储
监控无指标上报 CHM 的 mappingCount() 与桶数,发现 size 异常膨胀提前告警
核心要点(最佳实践清单)
  • 能预估容量就传初始容量(除以 0.75),减少扩容次数与 transfer 抖动。
  • mappingFunction 必须无副作用、轻量、无递归;重活走「get → 锁外算 → putIfAbsent」。
  • key 用不可变对象且正确重写 hashCode/equals;绝不用可变字段参与 hash 的对象做 key。
  • 需要淘汰、过期、容量上限 → 上 Caffeine/Guava;CHM 只当线程安全的底层 Map。
  • 精确计数自维护 LongAdder;监控用 mappingCount();遍历大集合注意弱一致语义。
总结

这一篇你掌握了什么

核心知识点回顾

  • 演进主线:Hashtable 整表锁 → 1.7 Segment 分段锁(并发度固定 16)→ 1.8 桶级 CAS + synchronized(并发度随数组扩展)。
  • 数据结构:Node[] 数组 + 链表 + 红黑树;四种节点(Node / TreeBin / ForwardingNode / ReservationNode)用 hash 哨兵区分:MOVED=-1、TREEBIN=-2、RESERVED=-3,普通节点 hash 经 spread() & HASH_BITS 恒非负。
  • put 流程:判空 → spread → initTable(CAS 置 sizeCtl=-1)→ 空桶 casTabAt 无锁插入 → MOVED 则 helpTransfer → 否则 synchronized(f) 锁桶头,二次校验后链表尾插/覆盖或 TreeBin.putTreeVal → binCount ≥ 8 触发 treeifyBin(数组 < 64 先扩容)→ addCount(1, binCount)。
  • 计数与 size:baseCount CAS 快速路径 + CounterCell 分段(@Contended 防伪共享)+ fullAddCount 初始化/扩容,sumCount 无锁求和;size() 弱一致,大数据量用 mappingCount()。
  • 协作扩容:stride = (n>>>3)/NCPU(下限 16),transferIndex CAS 分片认领;2 倍扩容 + hash & n 高低位拆分免重散列;lastRun 优化;sizeCtl 低 16 位记账,最后线程扫尾切换 table 并写入新阈值 1.5n;读写遇到 FWD 分别走 helpTransfer / find 转发。
  • 无锁读:tabAt volatile 读桶头 + final key/hash + volatile val/next + 先构建后发布;TreeBin 用 lockState(WRITER/WAITER/READER)实现读写锁语义。
  • 关键数字:树化 8 / 数组 64 / 退化 6 / 负载因子 0.75 / 扩容 2 倍(ArrayList 1.5 倍);树化阈值 8 有泊松分布(概率约千万分之六)背书。
  • 生产红线:不支持 null;mappingFunction 轻量无递归;key 不可变;size 弱一致别做精确判断;无淘汰策略要配缓存框架。

面试追问清单(自测)

  • put 流程中哪几步加锁、哪几步无锁?为什么空桶可以用 CAS?
  • 为什么 1.8 用 synchronized 而 1.7 用 ReentrantLock?
  • 为什么数组元素必须用 tabAt 读,不能直接 tab[i]?
  • sizeCtl 的四种取值分别代表什么?resizeStamp 怎么算、为什么能防串代?
  • 扩容为什么是 2 倍?(ph & n) 拆分、lastRun 优化分别解决什么问题?
  • size() 为什么弱一致?精确计数怎么做?

ConcurrentHashMap 值得反复读源码——它不是某个天才算法,而是「CAS + 细粒度锁 + 无锁读 + 协作式扩容」这一整套并发工具箱的组合。把 put / addCount / transfer 三条主路径读通,你对 Java 并发设计的理解会上一个台阶。

Comments · 评论