【多线程】---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,但只有一个能把 sizeCtl0 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」,分别放到新表的 ii+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 共用同一套桶锁,所以「读-改-写」组合(如 containsKeyput)之间没有原子性------需要原子复合请用 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. 参考资料与延伸

相关推荐
不才不才不不才2 小时前
Spring 源码系列(27): @Transactional 七大失效场景与源码归因
java·后端·spring
随遇而安zx2 小时前
【多线程】---线程池 ThreadPoolExecutor 原理与调优
java·线程池·多线程
抠脚小弟2 小时前
WebSocketServer 使用详解:从入门到实战
java·spring·websocketserver
随便做点啥2 小时前
32卡64G-910B4-16后端集群部署报告
java·大数据·运维
Wang's Blog2 小时前
Java框架快速入门: Spring Security+OAuth2之字段验证与自定义验证注解实战
java·算法·spring
CodeStats2 小时前
【Java 类加载器】Java 类加载器完整体系深度拆解(上):从 JVM 启动到双亲委派模型
java·开发语言·jvm·classloader·双亲委派
带多刺的玫瑰2 小时前
Leecode#29刷题之两数相除
java·python·算法
LuTshoes3 小时前
spring ai 实战-MCP
java·人工智能·spring
CodeStats3 小时前
【Java类加载器】Java 类加载器完整体系深度拆解(下):自定义加载器实战与 SPI 破坏双亲委派(MySQL 驱动揭秘)
java·开发语言·jvm·classloader·双亲委派