首页 / Java 学习笔记 / 04

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

ConcurrentHashMap 演进:JDK7 分段锁 → JDK8 CAS+synchronized

高级必问#集合#并发#核心
第 1 站

开场:这道面试题,考点地图

线上服务突然 CPU 飙高、请求全部卡死,排查发现是多个线程同时对 HashMap 做 put,扩容时形成了环形链表。你把它换成 Hashtable 能解决吗?能解决,但代价是什么?面试官真正想听的是:ConcurrentHashMap 在 JDK7 和 JDK8 里分别怎么保证线程安全,JDK8 又为什么敢放弃分段锁?

这道题是 Java 并发面试的「必考大题」,通常以连环追问的形式出现:

  • 「HashMap 线程安全吗?不安全会怎样?」——先确认你知道 HashMap 的问题
  • 「Hashtable 线程安全,为什么不用?」——再确认你知道全表锁的代价
  • 「ConcurrentHashMap 1.7 和 1.8 有什么区别?」——核心:Segment 分段锁 vs CAS + synchronized 锁桶头
  • 「JDK8 为什么放弃分段锁?锁粒度变成什么?」——四个维度:锁粒度、内存、size 统计、并发度
  • 「put 流程走一遍?size() 准确吗?扩容时其他线程在干什么?」——方法级源码考察

回答的「纵深」体现在三层:能画出 JDK7 的 Segment 结构 → 能背出 JDK8 putVal 的完整分支(空桶 CAS / MOVED 协助扩容 / synchronized 锁头节点)→ 能解释「为什么这样设计」。只背结论、不解释动机,是这道题最大的失分点。

本文的路线图:数据结构 → 核心流程 → 边界与优化 → 对比 → 生产坑,全程落到方法名、字段名、数字阈值上。

核心要点
  • JDK7:Segment 数组 + 段内 HashEntry 数组 + 链表,锁粒度 1/16
  • JDK8:Node 数组 + 链表/红黑树,空桶 CAS、非空桶 synchronized 锁头节点
  • 放弃分段锁的四个理由:锁粒度、内存开销、size 统计、并发度上限
  • 关键数字:树化 8 / 扩容阈值 64 / 退化 6 / 负载因子 0.75 / 扩容 2 倍
第 2 站

为什么不能直接上 HashMap / Hashtable?

HashMap:线程不安全,而且可能死循环

JDK7 的 HashMap 扩容采用头插法:rehash 时把旧桶的链表节点一个个摘下来头插到新表。两个线程并发 put 触发扩容时,可能把链表倒置成环形链表,之后任何线程 get 到这个桶都会无限循环,CPU 直接飙满——这是当年「线上事故」的经典剧本。JDK8 的 HashMap 改成尾插法解决了环的问题,但「检查-插入」这种复合操作依然不是原子的:两个线程同时 put 同一个 key,各自 read-modify-write,可能互相覆盖丢失更新;迭代期间被并发修改还会抛 ConcurrentModificationException(fail-fast)。

Hashtable / synchronizedMap:安全,但全表一把锁

Hashtable 的所有读写方法都用 synchronized 修饰,等价于锁住整张表:任何时刻只有一个线程能读写,连「读读」都互斥。线程越多,锁竞争越激烈,吞吐量不升反降。Collections.synchronizedMap(map) 只是在外面包了一层全表锁的装饰器,本质一样,还要多一层包装调用。

方案线程安全锁粒度get 是否加锁并发瓶颈
HashMap否(丢更新 / 环形链表 / CME)无锁多线程写直接坏
Hashtable全表 1 把锁读读都串行
Collections.synchronizedMap全表 1 把锁(包装层)同 Hashtable
ConcurrentHashMap段 / 桶级别否(volatile 读)几乎不竞争
一句话点破 · 后端类比

Hashtable 像数据库的表级锁:锁住整张表,安全但吞吐上不去;CHM 1.7 像分库分片锁:拆成 16 个分片,每片一把锁;CHM 1.8 像行级锁 + 乐观锁:只锁受影响的行,空行插入用乐观重试。你天天用 MySQL,却对内存里的「锁粒度演进」说不清,面试官会怀疑你只是背题。

核心要点
  • HashMap 不安全:JDK7 头插法扩容可能产生环形链表(死循环),JDK8 尾插法解决环但仍有丢更新
  • Hashtable / synchronizedMap 是全表锁:get 也要锁,并发读全部串行
  • CHM 的目标:线程安全 + 尽可能接近 HashMap 的并发性能
第 3 站

JDK7 结构:Segment 与 HashEntry

三层结构

JDK7 的 ConcurrentHashMap 是「Segment 数组 → 段内 HashEntry 数组 → 链表」的三层结构。默认 16 个 Segment,每个 Segment 内部是一张独立的 HashEntry 哈希表,互不相干。

ConcurrentHashMap final Segment[] segments 默认 16 个(concurrencyLevel 向上取 2 的幂) Segment<K,V> extends ReentrantLock volatile HashEntry[] table(段内数组) volatile int count / int modCount int threshold / float loadFactor lock() / unlock():锁对象就是 Segment 自己 HashEntry:final hash | final key volatile value | volatile next HashEntry:final hash | final key volatile value | volatile next HashEntry:final hash | final key volatile value | volatile next null 锁粒度 = 1 个 Segment(默认 1/16 的数据);get 全程无锁(volatile 读)
图 1JDK7 三层结构:Segment 数组 → 段内 HashEntry 数组 → 链表;Segment 继承 ReentrantLock,锁粒度是「段」。

两个核心类,字段必须背下来

ConcurrentHashMap.java · JDK7 内部结构(字段级)
// 外层:Segment 数组,final 不可变,构造后并发度固定
final Segment<K,V>[] segments;

