《Java 并发编程·JUC 源码与容器》5.2 put/get 的并发路径与 size 统计

沿 JDK 25 源码拆开 ConcurrentHashMap 的 get 与 putVal:无锁读、桶首 CAS、synchronized 锁桶首、MOVED 转发与 helpTransfer,再用探针展示 baseCount、CounterCell 分片计数和 sumCount 的近似性,附本机分支日志。

承接的问题

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;
    }

四步:

  1. table 为 null 或长度为 0,直接返回 null,不初始化表。
  2. 桶头 hash 等于目标 hash,比较 key 后返回 e.val。
  3. 桶头 hash 为负,说明是特殊节点,交给多态的 find:ForwardingNode.find 去新表找,TreeBin.find 走树或退回链表,ReservationNode.find 返回 null。
  4. 普通链表,顺着 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 弱一致迭代与复合操作

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收