基于:OpenJDK JDK 8u 的 java.util.concurrent.ConcurrentHashMap(对照 JDK 7 分段锁实现与 JDK 9+ 演进)
本文以源码解析为主线,逐段拆解 JDK 8 版 ConcurrentHashMap 的核心实现:字段与 sizeCtl 语义、putVal 全链路、多线程协作扩容、无锁读、基于 CounterCell 的计数、红黑树化与 TreeBin,配合数据布局总图、类关系图、可运行示例与面试 FAQ。解决「源码读了记不住、面试讲不清 put 之后发生了什么」的问题。
目录
- 1.1 一句话定位
- 1.2 线程安全容器定位表
- 1.3 从 JDK 7 到 JDK 8 的演进
- 2.1 数据布局
- 2.2 put 主流程总览
- 2.3 核心字段表
- 2.4 sizeCtl 的语义
- 3.1 类声明与重要常量
- 3.2 构造器与懒初始化
- 3.3 Node 内部类体系
- 4.1 定位与空桶 CAS
- 4.2 MOVED 与协助扩容
- 4.3 桶头加锁写入
- 4.4 树化触发
- 4.5 计数与阈值检查
- 4.6 put 全流程决策图
- 5.1 扩容触发点
- 5.2 多线程分工与区间领取
- 5.3 桶迁移与高低位拆分
- 5.4 ForwardingNode 与无锁读
- 6.1 CounterCell 与伪共享
- 6.2 addCount 与 fullAddCount
- 6.3 size 与 mappingCount
- 7.1 get 为什么可以无锁
- 7.2 remove 与 replace
- 7.3 弱一致性迭代器
- 7.4 复合操作 compute 系列
- 8.1 8 与 6 两个阈值
- 8.2 TreeBin 的读写锁思路
- 8.3 树与链表的双向转换
- 10.1 并发读写与遍历 demo
- 10.2 简易缓存 demo
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 的演进
| 数据结构 | 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 敢放弃成熟的分段锁模型?核心原因:
2. 整体设计总览
2.1 数据布局
JDK 8 的物理结构(桶数组里放四种节点之一,渲染版结构图): 
桶内节点的 hash 字段被复用为类型标记(这是理解一切分派逻辑的钥匙):
| ≥ 0(真实哈希) | Node / TreeNode 链表 | 普通键值节点 |
| -1(MOVED) | ForwardingNode | 该桶已迁移完成,指向新表对应桶 |
| -2(TREEBIN) | TreeBin | 桶内是红黑树(TreeBin 是树根容器) |
| -3(RESERVED) | ReservationNode | computeIfAbsent 的占位节点 |
2.2 put 主流程总览
先建立全局视角,4~5 章再逐段对照源码:
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 核心字段表
// 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 在不同阶段表达四种含义——面试必考点:
| 0 | 表未初始化,且构造时未指定容量(用默认 16) |
| > 0 | 表未初始化 → 初始容量;表已初始化 → 扩容阈值(0.75 * 容量) |
| -1 | 有线程正在进行 initTable 初始化(只有一个线程能 CAS 成功) |
| < -1(如 -2) | 正在扩容:-(1 + 参与扩容的线程数),如 -2 表示已有 1 个线程在迁移 |
// 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 类声明与重要常量
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();
}
两个反直觉点先记住:
3.2 构造器与懒初始化
// 无参构造:什么都不做(关键!表是懒初始化的)
public ConcurrentHashMap() { }
// 指定容量:只把它换算成 2 的幂存进 sizeCtl,仍然不建表
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0) throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap; // 此时 sizeCtl = 目标容量(正数),不是阈值
}
// 表真正建立发生在第一次 put 的 initTable()
initTable 的并发正确性(见 2.4 代码):多个线程同时首次 put,都发现 table == null,但只有一个能把 sizeCtl 从 0 CAS 成 -1——抢到者建表,其余线程 Thread.yield() 自旋等待,等 sizeCtl 变成正的阈值后退出。
关键认知:CHM 没有「构造即分配」的传统,内存占用与启动成本都被推迟到第一次写入。构造传入的 initialCapacity 会被当作「预期含元素数」,内部先加 50% 再取 2 的幂,所以 new ConcurrentHashMap<>(100) 实际容量 256——避免中途扩容。
3.3 Node 内部类体系
五个内部类分工明确(类关系图见第 9 章):
// 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
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:
// 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 时进入核心分支——锁桶头节点:
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,但并不一定真的树化:
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 章详述):
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 多线程分工与区间领取
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)... }
}
}
}
分工模型(务必理解「游标领取」而不是「均分」):
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) 锁桶头,再决定桶内元素去新表的哪个位置:
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn; // 低位链 / 高位链
if (fh >= 0) { // 普通链表
int runBit = fh & n;
// ① 先找到「最后一段 runBit 相同的子链」,直接整段复用
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) { runBit = b; lastRun = p; }
}
if (runBit == 0) { ln = lastRun; hn = null; }
else { hn = lastRun; ln = null; }
// ② 其余节点按 (hash & n) 拆成高低两条链
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0) ln = new Node<K,V>(ph, pk, pv, ln);
else hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln); // ③ 低位链 → 新表原下标 i
setTabAt(nextTab, i + n, hn); // ④ 高位链 → 新表下标 i+n
setTabAt(tab, i, fwd); // ⑤ 旧桶放 ForwardingNode 收工
advance = true;
}
else if (f instanceof TreeBin) {
// 树桶:遍历双向链表同样拆成 lo/hi 两条链,
// 若任一条 ≤ UNTREEIFY_THRESHOLD(6) 则退化成普通 Node 链,
// 否则各自 new TreeBin 保持树结构
...
}
}
}
为什么只看 hash & n 一位就能拆分?因为旧表长度 n 是 2 的幂:
关键认知(扩容数学):桶下标 = (n-1) & hash。扩容到 2n 后,新下标 = (2n-1) & hash = 原下标 或 原下标 + n——多出来的是 hash 二进制里的第 log2(n) 位。所以迁移只需按这一位把一条链拆成「低位链 ln」和「高位链 hn」,分别放到新表的 i 与 i+n 两个桶,节点在新表内不再需要重新哈希(这也是 HashMap 同款优化)。
5.4 ForwardingNode 与无锁读
扩容期间旧表的每个已迁移桶里都放一个 ForwardingNode(-1),它同时承担三个职责:
| 写保护 | 写线程定位到该桶 → 知道桶已搬走 → helpTransfer 加入迁移,避免数据写进旧桶丢失 |
| 读转发 | 读线程 get 到 MOVED 桶 → 顺着 ForwardingNode 里保存的 nextTable 引用去新表找(见 7.1) |
| 迁移进度 | 桶逐个被替换为 ForwardingNode,等于可并行的迁移进度条 |
所有桶搬完后的收尾(最后一个完成的线程):
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 自己的分片,把计数器热点打散。
// 每个分片一个 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 节代码):
// addCount(x, check) 的逻辑:
// ① counterCells 未初始化 且 baseCount CAS 成功 → 完事(无竞争快路径)
// ② 否则按线程探针(ThreadLocalRandom.getProbe)哈希到某个 Cell,CAS 该 Cell
// ③ 该 Cell 也 CAS 失败 → fullAddCount:扩容 Cell 数组(翻倍)或换个 Cell 重试
// 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
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 为什么可以无锁
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
public V remove(Object key) { return replaceNode(key, null, null); }
final V replaceNode(Object key, V value, Object cv) {
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || ... || (f = tabAt(tab, i = (n–1)&hash)) == null)
break; // 桶不存在 → 无需删
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 扩容中先帮忙
else {
boolean validated = false; boolean deleted = false;
synchronized (f) { // 同样锁桶头
if (tabAt(tab, i) == f) {
validated = true;
if (fh >= 0) { // 链表删除:prev.next = e.next
...
}
else if (f instanceof TreeBin) {
// 树删除:removeTreeNode(会维护双向链表 + 红黑树平衡),
// 桶内若只剩少量节点不主动转链表(等扩容时再退化)
}
}
}
}
}
// 删除成功 → addCount(-1L, -1) 回退计数
}
replace(key, oldValue, newValue) 走同一个 replaceNode(key, newValue, cv=oldValue) 重载:在锁内校验旧值,实现原子的条件替换。remove/replace 与 put 共用同一套桶锁,所以「读-改-写」组合(如 containsKey 后 put)之间没有原子性——需要原子复合请用 7.4 的 compute 系或 putIfAbsent。
7.3 弱一致性迭代器
JDK 8 的迭代器(KeyIterator/ValueIterator/EntryIterator)基于 Traverser 设计,遍历规则:
// 语义:逐桶前进;遇到 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 需要占位节点防止递归计算:
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 则删除占位
}
}
两个设计细节很值钱:
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):
| 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 简化读写锁:
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 树与链表的双向转换
链表 ──(桶内节点≥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 可运行):
// 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());
}
}
$ javac ChmConcurrentDemo.java && java ChmConcurrentDemo
写入完成后 size = 80000
mappingCount = 80000
get 不存在的 key = null
putIfAbsent 返回旧值 = 1
computeIfAbsent 结果 = 700
迭代期间看到的条目 >= 80000 # 数值不确定(弱一致),但不抛 ConcurrentModificationException
迭代结束后 size = 180000
10.2 简易缓存 demo
用 CHM 实现线程安全、惰性过期、并发加载只执行一次的本地缓存(模拟常见中间件缓存层写法):
// 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
}
}
$ 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. 横向对比总表
| 锁粒度 | 整表 | 整表 | 16 分段 | 单桶(冲突时) | 无 |
| 无冲突写入 | 全程锁 | 全程锁 | 锁分段 | 一次 CAS | CAS 都不用(单线程) |
| 读 | 锁 | 锁 | 无锁(volatile) | 无锁 | 无锁 |
| 扩容 | 全表锁后搬 | 全表锁后搬 | 段内搬 | 多线程协作搬 | 单线程搬 |
| 计数 | size 变量 | — | 段 count 和 | CounterCell 分片 | modCount+size |
| null | 不允许 | 取决于包装的 Map | 不允许 | 不允许 | key/value 均可 |
| 迭代 | fail-fast | fail-fast | 弱一致 | 弱一致 | fail-fast |
| 适用 | 遗留代码 | 快速小规模包装 | 已被 8 取代 | 默认选择 | 无线程共享 |
决策建议:
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. 参考资料与延伸
- OpenJDK JDK 8 ConcurrentHashMap.java(本文源码依据,源码里 Doug Lea 的长注释值得逐行读)
- OpenJDK JDK 8 LongAdder.java(CounterCell 计数的思想源头)
- OpenJDK JDK 7 ConcurrentHashMap.java(分段锁实现对照)
- ConcurrentHashMap (Java SE 8 官方文档)
- 《Java 并发编程的艺术》方腾飞 等:第 6 章 ConcurrentHashMap 详解
- 《深入理解 Java 虚拟机》周志明:锁优化与并发电面背景知识
网硕互联帮助中心

评论前必须登录!
注册