JAVA · Vol.I · DAY 04 · 集合框架源码
ConcurrentHashMap 演进:JDK7 分段锁 → JDK8 CAS+synchronized
开场:这道面试题,考点地图
这道题是 Java 并发面试的「必考大题」,通常以连环追问的形式出现:
- 「HashMap 线程安全吗?不安全会怎样?」——先确认你知道
HashMap的问题 - 「Hashtable 线程安全,为什么不用?」——再确认你知道全表锁的代价
- 「ConcurrentHashMap 1.7 和 1.8 有什么区别?」——核心:
Segment分段锁 vsCAS + 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 倍
为什么不能直接上 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 的并发性能
JDK7 结构:Segment 与 HashEntry
三层结构
JDK7 的 ConcurrentHashMap 是「Segment 数组 → 段内 HashEntry 数组 → 链表」的三层结构。默认 16 个 Segment,每个 Segment 内部是一张独立的 HashEntry 哈希表,互不相干。
两个核心类,字段必须背下来
// 外层: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.next 是 volatile,配合 value 的 volatile,get 才能在完全不加锁的情况下安全遍历链表。
- 并发度 =
segments.length,默认 16,由构造参数concurrencyLevel决定且不可变 - 每个 Segment 自带一把 ReentrantLock:
put只锁一个段,其他 15 个段可并行写 - get 无锁靠 volatile:
HashEntry.value与HashEntry.next都是 volatile - 段内扩容独立进行(
rehash),不影响其他段
JDK7 流程:put / get / size 源码级
put:先 tryLock,失败再「扫描 + 预创建」等锁
JDK7 的 put 很有意思:先不抢锁。第一步 tryLock(),成功直接进入临界区;失败则调用 scanAndLockForPut()——在不加锁的情况下先遍历一遍链表,确认 key 是否存在、并预创建好新节点,然后自旋 + 阻塞等待锁(多核机器最多自旋 MAX_SCAN_RETRIES = 64 次)。这样做的目的是把「找位置、造节点」这些耗时操作挪到锁外,缩短持锁时间。
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.value 和 next 都是 volatile,读到的永远是「某个时刻的完整状态」。
size:先乐观统计两遍,不行就锁全表
// 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 本质是「先乐观探测、失败再悲观加锁」——你把锁外预计算这个思路讲出来,面试官就知道你读过源码而不是背过博客。
- put:tryLock 失败 → scanAndLockForPut(锁外扫描 + 预创建节点,最多自旋 64 次)→ 头插法 → 段内 rehash(2 倍)
- get:无锁,纯 volatile 读
- size:两遍无锁统计(RETRIES_BEFORE_LOCK=2),modCount 不稳则锁全表兜底——这就是 1.8 要解决的痛点之一
为什么 JDK8 放弃分段锁?
这是全文最高频的追问,答案要拆成四个维度,缺一不可:
| 维度 | JDK7 Segment | JDK8 的问题本质 |
|---|---|---|
| 锁粒度 | 1 个段 = 默认 1/16 的数据,写一个 key 要锁 1/16 的表 | 粒度还是太粗;JDK8 锁桶头节点 = 1/N,空桶连锁都不用 |
| 内存开销 | 每个 Segment 是一个 ReentrantLock(AQS 的 state + 等待队列),至少 16 个锁对象 | JDK8 锁对象就是桶头节点本身,零额外锁对象,小 map 不浪费 |
| size 统计 | 先无锁统计两遍,不一致就锁全部 Segment | JDK8 用 baseCount + CounterCell 分条计数,O(1) 且几乎无锁 |
| 并发度 | 并发度 = segments.length,构造后固定,扩容也不变 | 并发度 = table.length,扩容翻倍、动态增长 |
还有一个经常被忽略的工程因素:synchronized 在 JDK6+ 经历了偏向锁、轻量级锁、自旋、锁消除/锁粗化等持续优化,性能已不输 ReentrantLock,而且使用更简单、语义更清晰。官方没有必要再维护一套自研的分段锁体系——用 JVM 自带的监视器锁锁住「恰好需要的那个桶」,就是最经济的方案。
把 Segment 锁想成分库分表:拆 16 个库,每库一把锁,写操作只碰自己那个库——但库的数量是写死的,扩容不增加库数,统计全局行数还得把 16 个库挨个查一遍甚至锁一遍。JDK8 的思路是行级锁 + 乐观锁:锁只落在被写的那一行(桶),空行插入用乐观重试,全局计数用分片计数器。你给 MySQL 做过分库分表、遇到过「再分就管不过来」的瓶颈,就完全能理解为什么 JDK 团队要改。
- 放弃分段锁 = 锁粒度 + 内存 + size 统计 + 并发度四个问题的综合解
- synchronized 经 JDK6+ 优化后性能足够,实现更简单、更可靠
- 「并发度不再由构造函数决定」是 JDK8 的招牌答案,务必背熟
JDK8 结构:Node 数组与关键字段
一张图看懂新结构
Node 与 HashMap.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),这也是「预估容量防多次扩容」的依据。
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.val与Node.next都是 volatile——get 无锁的根基sizeCtl三态:负数(初始化/扩容中)、0(默认)、正数(阈值或预置容量)- 负数 hash 是系统标记(MOVED/TREEBIN/RESERVED),与真实 hash 用 HASH_BITS 隔离
- 数组懒加载:无参构造不建表,首表 16;带参构造 sizeCtl 预置容量
JDK8 putVal:方法级拆解
这一站是全文的「题眼」。先把流程图画出来,再逐行读源码。
源码逐段解读
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 计数并可能触发扩容
锁桶头节点:synchronized 的「逆袭」
锁的是什么?
JDK8 的锁对象就是桶的头节点 Node(树化后是 TreeBin 根节点)。它不是一个专门的锁对象,而是「恰好要操作的那个桶的第一个元素」——零额外内存。这是与 Segment 最本质的区别:Segment 需要 16 个 ReentrantLock 对象(每个都是一套 AQS 状态机),而 JDK8 的锁随桶存在,桶空就没有锁。
为什么敢用 synchronized?
- JDK6+ 的锁升级路径:偏向锁 → 轻量级锁(CAS 自旋)→ 重量级锁(只有竞争激烈才升级),绝大多数桶操作在轻量级锁阶段就完成了。
- JIT 优化:锁消除、锁粗化、自适应自旋,由 JVM 按运行时特征调整,
ReentrantLock享受不到这些。 - 自动释放:异常也不会漏锁(monitor exit 由字节码保证),不用手写 finally。
- 语义简单:JDK 源码注释明确表示,现代 JVM 上 synchronized 已足够快,没必要维护一套自研锁体系。
别忘了锁的边界
- 锁只保护当前桶的读写:同桶写串行,不同桶完全并行。
- 扩容迁移时锁的也是旧桶的头节点,迁移完换成
ForwardingNode释放。 - 热点 key 仍串行:极端场景(例如秒杀同一个 key),同一桶的写还是排队——锁粒度到桶,不是到 key。这是生产上需要自己绕开的坑(见第 14 站)。
- 锁对象 = 桶头节点(或 TreeBin 根),零额外锁内存;空桶根本没有锁
- synchronized 靠锁升级 + JIT 优化追上 ReentrantLock,且实现更简单
- 锁粒度到桶(1/N),且随扩容增长;热点 key 单桶仍串行是边界
树化与退化:8 / 64 / 6 三个数字
树化的两个条件,缺一不可
treeifyBin(tab, i) 内部是这么判断的:
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 的滞回区间防抖。你给线程池配过 corePoolSize 与 maxPoolSize,就理解了「两个阈值之间必须留缓冲」的工程直觉。
- 树化:链 ≥ 8(binCount ≥ 8)且数组 ≥ 64;数组 < 64 先扩容(tryPresize 2 倍)
- 8 的来历:0.75 负载因子 + 泊松分布,长度 8 的概率约千万分之六,正常不会自然达到
- 6 与 8 差 2:滞回区间防树↔链抖动;退化发生在扩容拆分,remove 不主动退化
扩容:多线程协助 transfer
JDK8 的扩容是多线程协作的:发起扩容的线程只负责一部分桶,其余桶由「路过的」线程顺手帮忙搬。协作的媒介就是 ForwardingNode(hash = MOVED = -1)。
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 = nextTable,nextTable = null,sizeCtl = 1.5n(= 0.75 × 2n)。
其他线程遇到扩容怎么办
- put:看到
MOVED→helpTransfer(tab, f):校验 resizeStamp 一致、sizeCtl < 0、transferIndex > 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 去新表,不等待
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做缓存行填充,避免相邻分条互相「伪共享」拖慢性能。
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 累加 count | baseCount + 遍历 CounterCell 分条 |
| 一致性保障 | modCount 对比,不稳则锁全部 Segment | 无锁,O(1) 近似 |
| 最坏代价 | 全表冻结(锁 16 个段) | 遍历 counterCells(长度很小) |
| 返回值语义 | 尽力精确,但可能已过期 | 文档明示:并发更新时只是估计值 |
CounterCell 就是秒杀库存分片 / 数据库分片计数器:单个热点计数器被拆成 N 份,各线程各写各的分片,汇总时再求和——把「单点 CAS 争用」变成「多点并行」。Caffeine、Guava 的并发计数都借鉴了 LongAdder 这套思路。
不能依赖。并发写入下 size() 可能返回旧值;需要精确语义时,要么用外部计数(自维护 AtomicLong),要么用锁保护复合操作。这是第 14 站的坑 4,先记住结论。
- JDK8 计数 = baseCount(CAS)+ CounterCell[](分条,LongAdder 思路),无锁 O(1)
- @sun.misc.Contended 防伪共享;size() 是 int 截断版,mappingCount() 返回 long
- size() 是近似值:并发下可能不准确,别拿它做精确业务判断
get 无锁:可见性、null 与弱一致性
为什么 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) {
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,可能漏掉并发新增——别在遍历时做「必须精确」的统计
并发度:从 concurrencyLevel 到 table.length
JDK7:并发度写死,扩容也不涨
JDK7 的并发度 = segments.length,由构造参数 concurrencyLevel(默认 16)向上取 2 的幂得到,构造之后永不变化——哪怕数据涨到几百万,并发度还是 16。锁的「数量」跟不上数据的「规模」,这是架构层面的天花板。
JDK8:并发度 = table.length,随扩容翻倍
JDK8 没有独立的「锁数组」了,锁就是桶头节点,所以并发度天然等于桶数组长度:初始 16,扩容到 32、64……并发度同步翻倍。构造参数 concurrencyLevel 虽然保留(为了兼容老 API),但语义已变:
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 就是当前桶数组长度。
总对比表(背诵版)
| 特性 | Hashtable | synchronizedMap | JDK7 CHM | JDK8 CHM |
|---|---|---|---|---|
| 数据结构 | 数组 + 链表 | 包装 HashMap | Segment[] + HashEntry[] + 链表 | Node[] + 链表 + 红黑树 |
| 锁粒度 | 全表 1 把锁 | 全表 1 把锁 | Segment(1/16) | 桶头节点(1/N)+ 空桶 CAS |
| 锁实现 | synchronized 方法 | synchronized 包装 | ReentrantLock(继承) | synchronized 块 |
| get 加锁 | 是 | 是 | 否(volatile) | 否(volatile) |
| 并发度 | 1 | 1 | segments.length(固定,默认 16) | table.length(随扩容翻倍) |
| 扩容 | 全表重哈希 | 全表重哈希 | 段内独立 rehash(2 倍) | 多线程协作 transfer(2 倍) |
| 插入方式 | 头插 | — | 头插(锁内安全) | 尾插 |
| size 统计 | 全表锁 | 全表锁 | 两遍扫描,最坏锁全表 | baseCount + CounterCell,近似 |
| null 键值 | 不允许 | 不允许 | 不允许 | 不允许 |
| 迭代器 | fail-fast | fail-fast | 弱一致 | 弱一致 |
「并发度 = 数组长度」这句话别只说一半:同一桶的写仍然串行、扩容期间部分桶被锁,所以它是「理论上限」,不是「实测吞吐」。面试官追问「那并发度是无限的咯?」时,答出这两个边界才算完整。
- JDK7:并发度 = segments.length,构造固定,扩容不涨
- JDK8:并发度 = table.length,扩容翻倍;concurrencyLevel 仅作初始容量下限(兼容)
- 理论并发度 N 的两个边界:同桶写串行、扩容期部分桶被锁
生产实战:十个坑与四个最佳实践
坑 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,要么用原子方法。
四个最佳实践代码
ConcurrentHashMap<String, Long> stats = new ConcurrentHashMap<>();
stats.merge("error", 1L, Long::sum); // 不存在→1,存在→+1,全程原子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;// 已知将写入 100_000 条:new CHM(100_000),sizeCtl 自动预置合适首表容量
ConcurrentHashMap<String, Object> cache =
new ConcurrentHashMap<>(100_000);// 一个 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 保持不可变
面试追问速答
把高频追问 + 一句话参考回答整理成表,考前 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 · 评论