// 定位段:用 hash 的高位(segmentShift / segmentMask)
final int segmentShift;   // = 32 - sshift,默认 28
final int segmentMask;    // = ssize - 1,默认 15

// 段:一把可重入锁 + 一张独立的哈希表
static final class Segment<K,V> extends ReentrantLock {
    transient volatile HashEntry<K,V>[] table;
    transient volatile int count;      // 段内元素个数
    transient int modCount;              // 段内修改次数(size 统计用)
    transient int threshold;             // 段内扩容阈值 = table.length × loadFactor
    final float loadFactor;
}

// 节点:next 也是 volatile —— get 无锁遍历的根基
static final class HashEntry<K,V> {
    final int hash;
    final K key;
    volatile V value;
    volatile HashEntry<K,V> next;
}

注意两个细节:一是定位段用的是高位(hash >>> segmentShift) & segmentMask),与 JDK8 用 (n - 1) & hash 低位取模不同;二是 HashEntry.nextvolatile,配合 value 的 volatile,get 才能在完全不加锁的情况下安全遍历链表。

核心要点
  • 并发度 = segments.length,默认 16,由构造参数 concurrencyLevel 决定且不可变
  • 每个 Segment 自带一把 ReentrantLock:put 只锁一个段,其他 15 个段可并行写
  • get 无锁靠 volatile:HashEntry.valueHashEntry.next 都是 volatile
  • 段内扩容独立进行(rehash),不影响其他段
第 4 站

JDK7 流程:put / get / size 源码级

put:先 tryLock,失败再「扫描 + 预创建」等锁

JDK7 的 put 很有意思:先不抢锁。第一步 tryLock(),成功直接进入临界区;失败则调用 scanAndLockForPut()——在不加锁的情况下先遍历一遍链表,确认 key 是否存在、并预创建好新节点,然后自旋 + 阻塞等待锁(多核机器最多自旋 MAX_SCAN_RETRIES = 64 次)。这样做的目的是把「找位置、造节点」这些耗时操作挪到锁外,缩短持锁时间。

Segment.put() · JDK7 段内写入(流程简化)
HashEntry<K,V> node = tryLock() ? null
                                : scanAndLockForPut(key, hash, value);
try {
    HashEntry<K,V>[] tab = table;
    int index = (tab.length - 1) & hash;      // 段内定位(低位取模)
    HashEntry<K,V> first = entryAt(tab, index);
    // 遍历:命中则更新 value;未命中则……
    node.setNext(first);                        // 头插法!新节点插到链表头部
    int c = count + 1;
    if (c > threshold && tab.length < MAXIMUM_CAPACITY)
        rehash(node);                           // 段内扩容:表长 × 2(含 lastRun 优化)
    else
        setEntryAt(tab, index, node);
    count = c;
} finally {
    unlock();                                   // 锁的正是 Segment 自己(ReentrantLock)
}

get:全程无锁

get 用 segmentFor(hash) 定位段、getFirst(tab, hash) 定位桶,然后沿 volatile next 遍历——不加任何锁。因为 HashEntry.valuenext 都是 volatile,读到的永远是「某个时刻的完整状态」。

size:先乐观统计两遍,不行就锁全表

ConcurrentHashMap.size() · JDK7(伪代码,逻辑保留)
// RETRIES_BEFORE_LOCK = 2:先无锁统计两遍
for (int k = 0; k < RETRIES_BEFORE_LOCK; ++k) {
    // 遍历所有 Segment,累加 count,同时记录每个段的 modCount;
    // 若两次统计间所有 modCount 都没变 → 认为结果稳定,直接返回
}
// 两遍都不稳定 → 兜底:锁住【所有】Segment 再累加
for (int i = 0; i < segments.length; ++i)
    segments[i].lock();
// …… 累加 count 后逐个 unlock

这个 size 的实现暴露了 JDK7 的一个软肋:最坏情况要锁住全部 16 个 Segment,等于把分段锁的全部优势一次性还回去。写多读少的场景里,一次 size() 调用可能让整个 map 短暂「冻结」。

一句话点破

JDK7 的 tryLock + scanAndLockForPut 本质是「先乐观探测、失败再悲观加锁」——你把锁外预计算这个思路讲出来,面试官就知道你读过源码而不是背过博客。

JDK7 已经把并发度做到 16 了,写操作互不干扰,为什么 JDK8 还要推倒重来?
核心要点
  • put:tryLock 失败 → scanAndLockForPut(锁外扫描 + 预创建节点,最多自旋 64 次)→ 头插法 → 段内 rehash(2 倍)
  • get:无锁,纯 volatile 读
  • size:两遍无锁统计(RETRIES_BEFORE_LOCK=2),modCount 不稳则锁全表兜底——这就是 1.8 要解决的痛点之一
第 5 站

为什么 JDK8 放弃分段锁?

这是全文最高频的追问,答案要拆成四个维度,缺一不可:

维度JDK7 SegmentJDK8 的问题本质
锁粒度1 个段 = 默认 1/16 的数据,写一个 key 要锁 1/16 的表粒度还是太粗;JDK8 锁桶头节点 = 1/N,空桶连锁都不用
内存开销每个 Segment 是一个 ReentrantLock(AQS 的 state + 等待队列),至少 16 个锁对象JDK8 锁对象就是桶头节点本身,零额外锁对象,小 map 不浪费
size 统计先无锁统计两遍,不一致就锁全部 SegmentJDK8 用 baseCount + CounterCell 分条计数,O(1) 且几乎无锁
并发度并发度 = segments.length,构造后固定,扩容也不变并发度 = table.length,扩容翻倍、动态增长

