承接的问题
5.1 看清了桶里能放什么:普通链表、TreeBin、ForwardingNode、ReservationNode。结构只是静态布局,真正决定并发行为的是每个操作走哪条路径、在哪一步取锁、在哪一步放弃锁去帮忙扩容。
本节回答四个问题:
get为什么完全不加锁,读到迁移中的旧表怎么办;putVal在空桶、链表桶、树桶、迁移桶里分别做什么;- 扩容时后来的写者怎样从「被阻塞」变成「一起搬」;
size()为什么只是一个估计值,CounterCell怎样把计数争用摊开。
所有源码来自本机 Temurin 25.0.5+8 的 src.zip;分支日志来自一个只读反射探针,它在每次操作前后读 table、sizeCtl 和桶头,不修改 JDK 代码。
无锁读:get 不碰锁
get 的全部逻辑只有十几行:
// JDK 25 源码摘录,已删减:ConcurrentHashMap.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)
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;
}
四步:
table为null或长度为 0,直接返回null,不初始化表。- 桶头 hash 等于目标 hash,比较 key 后返回
e.val。 - 桶头 hash 为负,说明是特殊节点,交给多态的
find:ForwardingNode.find去新表找,TreeBin.find走树或退回链表,ReservationNode.find返回null。 - 普通链表,顺着
volatile next往下走。
tabAt 不是普通数组读。JDK 25 里它是 U.getReferenceAcquire(tab, ...),配对的 setTabAt 是 U.putReferenceRelease,casTabAt 是 U.compareAndSetReference。这里的 U 是 jdk.internal.misc.Unsafe,属于 JDK 内部实现,不是业务代码可用的 API;业务代码需要同样的语义时用卷一九章讲的 VarHandle。
读路径不加锁,靠的是「已发布的节点不会再改 next 指向的下一个节点」。删除和改值都在桶锁里做,但读者可能看到删除前的旧值;这是 CHM 弱一致语义的一部分,5.3 展开。
验证「不取锁」最直接的办法,是让另一个线程持有桶头监视器,再调用 get。如果 get 需要同一把锁,它会一直等到监视器释放。
== branch: get while bin monitor is held ==
holder acquired monitor of alpha bin
main holds monitor=false holder state=WAITING; get returned=1 in 1625 ns
holder state=WAITING 表示持有监视器的线程正等在 CountDownLatch 上,监视器没有释放;主线程的 get 仍然在 1625 纳秒内返回。这个数字是单次测量,只用于证明没有阻塞,不作为性能结论。
putVal:先 CAS 空桶,再锁桶首
putVal 是一个大循环,按桶的状态分成几条互斥的分支。先看前半段:
// JDK 25 源码摘录,已删减:ConcurrentHashMap.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; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent // check first node without acquiring lock
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
分支顺序不能换:
- 表未初始化:
initTable用 CAS 把sizeCtl从 0 或正数改成 -1,赢的线程建表并把sizeCtl设成 0.75 倍阈值,输的线程Thread.yield()后重试。 - 空桶:
casTabAt把新Node直接挂上去。这是 put 的最快路径,没有监视器,也没有链表遍历。 - 桶头是
MOVED:当前表已经迁移到新表,调用helpTransfer拿到nextTable,循环回到开头,后续操作都在新表上做。 onlyIfAbsent且桶头就是目标键:不取锁直接返回旧值。putIfAbsent命中已有键时走这条。
如果桶非空、又不是迁移节点,就进入加锁段:
// JDK 25 源码摘录,已删减:ConcurrentHashMap.putVal 加锁段
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");
}
}
锁的是桶头对象 f,不是整个 map。拿到锁后必须再检查一次 tabAt(tab, i) == f:等锁期间桶头可能已经被别的线程换掉(比如树化、迁移),此时这次加锁白拿,循环回去重试。
链表桶尾插或改值,binCount 记录遍历长度;树桶调用 TreeBin.putTreeVal,binCount 直接设 2;遇到 ReservationNode 说明有另一个 computeIfAbsent 正在这个空桶里执行用户函数,当前线程不能等(会死锁),直接抛 IllegalStateException("Recursive update")。加锁段结束后,如果 binCount >= TREEIFY_THRESHOLD 就尝试树化,最后 addCount(1L, binCount) 计数并检查扩容。
探针把初始化、空桶 CAS、链表桶、onlyIfAbsent 快速返回和 MOVED 转发这几条分支走了一遍(树桶分支见 5.1,ReservationNode 分支见 5.3):
== branch: initTable -> casTabAt ==
before put: table=null sizeCtl=0 path=initTable
after put: table.length=16 sizeCtl=12 bucket[7]=Node hash=92910807
second put key=d0 bucket[12]: path=casTabAt; after head=Node
== branch: synchronized list / onlyIfAbsent fast path ==
colliding key=c14 bucket=7 (alpha bucket=7)
before collision put: path=synchronized list
after collision put: bucket head=Node chainLen=2 path(existing alpha)=synchronized list
putIfAbsent(alpha) path=onlyIfAbsent fast return returned=1 valUnchanged=1
alpha 和 c14 的 spread 结果都落在 7 号桶,第二个 put 因此走 synchronized 链表追加,chainLen=2。putIfAbsent 命中桶头时走无锁快速返回,返回旧值 1,没有覆盖成 99。
MOVED 与 helpTransfer
helpTransfer 只在桶头是 ForwardingNode 且 nextTable 非空时生效:
// JDK 25 源码摘录,已删减:ConcurrentHashMap.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;
}
三个退出条件分别是:帮助线程数到上限 rs + MAX_RESIZERS、只剩最后一个收尾线程 rs + 1、没有可领取的迁移区间 transferIndex <= 0。否则 CAS 把 sizeCtl 加 1,表示「我也加入这次扩容」,然后调用 transfer 搬一段桶。
探针手工把一个 ForwardingNode 放进旧表桶里,模拟「这个桶已经迁走」:
== branch: helpTransfer (manual forwarding node) ==
before put: path=helpTransfer(MOVED) oldBucket[30]=ForwardingNode
after put: oldBucket still=ForwardingNode; get(gamma)=30
旧表桶仍是 ForwardingNode,新值写进了 nextTable 对应桶,随后 get(gamma) 顺着 ForwardingNode.find 在新表里找到 30。真实扩容中旧表最终会被整体替换,但「旧桶只留转发节点」这个中间状态与探针一致。
扩容协作:把等待变成搬运
transfer 把旧表按区间切分,每个线程通过 CAS transferIndex 领取一段,步长至少 MIN_TRANSFER_STRIDE = 16。领取到区间后逐桶加锁搬运;空桶直接 CAS 放一个 ForwardingNode,已迁移的桶跳过,链表和树按 hash & n 拆成低位链和高位链,分别放到新表的 i 和 i + n。
这解释了为什么 put 遇到 MOVED 不是阻塞等待,而是调用 helpTransfer:扩容线程越多,搬得越快,后来的写者不会因为一次扩容被长时间挂起。代价是 put 的代码路径变长,扩容期间缓存行争用也更复杂;线上观察扩容行为要看 JFR 与 GC 日志,卷三第 3 章展开。
size 统计:baseCount 加一排 Cell
计数没有用一把锁,也没有只用一个 AtomicLong。addCount 先试 baseCount 的 CAS,失败后分散到 CounterCell 数组:
// JDK 25 源码摘录,已删减:ConcurrentHashMap.addCount
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();
}
}
}
CounterCell 和 sumCount 本身很短:
// JDK 25 源码摘录,已删减:ConcurrentHashMap.CounterCell 与 sumCount
@jdk.internal.vm.annotation.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
final long sumCount() {
CounterCell[] cs = counterCells;
long sum = baseCount;
if (cs != null) {
for (CounterCell c : cs)
if (c != null)
sum += c.value;
}
return sum;
}
@Contended 让每个 Cell 独占缓存行,避免相邻 Cell 互相伪共享。数组长度是 2 的幂,线程用 ThreadLocalRandom.getProbe() & (length - 1) 选槽;槽为空就建 Cell,CAS 失败就 fullAddCount 扩容或换 probe。cellsBusy 是建数组/建 Cell 的自旋锁,不参与日常加减。
本机 8 个线程各插入 5 000 个不同键,结束后读内部字段:
== addCount / CounterCell ==
size=40000 baseCount=5163 counterCells=4 nonZeroCells=4 cellSum=34837
baseCount + cellSum = 40000, table.length=131072 sizeCtl=98304
check sumCount == size: true
baseCount 和 Cell 的分配比例每次运行都不同,取决于线程 probe 和竞争时序;baseCount + cellSum 与 size() 在写线程全部退出后一致。
但 sumCount 不是原子快照:它先读 baseCount,再逐个读 Cell。读第一个 Cell 和读最后一个 Cell 之间,写线程可能已经改了后面的 Cell,读者看到的是不同时刻拼起来的值。并发增删时 size() 可能偏小也可能偏大,isEmpty() 用 sumCount() <= 0 也只是近似判断。size() 返回 int 并做上限截断,超过 Integer.MAX_VALUE 要改用 mappingCount()。
在 libraryd-conc 里落地
05.1 的 MapBackedStockCache 只有读。这里加扣减和归还,用 get 读当前值、replace(k, old, new) 做单键 CAS,失败就重读重试。
package com.plume.conc.core;
import java.util.concurrent.ConcurrentHashMap;
public final class MapBackedStockCache {
private final BookRepository books;
private final ConcurrentHashMap<String, Integer> stock;
public MapBackedStockCache(BookRepository books, int expectedIsbns) {
this.books = books;
this.stock = new ConcurrentHashMap<>(expectedIsbns);
}
public int stockOf(String isbn) {
Integer cached = stock.get(isbn);
if (cached != null) {
return cached;
}
Book book = books.findByIsbn(isbn)
.orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn));
Integer prior = stock.putIfAbsent(isbn, book.stock());
return prior != null ? prior : book.stock();
}
public boolean tryReserve(String isbn, int copies) {
stockOf(isbn);
while (true) {
Integer current = stock.get(isbn);
if (current == null || current < copies) {
return false;
}
if (stock.replace(isbn, current, current - copies)) {
return true;
}
}
}
public boolean returnCopies(String isbn, int copies) {
stockOf(isbn);
while (true) {
Integer current = stock.get(isbn);
if (current == null) {
return false;
}
if (stock.replace(isbn, current, current + copies)) {
return true;
}
}
}
}
replace(k, old, new) 是单键的原子比较替换,内部同样锁住桶头并核对当前值;CAS 失败说明有人先改了,循环重读即可。这个循环正确,但每次失败都要重新读、重新比较,键竞争激烈时重试次数会上升。5.3 会用 compute/merge 把这套循环收进 CHM 的桶锁里。
监控缓存的键数量可以用 stock.size(),但不能拿它做库存配额判断;需要精确上限时用单独的 AtomicInteger 或数据库约束。
常见错误与边界
get后put做检查再行动:if (map.get(k) < n) map.put(k, ...)不是原子的,两个线程会同时通过检查。用replace(k, old, new)、compute或merge。- 把
size()当精确值:并发写时它可能少算或多算;isEmpty()也可能在空表上短暂返回false。需要精确计数时用LongAdder或外部计数器,并接受它同样不是快照。 - 在
synchronized (map)上做复合操作:CHM 内部锁的是桶头,不是 map 对象;外部锁 map 不能阻止内部写者,除非所有访问路径都遵守同一把外部锁。那还不如直接用HashMap加锁。 - 用可变对象当键:
hash在插入时算好并缓存,键的hashCode后来变了,get会去错的桶找,永远找不到。键要不可变。 - 什么时候不要用:需要跨键原子性(转账、库存加借阅配额同时更新)、需要稳定遍历顺序、需要按范围查询,都不适合 CHM。跨键事务要么用外部锁,要么换成数据库事务;有序遍历看 6.2 的跳表。
小结
get完全不加锁:桶头 hash 命中直接读val,负数交给ForwardingNode/TreeBin/ReservationNode的find,链表顺着volatile next走。putVal先 CAS 空桶;桶非空才synchronized桶头,进锁后必须复核桶头未变;链表尾插、树桶putTreeVal,遇到ReservationNode抛Recursive update。- 遇到
MOVED调用helpTransfer,CAS 增加sizeCtl的参与者计数后一起搬运;transferIndex按至少 16 个桶一段领取。 - 计数是
baseCount加CounterCell[]的分片和,sumCount逐字段读,不是原子快照,size()只是估计值。 - 单键原子更新用
replace/compute,跨键不变量必须外部加锁,不能靠 CHM 的桶锁。
阅读导航:上一节 5.1 ConcurrentHashMap 的节点结构 | 下一节 5.3 弱一致迭代与复合操作
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。