ConcurrentHashMap:JDK 8 为什么去掉了分段锁?
一句话结论(30s)
JDK 8 去掉分段锁是因为固定 16 个 Segment 的并发度天花板太低,不同桶一旦落到同一 Segment 就串行。关键设计是”桶头节点 synchronized + 空桶 CAS 无锁写”,并发度提升到桶数量(无上限);配合 ForwardingNode 路标让读操作对扩容透明、多线程 CAS 认领区间协助扩容。权衡是锁粒度更细、迁移协调更复杂,但换来了可扩展的并发度和单桶性能。
核心原理(2min)
put 时空桶用 casTabAt 无锁设置桶头,非空则 synchronized 锁桶头节点在链表/红黑树插入。扩容时各线程按 stride 通过 CAS 递减 transferIndex 认领区间迁移,已完成桶放 ForwardingNode(hash=MOVED)指向新表——get 遇到即跳新表,put 遇到则 helpTransfer 帮忙再执行。size 用 CounterCell 数组分散计数。
想一想:CHM 为什么只锁桶头节点、不锁整张表?——锁的粒度直接决定并发上限:锁整表时不同桶的写也要互相排队,锁到单桶则不同桶天然并行,最大并发度从「1」或「固定 16」抬升到「桶数量、无上限」。这正是 JDK 8 敢去掉分段锁的底气。
底层深入(5-10min)
JDK 7 的 Segment 分段锁
// JDK 7
final Segment<K,V>[] segments; // 默认 16 个 Segment
// 每个 Segment 继承 ReentrantLock,内部是一个小 HashMap
Segment 是 ReentrantLock 的子类。put 时先定位 Segment,再对 Segment 加锁。并发度固定 = Segment 数量(默认 16)。超过 16 个线程并发写不同桶但落在同一 Segment → 串行。
想一想:为什么固定 16 个 Segment 会成为「天花板」?——因为并发度被硬编码成 16,且不同桶只要哈希到同一个 Segment 就得共享一把锁;Segment 数量要么太少撑不起并发、要么构造时写死无法随数据量增长。这个死结让 JDK 8 彻底重写。
JDK 8 的桶级锁
先看三个底层原子读写封装——所有”CAS 定位桶”都靠它们:
// 原子读桶头(acquire 语义,保证后续读到的 next 链可见)
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getReferenceAcquire(tab, ((long)i << ASHIFT) + ABASE);
}
// 原子 CAS 写桶头:仅当当前值 == c 时才替换成 v,返回是否成功
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSetReference(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
// hash 字段的三种特殊编码
static final int MOVED = -1; // forwarding node 的 hash:桶已迁移
static final int TREEBIN = -2; // 红黑树桶根的 hash
static final int RESERVED = -3; // 临时占位(computeIfAbsent 等用)
put 的完整核心逻辑在 putVal,桶级锁就在这里:
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == 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; K fk; V fv;
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)))
break; // 空桶插入无需加锁
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent // 不加锁先检查首节点
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
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;
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) {
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;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
为什么这么写:空桶直接 casTabAt 无锁 CAS 设头节点,失败就回到 for 循环重试,一次插入不必抢任何锁;非空桶 synchronized (f) 锁的是桶头节点对象而非整张表,不同桶互不干扰,所以并发度 = 桶数量、无上限。加锁后再 if (tabAt(tab, i) == f) 二次校验,防止加锁前该桶已被扩容或替换,保证锁到的还是原桶。看到 hash == MOVED 就调 helpTransfer 帮忙扩容再重试,写操作对扩容也不阻塞。
想一想:空桶为什么用 CAS 抢头节点、而不是直接抢锁?——因为 CAS 是无锁乐观写:大多数新元素落到空桶,一次 CAS 成功即返回、失败回到 for 重试,省掉了「写个空桶也要先抢锁」的开销;这也是「桶级锁」比「每桶一把锁」更轻量的关键。
链表升级红黑树:treeifyBin
putVal 末尾 binCount >= TREEIFY_THRESHOLD(8)时触发树化,真实源码:
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n;
if (tab != null) {
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
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));
}
}
}
}
}
为什么这么写:链表长到 8 不一定立刻树化,先判断 tab.length < MIN_TREEIFY_CAPACITY(64)——表还小时直接 tryPresize 扩容,因为哈希冲突的根因往往是桶太少,而不是真的需要树。树化同样 synchronized (b) 锁桶头,在锁内把链表节点逐个包成 TreeNode、最后整体 setTabAt 替换成 TreeBin,读操作不会看到半成品。
想一想:链表为什么要长到 8 才考虑树化、而且先看表大小?——因为树化本身有成本(节点包成 TreeNode、还要维护红黑树平衡),短链表线性查找反而更快;而冲突多的根因常是桶太少,先把表扩容摊开元素往往比树化更划算,所以 table.length < 64 时宁可先扩容。
多线程协助扩容
扩容不是单线程迁徙——所有线程都参与:
扩容时 table 状态:
[桶0] [桶1] ... [桶k] [ForwardingNode] [ForwardingNode] ... [ForwardingNode]
↑ 待迁移 ↑ 已迁移 → 新来的读/写自动跳转到新表
transferIndex(volatile int)从 table.length 递减,每个线程通过 CAS 认领一段区间:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < 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) { // 尽力应对 OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // 提交 nextTab 前确保扫完一遍
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSetInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSetInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // 提交前复查一遍
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // 已被处理
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
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);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
// 红黑树 TreeBin 分支:同样按 hash & n 拆成 lo/hi 两棵子树,此处从略
}
}
}
}
}
为什么这么写:stride 是每个线程一次认领的区间大小,transferIndex 从 n 递减、靠 compareAndSetInt CAS 认领 [nextBound, nextIndex),多线程并发认领互不重叠,这就是”协助扩容”的调度核心。迁移链表时只用 hash & n 一位把节点分成 ln/hn 两条链,setTabAt 写入新表后立刻把旧桶 setTabAt(tab, i, fwd) 换成路标,读写因此对新旧表始终可见。空桶直接 casTabAt(tab, i, null, fwd) 打成 ForwardingNode 免加锁;最后一个认领完区间的线程把 table 切到 nextTab。
想一想:为什么要让所有线程都来「帮忙」扩容,而不是单线程扛?——单线程迁移大表时其他写线程只能干等或阻塞,可能造成系统级停顿;按 stride 区间 CAS 认领、互不重叠,能把一次大迁移摊薄成「人人搭把手」,让写操作对扩容也不阻塞。
读操作不阻塞:遇到 ForwardingNode(hash == MOVED),通过其 nextTable 字段直接去新表查找。
写操作主动帮忙:put 发现当前桶是 ForwardingNode → 调 helpTransfer() → 认领一段区间帮扩容 → 自己的 put 在新表执行。
ForwardingNode 的协调作用
// ForwardingNode 是一个"路标",本身不存数据
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null); // hash = MOVED(-1)
this.nextTable = tab;
}
}
put 撞上 ForwardingNode 时走的 helpTransfer:
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length) << RESIZE_STAMP_SHIFT;
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
transferIndex <= 0)
break;
if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
为什么这么写:ForwardingNode 的 hash = MOVED(-1)、nextTable 指向新表——任何线程看到桶头是它,就立刻知道”这桶已迁走、去新表找”。get 在 find 里直接用 nextTable 按位查找,全程无锁;put 则走 helpTransfer,用 sizeCtl 做 CAS +1 认领一份迁移任务,transfer 干完回到 nextTab 继续自己的插入,所以写操作对扩容不阻塞、读操作对扩容透明。
想一想:读操作凭什么能对扩容「透明」?——靠 ForwardingNode 这个路标:旧桶一迁走就留下 hash=MOVED、nextTable 指向新表的节点,get 遇到它按 nextTable 继续找,全程无锁,读者根本感知不到扩容正在发生。
addCount / CounterCell:分散计数
size() 不再全局加锁统计,而是把计数拆到一个 baseCount + 一个 CounterCell[]:
@jdk.internal.vm.annotation.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
private final void addCount(long x, int check) {
CounterCell[] cs; long b, s;
if ((cs = counterCells) != null ||
!U.compareAndSetLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell c; long v; int m;
boolean uncontended = true;
if (cs == null || (m = cs.length - 1) < 0 ||
(c = cs[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSetLong(c, CELLVALUE, v = c.value, v + x))) {
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
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) << RESIZE_STAMP_SHIFT;
if (sc < 0) {
if (sc == rs + MAX_RESIZERS || sc == rs + 1 ||
(nt = nextTable) == null || transferIndex <= 0)
break;
if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSetInt(this, SIZECTL, sc, rs + 2))
transfer(tab, null);
s = sumCount();
}
}
}
size() 实际求和逻辑:
final long sumCount() {
CounterCell[] cs = counterCells;
long sum = baseCount;
if (cs != null) {
for (CounterCell c : cs)
if (c != null)
sum += c.value;
}
return sum;
}
为什么这么写:先 CAS 累加 baseCount,成功就直接返回——绝大多数无竞争场景只走一次 CAS、不创建数组。竞争失败才按 ThreadLocalRandom.getProbe() 哈希到某个 CounterCell 上继续 CAS,把全局争抢分散到多个槽位,这是 LongAdder/Striped64 同款的”分散计数”。@Contended 给每个 Cell 加缓存行填充避免伪共享;sumCount 无锁相加 baseCount + 所有 Cell,所以 size() 返回的是近似值。addCount 末尾顺带检查 s >= sizeCtl 触发 transfer 扩容,计数和扩容在同一处协调。
想一想:size() 为什么不直接用一个 long 加锁统计?——因为全局计数是高频热点,加锁或单个 volatile CAS 会让所有写线程争抢同一缓存行、成为吞吐瓶颈;拆成 baseCount + CounterCell[] 把争抢分散到多个槽位,@Contended 再消除伪共享,换来近似值但吞吐大幅提升。
JDK 7 vs JDK 8 对比
| JDK 7 (Segment) | JDK 8 (桶级锁) | |
|---|---|---|
| 锁粒度 | Segment(默认 16) | 单个桶头节点 |
| 最大并发度 | 16(固定) | table.length(无上限) |
| 扩容 | 单线程 | 多线程协助 |
| 读操作 | 无锁(volatile 读) | 无锁(volatile 读 + ForwardingNode 重定向) |
| size | 三次不加锁统计+一次加锁兜底 | CounterCell 数组分散计数 |
JDK 8 的 ConcurrentHashMap 是一个全新的设计——从基于”固定分段”的粗粒度锁升级为基于”桶头节点”的细粒度锁 + CAS 无锁写空桶 + 多线程协助扩容。并发度和扩展能力得到了质的飞跃。
章末提问
-
为什么 JDK 8 要用「桶头节点 synchronized + CAS」替换 JDK 7 的 Segment 分段锁? —— 结论:为了突破固定并发度、把锁粒度降到单桶。因为 Segment 数量固定 16,不同桶落到同一 Segment 就串行;桶级锁让并发度 = 桶数量(无上限),空桶还能 CAS 无锁写,单桶性能也更好。
-
空桶插入为什么用 CAS 而不直接抢锁? —— 结论:为了省掉无谓的锁开销。因为多数插入落在空桶,一次 CAS 成功即返回、失败回退重试,比「先拿锁再写」轻量得多;这也是桶级锁比「每桶一把锁」更优的关键。
-
读操作为什么能在扩容期间保持无锁且不出错? —— 结论:因为 ForwardingNode 路标让读对扩容透明。桶迁走后留下
hash=MOVED、nextTable指向新表,get 遇到它就跳新表继续查,全程无锁也始终能定位到数据。 -
size() 为什么是近似值而不是精确值? —— 结论:为了用分散计数换吞吐。
baseCount + CounterCell[]把全局争抢摊到多个槽位、@Contended消除伪共享;代价是sumCount无锁求和无法保证瞬时精确。 -
多线程协助扩容怎么避免重复迁移同一个桶? —— 结论:靠
transferIndex的 CAS 递减认领区间。每个线程用compareAndSetInt抢一段不重叠的[nextBound, nextIndex),迁完把桶打成 ForwardingNode,别人看到 MOVED 就跳过,天然不重复。