还有一个经常被忽略的工程因素:synchronized 在 JDK6+ 经历了偏向锁、轻量级锁、自旋、锁消除/锁粗化等持续优化,性能已不输 ReentrantLock,而且使用更简单、语义更清晰。官方没有必要再维护一套自研的分段锁体系——用 JVM 自带的监视器锁锁住「恰好需要的那个桶」,就是最经济的方案。

一句话点破 · 后端类比

把 Segment 锁想成分库分表:拆 16 个库,每库一把锁,写操作只碰自己那个库——但库的数量是写死的,扩容不增加库数,统计全局行数还得把 16 个库挨个查一遍甚至锁一遍。JDK8 的思路是行级锁 + 乐观锁:锁只落在被写的那一行(桶),空行插入用乐观重试,全局计数用分片计数器。你给 MySQL 做过分库分表、遇到过「再分就管不过来」的瓶颈,就完全能理解为什么 JDK 团队要改。

核心要点
  • 放弃分段锁 = 锁粒度 + 内存 + size 统计 + 并发度四个问题的综合解
  • synchronized 经 JDK6+ 优化后性能足够,实现更简单、更可靠
  • 「并发度不再由构造函数决定」是 JDK8 的招牌答案,务必背熟
第 6 站

JDK8 结构:Node 数组与关键字段

一张图看懂新结构

volatile Node<K,V>[] table(桶数组,长度恒为 2 的幂) null 链表(2 节点) 红黑树 null 链表(3 节点) null null … N Node 🔒 Node TreeBin 🔒 Node 🔒 Node Node 关键字段(全部 volatile,靠 CAS 更新) sizeCtl:-1 初始化中 / <0 扩容中(-(1+协助线程数))/ 0 默认 / >0 扩容阈值或预置容量 baseCount + CounterCell[]:size 统计(LongAdder 思路,@sun.misc.Contended 防伪共享) nextTable / transferIndex:多线程协助扩容的辅助字段 空桶 → CAS 无锁插入;链表/树桶 → synchronized 锁头节点(TreeBin 锁根);🔒 表示被锁的节点
图 2JDK8 结构:Node 数组 + 链表/红黑树;锁的对象是「桶头节点」,空桶走 CAS。

Node 与 HashMap.Node 的差别

ConcurrentHashMap.java · JDK8 Node 与常量
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    volatile V val;                 // 与 HashMap 不同:val 是 volatile
    volatile Node<K,V> next;         // next 也是 volatile
}

// 哈希标记:负值不会与真实 hash 冲突(真实 hash 经 HASH_BITS 清符号位)
static final int MOVED     = -1;   // ForwardingNode:该桶已迁移,去新表找
static final int TREEBIN   = -2;   // TreeBin:该桶已树化
static final int RESERVED  = -3;   // ReservationNode:占位(compute 等用)
static final int HASH_BITS = 0x7fffffff; // spread 后清掉符号位

// 阈值常量(面试必须脱口而出)
static final int TREEIFY_THRESHOLD = 8;      // 链表转树阈值
static final int UNTREEIFY_THRESHOLD = 6;   // 树退化链表阈值
static final int MIN_TREEIFY_CAPACITY = 64;  // 树化的最小数组长度
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int DEFAULT_CAPACITY = 16;
static final int MAXIMUM_CAPACITY = 1 << 30;

spread 扰动与懒加载

spread(h) 把 hashCode 的高 16 位与低 16 位异或后再清掉符号位:(h ^ (h >>> 16)) & HASH_BITS。清符号位是为了让真实 hash 恒为非负,与 MOVED(-1) / TREEBIN(-2) / RESERVED(-3) 这三个负数标记严格区分。

另外,JDK8 的无参构造函数是空的——数组完全懒加载,首次 put 时由 initTable() 创建(默认容量 16)。带容量参数的构造器会把 sizeCtl 预置为 tableSizeFor 的估算值(例如 new ConcurrentHashMap(16)sizeCtl = 32,首表 32),这也是「预估容量防多次扩容」的依据。

initTable() · 初始化(CAS 把 sizeCtl 从 sc 改为 -1,抢占初始化权)
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        if ((sc = sizeCtl) < 0) Thread.yield();   // 别人在初始化,让出 CPU
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    table = tab = new Node<?,?>[n];
                    sc = n - (n >>> 2);              // 阈值 = 0.75n
                }
            } finally { sizeCtl = sc; }
            break;
        }
    }
    return tab;
}
核心要点
  • Node.valNode.next 都是 volatile——get 无锁的根基
  • sizeCtl 三态:负数(初始化/扩容中)、0(默认)、正数(阈值或预置容量)
  • 负数 hash 是系统标记(MOVED/TREEBIN/RESERVED),与真实 hash 用 HASH_BITS 隔离
  • 数组懒加载:无参构造不建表,首表 16;带参构造 sizeCtl 预置容量
第 7 站

JDK8 putVal:方法级拆解

这一站是全文的「题眼」。先把流程图画出来,再逐行读源码。

put(key, value):key/value 为 null → 抛 NPE hash = spread(key.hashCode()) table 为空 → initTable()(CAS 抢占初始化) f = tabAt(tab, (n-1) & hash) f == null?→ casTabAt 无锁插入 → break f.hash == MOVED?→ helpTransfer 协助扩容 synchronized(f),双重校验 tabAt(tab,i) == f 链表:命中更新 / 尾插;TreeBin:putTreeVal binCount ≥ 8 → treeifyBin(数组 < 64 先扩容) addCount(1, binCount):计数 + 可能触发扩容 无锁路径!覆盖绝大多数写入 (碰撞率低时性能≈HashMap) 扩容期间看到 ForwardingNode (hash=-1)→ 先帮别人搬,再回来 put binCount 从 1 起计;JDK8 是尾插法 (JDK7 头插法,JDK8 改尾插) size ≥ sizeCtl → transfer 扩容 (高争用路径下 check≤1 跳过检查)
图 3putVal 六步:① 判空 ② 扰动 ③ 初始化 ④ 空桶 CAS ⑤ 协助扩容/锁头节点 ⑥ 计数与扩容检查。

源码逐段解读

putVal() · JDK8 核心写入(关键分支保留)
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null)          // ① null 直接拒绝
        throw new NullPointerException();
    int hash = spread(key.hashCode());          // ② 扰动 + 清符号位
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, 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))) // ④ 空桶:CAS 无锁插入
                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;     // 命中:更新(volatile 写)
                                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); // 尾插
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin)
                        ...putTreeVal...             // 树桶:按红黑树插入
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)   // ⑦ 树化检查
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);                       // ⑧ 计数 + 扩容检查
    return null;
}

三个必须讲清的细节

  • 尾插法:JDK8 新节点插到链表尾部,从根上消除了 JDK7 头插法在多线程 rehash 时的环形链表隐患。
  • 双重校验:进入 synchronized(f) 后还要再检查 tabAt(tab, i) == f——因为等待锁期间,该桶可能已被别的线程扩容迁移(头节点被换成 ForwardingNode),锁错对象就白锁了。
  • binCount 从 1 起计:源码判定是 binCount >= TREEIFY_THRESHOLD(8),即本次扫描过 ≥ 8 个节点(链表实际达到 8~9 个节点)才触发 treeifyBin;「链表长度 ≥ 8 转树」是通俗说法,代码判定以 binCount 为准。
一句话点破

putVal 的精髓是「尽量不锁」:空桶 CAS 一把梭,只有撞上非空桶才 synchronized 锁头节点,且锁内只做本桶链表操作。锁的持有时间被压缩到极致——这就是为什么它敢叫「Concurrent」HashMap。

核心要点
  • 空桶:CAS 无锁插入(覆盖大多数写入);非空桶:synchronized 锁头节点
  • 遇 MOVED → helpTransfer 协助扩容;锁内双重校验防「锁错对象」
  • JDK8 尾插法;binCount ≥ 8 触发 treeifyBin;末尾 addCount 计数并可能触发扩容
第 8 站

锁桶头节点:synchronized 的「逆袭」

面试官常有一个刻板印象:「synchronized 是重量级锁,性能差」。那 JDK8 为什么反而用它锁一个普通的 Node 对象,而不用 JDK7 那套更「高级」的 ReentrantLock?

锁的是什么?

JDK8 的锁对象就是桶的头节点 Node(树化后是 TreeBin 根节点)。它不是一个专门的锁对象,而是「恰好要操作的那个桶的第一个元素」——零额外内存。这是与 Segment 最本质的区别:Segment 需要 16 个 ReentrantLock 对象(每个都是一套 AQS 状态机),而 JDK8 的锁随桶存在,桶空就没有锁。

为什么敢用 synchronized?

  • JDK6+ 的锁升级路径:偏向锁 → 轻量级锁(CAS 自旋)→ 重量级锁(只有竞争激烈才升级),绝大多数桶操作在轻量级锁阶段就完成了。
  • JIT 优化:锁消除、锁粗化、自适应自旋,由 JVM 按运行时特征调整,ReentrantLock 享受不到这些。
  • 自动释放:异常也不会漏锁(monitor exit 由字节码保证),不用手写 finally。
  • 语义简单:JDK 源码注释明确表示,现代 JVM 上 synchronized 已足够快,没必要维护一套自研锁体系。
Hashtable:全表 1 把锁 LOCK 读读互斥,并发度 = 1 JDK7:16 把段锁 段间并行,段内串行;并发度 = 16(固定) JDK8:桶级锁 + CAS 空桶 CAS 无锁;非空锁头节点;并发度 = N 锁粒度演进:1 把全表锁 → 16 把段锁 → N 把「随桶而生的隐式锁」 JDK8 的锁不是「申请」来的,而是「桶头节点本身就是锁」——内存零成本
图 4锁粒度对比:全表锁 → 分段锁(1/16)→ 桶锁(1/N)+ CAS。

别忘了锁的边界

  • 锁只保护当前桶的读写:同桶写串行,不同桶完全并行。
  • 扩容迁移时锁的也是旧桶的头节点,迁移完换成 ForwardingNode 释放。
  • 热点 key 仍串行:极端场景(例如秒杀同一个 key),同一桶的写还是排队——锁粒度到桶,不是到 key。这是生产上需要自己绕开的坑(见第 14 站)。
核心要点
  • 锁对象 = 桶头节点(或 TreeBin 根),零额外锁内存;空桶根本没有锁
  • synchronized 靠锁升级 + JIT 优化追上 ReentrantLock,且实现更简单
  • 锁粒度到桶(1/N),且随扩容增长;热点 key 单桶仍串行是边界
第 9 站

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

为什么树化阈值是 8,而不是 5 或 10?为什么数组小于 64 时反而不树化、先扩容?为什么退化阈值是 6,跟 8 差着 2?

树化的两个条件,缺一不可

treeifyBin(tab, i) 内部是这么判断的:

treeifyBin() · 树化前的容量检查
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);                        // 先扩容 2 倍,不树化
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            synchronized (b) {                     // 锁头节点,改造成 TreeBin
                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<>(e.hash, e.key, e.val, null, null);
                        // 双向链表串联……
                    }
                    setTabAt(tab, index, new TreeBin<>(hd)); // 头节点换成 TreeBin(hash=-2)
                }
            }
        }
    }
}

所以树化的完整条件是:put 后链上节点数 ≥ 8(binCount ≥ 8)且数组长度 ≥ 64。数组还小时,优先扩容 2 倍而不是树化——因为容量翻倍能把已有节点均匀摊开、直接消除长链,比建一棵树更省事更高效。

为什么是 8 / 64 / 6?

数字含义设计理由
8(TREEIFY_THRESHOLD)链表转红黑树阈值HashMap 源码注释的经典推导:负载因子 0.75、hash 分布随机时,链表长度到达 8 的概率约 千万分之六(泊松分布)。正常数据几乎不可能自然触发,一旦触发说明 hashCode 分布劣化(被恶意构造/低质量散列),此时树化把 O(n) 查找降到 O(log n)
64(MIN_TREEIFY_CAPACITY)允许树化的最小数组长度容量太小时「扩容」比「树化」更能解决问题;避免小表上一上来就建树
6(UNTREEIFY_THRESHOLD)红黑树退化回链表阈值与 8 故意留 2 的缓冲,防止元素在阈值附近增删时树↔链反复横跳(抖动)

退化发生在哪?

JDK8 的退化主要发生在扩容拆分时:红黑树按 hash & n 拆成 lo/hi 两棵(或两条链),如果某一侧节点数 ≤ 6,直接 untreeify 还原成链表。注意:JDK8 的 remove 不会主动把树退化成链表——树变小了也只留在树状态,直到扩容拆分才处理。这点和很多人以为的「删到 6 就退化」不一样。

一句话点破 · 后端类比

「8/64/6」这套阈值组合,跟连接池/限流的双阈值设计是一个道理:8 是「触发升级」的警戒线,6 是「解除升级」的回落线,中间留 2 的滞回区间防抖。你给线程池配过 corePoolSizemaxPoolSize,就理解了「两个阈值之间必须留缓冲」的工程直觉。

核心要点
  • 树化:链 ≥ 8(binCount ≥ 8)且数组 ≥ 64;数组 < 64 先扩容(tryPresize 2 倍)
  • 8 的来历:0.75 负载因子 + 泊松分布,长度 8 的概率约千万分之六,正常不会自然达到
  • 6 与 8 差 2:滞回区间防树↔链抖动;退化发生在扩容拆分,remove 不主动退化
第 10 站

扩容:多线程协助 transfer

扩容要搬运整个数组,期间别的线程来 put / get 怎么办?总不能让所有线程干等吧?

JDK8 的扩容是多线程协作的:发起扩容的线程只负责一部分桶,其余桶由「路过的」线程顺手帮忙搬。协作的媒介就是 ForwardingNode(hash = MOVED = -1)。

旧表 table:n = 16 fwd fwd fwd Thread A:领 stride = 16 个桶 Thread B:协助,再领 16 个桶(CAS 更新 transferIndex) 拆链规则:hash & n == 0 → 留在原下标 i(lo);== 1 → 搬到 i + n(hi) 新表 nextTable:2n = 32(每个旧桶拆成两个新桶) ForwardingNode 特写 hash = MOVED = -1 内含 nextTable 引用 → 新表 put 线程看到 → helpTransfer() 帮搬 get 线程看到 → find() 去新表查 搬完:table = nextTable(2n),nextTable = null,sizeCtl = 1.5n(= 0.75 × 2n,即新表阈值) 扩容倍数 2(对比:ArrayList 扩容 1.5 倍)——哈希表必须 2 的幂才能用 (n-1)&hash 定位
图 5transfer:每个线程 CAS 领取一段 stride 桶,搬完放 ForwardingNode 标记;旧桶按 hash&n 拆成 lo/hi 两个新桶。

transfer 的关键机制

  • 任务领取stride = max((n >>> 3) / NCPU, 16),每个线程通过 CAS 更新 transferIndex 领取一段连续的桶,互不重叠。
  • 空桶:直接 CAS 放一个 ForwardingNode,标记「已处理」。
  • 链表桶:按 e.hash & n 拆成 lo / hi 两条链(lastRun 优化:从最后一段同向连续节点整体搬移),lo 留在原下标 i,hi 搬到 i + n——这也是 JDK7 rehash 里就有的技巧。
  • 树桶:拆成两棵子树,任一侧 ≤ 6 则 untreeify 退化成链表(见第 9 站)。
  • 收尾:全部搬完,table = nextTablenextTable = nullsizeCtl = 1.5n(= 0.75 × 2n)。

其他线程遇到扩容怎么办

  • put:看到 MOVEDhelpTransfer(tab, f):校验 resizeStamp 一致、sizeCtl < 0transferIndex > 0 后,CAS 把 sizeCtl 加 1 并加入 transfer——「帮忙搬完再回来继续自己的 put」。
  • get:看到 MOVED → 走 ForwardingNode.find(),沿着 nextTable 引用去新表里找,完全不用等。
一句话点破 · 后端类比

ForwardingNode 就是 Redis Cluster 的 MOVED 重定向:数据迁移期间,客户端(线程)命中「正在搬的槽位」时收到一条重定向标记,自动转向新节点再查一次。把 CHM 的协助扩容讲成「搬库时路过的线程顺手帮扛几箱数据」,面试官一下就懂你是真的理解了。

核心要点
  • 扩容 2 倍(nextTable = n << 1),新表阈值 sizeCtl = 1.5n;ArrayList 是 1.5 倍,哈希表必须 2 的幂
  • 协作机制:stride 分片 + transferIndex CAS 领任务 + ForwardingNode(MOVED) 标记
  • put 遇 MOVED → helpTransfer 协助;get 遇 MOVED → find 去新表,不等待
第 11 站

size():从锁全表到 CounterCell

JDK7 的困境

JDK7 的 size() 要遍历 16 个 Segment 的 count 求和,还得靠 modCount 判断统计期间有没有并发写;一旦不稳定,最坏情况锁住全部 Segment。写多读少时,一次 size() 就是一次「全表冻结」。

JDK8 的解法:LongAdder 思想的分条计数

JDK8 把计数拆成「一个主计数器 + 一组分条计数器」:

  • 默认只 CAS 累加 baseCount(无争用时一次 CAS 搞定,零开销);
  • CAS 失败说明并发激烈,把计数散列到 CounterCell[] 的某个分条上各自累加(ThreadLocalRandom.getProbe() & m 选条),把「单点热点」摊成「多点并行」;
  • CounterCell@sun.misc.Contended缓存行填充,避免相邻分条互相「伪共享」拖慢性能。
sumCount() / size() · JDK8 计数求和
final long sumCount() {
    CounterCell[] as = counterCells;
    long sum = baseCount;                    // 主计数器
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            CounterCell a;
            if ((a = as[i]) != null)
                sum += a.value;                 // 加上各分条
        }
    }
    return sum;
}

public int size() {                          // int 版本,溢出截断到 MAX_VALUE
    long n = sumCount();
    return (n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n;
}

public long mappingCount() {                 // long 版本,推荐用它
    long n = sumCount();
    return (n < 0L) ? 0L : n;
}
维度JDK7 size()JDK8 size()
统计方式遍历所有 Segment 累加 countbaseCount + 遍历 CounterCell 分条
一致性保障modCount 对比,不稳则锁全部 Segment无锁,O(1) 近似
最坏代价全表冻结(锁 16 个段)遍历 counterCells(长度很小)
返回值语义尽力精确,但可能已过期文档明示:并发更新时只是估计值
一句话点破 · 后端类比

CounterCell 就是秒杀库存分片 / 数据库分片计数器:单个热点计数器被拆成 N 份,各线程各写各的分片,汇总时再求和——把「单点 CAS 争用」变成「多点并行」。Caffeine、Guava 的并发计数都借鉴了 LongAdder 这套思路。

size() 是近似值,那「if (map.size() == 0) 就清缓存」这种代码能写吗?

不能依赖。并发写入下 size() 可能返回旧值;需要精确语义时,要么用外部计数(自维护 AtomicLong),要么用锁保护复合操作。这是第 14 站的坑 4,先记住结论。

核心要点
  • JDK8 计数 = baseCount(CAS)+ CounterCell[](分条,LongAdder 思路),无锁 O(1)
  • @sun.misc.Contended 防伪共享;size() 是 int 截断版,mappingCount() 返回 long
  • size() 是近似值:并发下可能不准确,别拿它做精确业务判断
第 12 站

get 无锁:可见性、null 与弱一致性

get 不加锁,会不会读到「写了一半」的脏数据?返回 null 到底代表「key 不存在」还是「值为 null」?

为什么 get 可以无锁

get() · JDK8(无任何加锁)
public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        if ((eh = e.hash) == h) {                // 头节点直接命中
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        else if (eh < 0)                        // MOVED / TREEBIN / RESERVED
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {           // 遍历链表
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

安全性的三根支柱:

  • volatile 可见性Node.val / Node.next 都是 volatile,volatile 写对后续读有 happens-before 保证——put 更新的值,后续 get 一定能看到(至少看到「某个已发布版本」)。
  • 安全发布:新节点先完整构造(hash/key 是 final),再通过 casTabAt 一次性发布到桶里;get 要么看到 null(旧态),要么看到一个完整构造好的节点,绝不会读到半初始化的节点
  • 读旧不读脏:get 可能读到旧值(弱一致),但读到的永远是「曾经存在过的合法状态」,不会有中间态。
一句话点破 · 数据库类比

CHM 的 get 等价于数据库读已提交(Read Committed)级别的读:允许读到稍旧的快照,但不允许读到脏数据。你会跟 DBA 解释「读已提交解决脏读、不解决不可重复读」,换成 CHM 就该说「get 无锁读到旧值、不读脏值」。

为什么不允许 null 键和 null 值

putVal 第一行就 throw new NullPointerException()。原因要答到 Doug Lea 的原意:

  • 语义歧义get(key) 返回 null,无法区分「key 不存在」还是「key 映射到 null」;
  • 并发下无法消除歧义:单线程 HashMap 可以用 containsKey 二次确认,但并发环境中两次调用之间 map 可能已经变化,「get 为 null → 断定 key 不存在」这个推断不再可靠;
  • Doug Lea 在邮件中的原话大意:并发 Map 中这种「勉强能忍的歧义」是无法被容忍的,因为我们不想为 null 引入哨兵机制。

弱一致迭代器

CHM 的迭代器是弱一致(weakly consistent)的:不会抛 ConcurrentModificationException;能看到迭代器创建时已存在的元素;创建之后的并发修改「可能反映,也可能不反映」。原因很简单——遍历走的是 tabAt 的 volatile 快照,新增到「已经遍历过」的桶里的节点自然看不到。

核心要点
  • get 无锁 = volatile 可见性 + final 字段安全发布 + 引用赋值原子性,读旧不读脏
  • null 键值被拒:get 返回 null 语义歧义 + 并发下 get/containsKey 复合判断非原子
  • 迭代器弱一致:不抛 CME,可能漏掉并发新增——别在遍历时做「必须精确」的统计
第 13 站

并发度:从 concurrencyLevel 到 table.length

JDK7:并发度写死,扩容也不涨

JDK7 的并发度 = segments.length,由构造参数 concurrencyLevel(默认 16)向上取 2 的幂得到,构造之后永不变化——哪怕数据涨到几百万,并发度还是 16。锁的「数量」跟不上数据的「规模」,这是架构层面的天花板。

JDK8:并发度 = table.length,随扩容翻倍

JDK8 没有独立的「锁数组」了,锁就是桶头节点,所以并发度天然等于桶数组长度:初始 16,扩容到 32、64……并发度同步翻倍。构造参数 concurrencyLevel 虽然保留(为了兼容老 API),但语义已变:

ConcurrentHashMap(int, float, int) · JDK8 三参构造器
public ConcurrentHashMap(int initialCapacity,
                         float loadFactor, int concurrencyLevel) {
    if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
        throw new IllegalArgumentException();
    if (initialCapacity < concurrencyLevel)   // 只用来「垫高」初始容量
        initialCapacity = concurrencyLevel;    // 不再决定并发度!
    long size = (long)(1.0 + (long)initialCapacity / loadFactor);
    int cap = (size >= (long)MAXIMUM_CAPACITY) ?
        MAXIMUM_CAPACITY : tableSizeFor((int)size);
    this.sizeCtl = cap;                       // 预置首表容量与阈值
}

面试标准答案:JDK8 的并发度不再由任何构造参数决定,而是等于 table.length,随扩容动态增长。所谓「理论并发度 N」,N 就是当前桶数组长度。

总对比表(背诵版)

特性HashtablesynchronizedMapJDK7 CHMJDK8 CHM
数据结构数组 + 链表包装 HashMapSegment[] + HashEntry[] + 链表Node[] + 链表 + 红黑树
锁粒度全表 1 把锁全表 1 把锁Segment(1/16)桶头节点(1/N)+ 空桶 CAS
锁实现synchronized 方法synchronized 包装ReentrantLock(继承)synchronized 块
get 加锁否(volatile)否(volatile)
并发度11segments.length(固定,默认 16)table.length(随扩容翻倍)
扩容全表重哈希全表重哈希段内独立 rehash(2 倍)多线程协作 transfer(2 倍)
插入方式头插头插(锁内安全)尾插
size 统计全表锁全表锁两遍扫描,最坏锁全表baseCount + CounterCell,近似
null 键值不允许不允许不允许不允许
迭代器fail-fastfail-fast弱一致弱一致
一句话点破

「并发度 = 数组长度」这句话别只说一半:同一桶的写仍然串行、扩容期间部分桶被锁,所以它是「理论上限」,不是「实测吞吐」。面试官追问「那并发度是无限的咯?」时,答出这两个边界才算完整。

核心要点
  • JDK7:并发度 = segments.length,构造固定,扩容不涨
  • JDK8:并发度 = table.length,扩容翻倍;concurrencyLevel 仅作初始容量下限(兼容)
  • 理论并发度 N 的两个边界:同桶写串行、扩容期部分桶被锁
第 14 站

生产实战:十个坑与四个最佳实践

某同学用 computeIfAbsent 做本地缓存:key 不存在时去查数据库。上线后线上偶发「卡顿几秒」,为什么?

坑 1(最经典):映射函数持锁做 IO

JDK8 的 compute / computeIfAbsent / merge桶的锁内执行映射函数:空桶场景用 ReservationNode 占位并加锁(hash = RESERVED),非空桶锁头节点。函数里查数据库、调远程接口 → 该桶所有读写全部排队 → 接口毛刺、连接池被打满。官方在 JDK-8161372 中修复了「key 已存在时仍要持锁检查」的问题(JDK9 起头节点命中且值非 null 时走无锁快速路径直接返回),但「key 不存在需要插入」时函数仍可能在锁内执行。最佳实践:映射函数必须快;慢逻辑锁外预计算好再放进去,或用 get 先预检查。

坑 2:锁内再锁 → 死锁

compute 的映射函数里再操作另一个 CHM(甚至同一个 CHM),两把桶锁之间没有全局顺序,多线程交叉申请就可能死锁。原则:映射函数里只做纯计算,绝不碰其他锁

坑 3:热点 key 单桶串行

锁粒度到桶不到 key,秒杀同一个 key 的并发写全部排队。计数类场景用 LongAdder 或自建分片;需要「读改写」原子性时考虑 merge(锁内完成)而不是 get + put。

坑 4:size() 是近似值

不要写 if (map.size() == 0) 作为并发控制条件;精确语义用外部 AtomicLong 或在锁保护下操作。

坑 5:弱一致迭代漏数据

遍历做统计可能漏掉并发新增;要求精确时先快照(如复制到新 map)或在无并发窗口操作。

坑 6:可变 key

key 的 hashCode() 依赖可变字段,改字段后按新 hash 找不到原节点——key 必须不可变或保证 hash 稳定。

坑 7:不预分配容量导致多次扩容

一次性塞几十万条,默认 16 起步要扩容近 15 次。用 new ConcurrentHashMap(expectedSize),sizeCtl 会预置合适的首表容量,避免扩容风暴。

坑 8:大 map 扩容期 get 变慢

扩容期间 get 命中的桶若已迁移,要走 ForwardingNode.find 去新表;迁移量越大、窗口越长,读延迟越高。预分配容量同样能缓解。

坑 9:put 覆盖 vs putIfAbsent

put 无条件覆盖;「不存在才写」用 putIfAbsent;「不存在才初始化复杂值」用 computeIfAbsent(注意坑 1 的持锁)。

坑 10:containsKey + get 不是原子的

先 containsKey 再 get 之间可能有别的线程删除该 key;要么容忍 null,要么用原子方法。

四个最佳实践代码

实践 1 · merge 原子计数(替代 get+put)
ConcurrentHashMap<String, Long> stats = new ConcurrentHashMap<>();
stats.merge("error", 1L, Long::sum);     // 不存在→1,存在→+1,全程原子
实践 2 · computeIfAbsent 缓存:锁外预检查 + 快函数
V v = cache.get(key);                       // ① 无锁预检查,命中直接返回
if (v == null) {
    V loaded = loadFromDb(key);             // ② 慢 IO 在【锁外】完成
    if (loaded != null)
        v = cache.putIfAbsent(key, loaded);  // ③ 锁内只做引用赋值,快
    return (v != null) ? v : loaded;
}
return v;
实践 3 · 预分配容量,避免扩容风暴
// 已知将写入 100_000 条:new CHM(100_000),sizeCtl 自动预置合适首表容量
ConcurrentHashMap<String, Object> cache =
    new ConcurrentHashMap<>(100_000);
实践 4 · 热点计数分片(避免单桶串行)
// 一个 key 打爆一个桶?把计数拆成 N 份,读取时求和
LongAdder[] cells = new LongAdder[16];
// 写:cells[ThreadLocalRandom.current().nextInt(16)].increment();
// 读:Stream.of(cells).mapToLong(LongAdder::sum).sum();
一句话点破

CHM 的锁很「轻」,但轻锁也怕长时间占用。把它当成 MySQL 的行锁来用:事务(映射函数)里只放快操作,IO 一律挪到事务外。这个「锁内快进快出」的原则,是并发代码的通用素养。

核心要点
  • compute/computeIfAbsent/merge 的映射函数 JDK8 持桶锁:函数必须快,IO 放锁外(JDK9+ 已优化「key 存在」场景,见 JDK-8161372)
  • 映射函数里不碰其他锁(防死锁);热点 key 计数用 LongAdder 分片
  • 预分配容量防扩容风暴;size() 是近似值;key 保持不可变
第 15 站

面试追问速答

把高频追问 + 一句话参考回答整理成表,考前 5 分钟过一遍:

追问一句话参考回答
JDK8 锁的到底是什么?桶头节点 Node(树化后是 TreeBin 根);空桶走 CAS 无锁
为什么 JDK8 用 synchronized 不用 ReentrantLock?synchronized 在 JDK6+ 有锁升级与 JIT 优化,性能已足够;锁对象即桶头节点,零额外内存;异常自动释放
树化阈值是「链表长度 ≥ 8」吗?代码判定是 binCount ≥ 8(binCount 从 1 起计),且数组长度 ≥ 64;数组 < 64 先扩容不树化
size() 精确吗?不保证:baseCount + CounterCell 分条求和,并发写时是估计值;精确场景用外部计数
扩容时其他线程在做什么?put 遇 MOVED 走 helpTransfer 协助搬桶,搬完再继续 put;get 遇 MOVED 去新表 find,不等待
get 不加锁安全吗?安全:volatile 可见性 + final 字段安全发布;可能读到旧值,但不会读到脏值/半初始化节点
为什么不允许 null 键值?get 返回 null 无法区分「不存在」与「值为 null」,并发下 get + containsKey 复合判断非原子(Doug Lea 观点)
并发度是多少?由什么决定?等于 table.length,随扩容翻倍;不再由 concurrencyLevel 决定(该参数仅作初始容量下限,兼容保留)
JDK8 put 是头插还是尾插?尾插;JDK7 段内头插在 Segment 锁保护下也安全,但 JDK8 统一改尾插消除环形链表隐患
迭代器会抛 ConcurrentModificationException 吗?不会,CHM 迭代器弱一致:反映创建时已存在的元素,后续修改可能反映也可能不反映
computeIfAbsent 里能查数据库吗?JDK8 不行(映射函数持桶锁);JDK9+ 对「key 已存在」加了无锁快速路径,但插入场景仍可能持锁——一律锁外预加载
核心要点
  • 先背结构(Segment vs Node 数组),再背流程(putVal 六步),最后背动机(四个放弃理由)
  • 数字:8 / 64 / 6 / 0.75 / 16 / 2 倍 / MOVED=-1 / RESERVED=-3
  • 每个结论都带「为什么」,这是从「背题」到「懂」的分水岭
总结

这一篇你掌握了什么

核心知识点回顾

  • 结构演进:JDK7 是 Segment 数组(继承 ReentrantLock)+ HashEntry 数组 + 链表;JDK8 是 Node 数组 + 链表/红黑树,去掉独立锁对象
  • 锁演进:JDK7 分段锁(1/16,固定);JDK8 空桶 CAS + synchronized 锁桶头节点(1/N,随扩容翻倍)
  • 放弃分段锁的四因:锁粒度粗、每个 Segment 一把 ReentrantLock 内存贵、size 最坏锁全表、并发度被构造参数写死
  • putVal 六步:判空 → spread 扰动 → initTable → 空桶 CAS → MOVED 协助扩容 / synchronized 锁头节点(双重校验 + 尾插)→ treeifyBin 检查 + addCount 计数扩容
  • 关键数字:树化 8(binCount ≥ 8)、数组下限 64、退化 6、负载因子 0.75、扩容 2 倍(ArrayList 1.5 倍)、MOVED=-1 / TREEBIN=-2 / RESERVED=-3
  • 关键字段:sizeCtl(初始化/扩容状态机)、baseCount + CounterCell(LongAdder 分条计数)、nextTable / transferIndex(协作扩容)
  • get 无锁三支柱:volatile 可见性、final 字段安全发布、引用赋值原子性——读旧不读脏;null 键值被拒、迭代器弱一致
  • 生产红线:映射函数锁内快进快出(别做 IO)、锁内不再加锁、热点 key 分片、size() 是近似值、预分配容量防扩容风暴

到这里,你已经能完整回答「ConcurrentHashMap 在 JDK7 和 JDK8 中的演进」这道题:从三层结构画到 putVal 的每个分支,从「为什么放弃分段锁」讲到「并发度不再由构造函数决定」,最后落到生产里那些真实的坑。下一站,我们把目光转向 ArrayList 与 LinkedList——同样是高频考点,但考察的是另一套思维:数据结构选型与内存布局。

Comments · 评论