并发容器写侧契约:ConcurrentHashMap、CopyOnWriteArrayList 与 ConcurrentLinkedQueue
版本基线:语言语义以 JLS SE 17 为准,API 契约以 Java SE 17 为准,实现分析固定到 OpenJDK 17u
jdk-17.0.12+7。本机实际编译运行版本为 Oracle JDK 17.0.12+8(x86_64,/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home);旧记录中的 Liberica 17.0.19 路径已不存在。happens-before/volatile/final 的权威在JMM 正文,CAS/ABA 与原子类在CAS 正文,互斥锁与公平性在锁正文,AQS 与 Condition 在AQS 正文;迭代器读侧契约(快照/弱一致迭代)权威在集合契约正文。本页讲三个并发容器的写侧:协议、失败分支、进度代价与选型。table、sizeCtl、array等是内部字段,不是跨版本契约。
容器级 happens-before:放进去之前的事,取出来之后可见
JLS 只给了 volatile/监视器/start/join 这些底层边;java.util.concurrent 把它们扩展成容器级承诺:
原码连续摘录(仅统一公共缩进):java/util/concurrent/package-info.java,jdk-17.0.12+7,L270–L272。
* <li>Actions in a thread prior to placing an object into any concurrent
* collection <i>happen-before</i> actions subsequent to the access or
* removal of that element from the collection in another thread.这条边的含义:obj.field = 42; queue.offer(obj); 之后,任何线程从容器读到 obj 时,field=42 必然可见——不需要你再把 field 声明成 volatile。边界同样清晰:它只覆盖"放入前 → 取出后",不覆盖两个线程绕过容器直接访问同一对象的竞争,也不覆盖复合操作(先 get 再 put 之间容器不提供事务)。写侧契约都建立在这条边与各自的同步手段上。
ConcurrentHashMap:桶状态决定写协议
ConcurrentHashMap(下称 CHM)的核心是 Node<K,V>[] table,表长恒为 2 的幂,首次插入才初始化。它没有整表锁:读路径通常无锁,写路径按桶状态选择 CAS、桶头 synchronized 或协助扩容。putVal 主路径概括:
putVal(key, value):
key 或 value 为 null -> NullPointerException
for (;;):
table 未初始化 -> initTable()
目标桶为空 -> casTabAt(tab, i, null, new Node) 成功则结束
桶头 hash == MOVED -> helpTransfer(tab, f),协助搬迁后重试
否则 synchronized (桶头 f):
复核 tabAt(tab, i) == f
链表尾插,或 TreeBin 插入/覆盖
binCount >= 8 -> treeifyBin(tab, i)
addCount(1, binCount)关键点:
- 空桶 CAS 安装,不加锁;冲突桶只锁桶内第一个节点,其他桶不受影响。
synchronized (f)内必须复核tabAt(tab, i) == f:等锁期间桶头可能已被替换或迁移。treeifyBin在表长小于 64 时先扩容,达到 64 才树化;与HashMap的 8/64 条件同源,但 CHM 的迁移路径要单独核对。- 扩容拆分树桶时,某侧节点数不超过 6 会退化为链表;不要把"删除到 6 一定退化"套到所有路径。
get不加锁,依赖tabAt的 acquire 语义与节点发布;遇到ForwardingNode转到新表查找。- 禁止 null 键值:
get返回 null 只表示"没有映射",compute/merge的"返回 null 即删除"也依赖这一约束。
原码:节点哈希状态编码
原码连续摘录(仅统一公共缩进,截取字段声明):ConcurrentHashMap.java,jdk-17.0.12+7,L588–L597。
/*
* Encodings for Node hash fields. See above for explanation.
*/
static final int MOVED = -1; // hash for forwarding nodes
static final int TREEBIN = -2; // hash for roots of trees
static final int RESERVED = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
/** Number of CPUS, to place bounds on some sizings */
static final int NCPU = Runtime.getRuntime().availableProcessors();节点哈希字段同时承载三种控制状态:MOVED 标记已迁移桶,TREEBIN 标记树根,RESERVED 留给写侧占位节点(后文 computeIfAbsent);普通节点哈希还要与 HASH_BITS 相与保证非负。"桶头 hash 为负"不是业务哈希,而是容器协议的一部分。
原码:sizeCtl 的三个含义
原码连续摘录(仅统一公共缩进,截取字段声明):ConcurrentHashMap.java,jdk-17.0.12+7,L792–L805。
/**
* Table initialization and resizing control. When negative, the
* table is being initialized or resized: -1 for initialization,
* else -(1 + the number of active resizing threads). Otherwise,
* when table is null, holds the initial table size to use upon
* creation, or 0 for default. After initialization, holds the
* next element count value upon which to resize the table.
*/
private transient volatile int sizeCtl;
/**
* The next table index (plus one) to split while resizing.
*/
private transient volatile int transferIndex;sizeCtl 至少三种含义:初始化前是容量提示,初始化后是扩容阈值,扩容期间为负并编码扩容戳与协助线程数。构造器容量提示也易与 HashMap 混淆:new ConcurrentHashMap<>(1000) 的 sizeCtl 是 2048(按 1 + initialCapacity/loadFactor 取 2 的幂),首次 put 后表长 2048、阈值 1536;而 new HashMap<>(1000) 首次分配 1024。已知规模时应预分配,扩容不是免费的。
单键原子:computeIfAbsent 的占位节点与失败分支
compute、merge、computeIfAbsent 对同一个键的一次调用是原子的:把"读旧值—计算—写回/删除"收敛成一次调用。空桶路径的实现:
原码连续摘录(仅统一公共缩进,截取方法局部):ConcurrentHashMap.java,jdk-17.0.12+7,L1691–L1717。
public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) {
if (key == null || mappingFunction == null)
throw new NullPointerException();
int h = spread(key.hashCode());
V val = null;
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) & h)) == null) {
Node<K,V> r = new ReservationNode<K,V>();
synchronized (r) {
if (casTabAt(tab, i, null, r)) {
binCount = 1;
Node<K,V> node = null;
try {
if ((val = mappingFunction.apply(key)) != null)
node = new Node<K,V>(h, key, val);
} finally {
setTabAt(tab, i, node);
}
}
}
if (binCount != 0)
break;
}读法:空桶先用 CAS 安装 ReservationNode 占位,然后在占位节点的监视器内执行你的映射函数。这带来三条写侧契约:
- 同键串行化(进度代价):映射函数执行期间,同键的其他写操作(
put/compute)阻塞在同一个监视器上;不同键在不同桶,不受影响。实验实测:600ms 的映射函数让put(同键)等待 ~606ms,put(异键)~0ms。因此"计算短小、不做阻塞 I/O/远程调用"不是风格建议,是进度契约——映射函数里的慢操作会变成同桶所有写者的等待。 - 递归更新即协议错误:映射函数修改同一映射时,重入路径撞上占位节点,被显式拒绝:
原码连续摘录(仅统一公共缩进,截取方法局部):ConcurrentHashMap.java,jdk-17.0.12+7,L1166–L1167。
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");实测:computeIfAbsent(k, x -> { map.put(k, v); ... }) 抛 IllegalStateException,且键最终不在映射里。不是死锁、不是静默失败——协议拒绝。 3. 映射函数抛异常 = 这次调用没有发生:异常原样传播,映射保持调用前状态(空桶不留占位、已有键保留旧值)。实测 computeIfAbsent 抛异常后键缺失、compute 对已有键抛异常后旧值保留。调用方仍要定义重试或失败语义。另一条相邻契约:merge 的重映射函数返回 null 即删除该键(实测生效)。
协作扩容:transferIndex 与 ForwardingNode
扩容由 addCount 在元素数达到阈值时发起。transfer 不是"一个线程复制整张表",而是把旧表按段分给多个线程:
- 线程通过 CAS 更新
transferIndex领取一段旧桶,最小步长MIN_TRANSFER_STRIDE = 16。 - 处理空桶直接放
ForwardingNode;处理非空桶时锁住桶头,按hash & n把节点拆到新表的i与i + n。 - 旧桶处理完写入
ForwardingNode(nextTab):"这个桶已迁移,请去新表"。 - 读线程遇到
ForwardingNode转到nextTable查找;写线程遇到hash == MOVED调用helpTransfer协助搬迁而不是阻塞等待。 - 最后一个完成搬迁的线程把
table指向新表,sizeCtl设为新容量的 0.75 倍。
这解释了扩容期间写操作仍能推进:搬迁可被任何写线程分担,读操作不必整表等待。代价是扩容期间新旧表并存、额外内存与更复杂的可见性协议。
弱一致与聚合:size 不是闸门
迭代器、Spliterator、Enumeration 是弱一致的:不抛 ConcurrentModificationException,可能反映部分并发更新,但不是全表快照;迭代器本身仍只应由单线程使用(读侧机制见集合契约正文的 Traverser.advance 摘录)。
size()、isEmpty()、containsValue() 通过 baseCount 与 CounterCell[] 求和,读取期间的并发更新可能未计入;JDK 文档明确它们适合监控估算、不适合程序控制。putAll、clear 也不是原子操作。跨键不变量、批量替换、库存扣减不能靠"用了 CHM"解决——需要外部锁、数据库事务、条件更新或状态机,并明确失败恢复责任(见并发综合案例与CAS 正文的计数边界)。
CopyOnWriteArrayList:每次写都全量复制
读侧完全无锁:get/迭代直接读当前数组引用。写侧的契约全在这一段:
原码连续摘录(仅统一公共缩进,截取方法局部):CopyOnWriteArrayList.java,jdk-17.0.12+7,L428–L437。
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1);
es[len] = e;
setArray(es);
return true;
}
}每个写操作:拿全局锁 → 复制整个数组 → 改副本 → 一次性替换引用。由此推出全部写侧性质:
- 写写在锁上互斥(实测双写者并发各加 1000 次无丢失);读写从不互斥,读到的永远是某一刻的完整快照(迭代器快照语义的读侧讲解与
COWIterator源码在集合契约正文,此处不重复)。 - 成本模型:单次写 O(n) 时间 + O(n) 内存分配;数组越大、写越频繁,复制成本与 GC 压力越高。适用面是"读极多、写极少、总量小":监听器列表、路由规则、配置快照。频繁追加的大列表会放大成内存与停顿问题。
- 快照迭代器不支持修改:
iterator().remove()抛UnsupportedOperationException(实测)。"边遍历边删"要用removeIf(锁内整体处理)或显式重建列表。 - 复合读-改-写仍不原子,
addIfAbsent用"先无锁快照查询、锁内复核"保住单调用原子:
原码连续摘录(仅统一公共缩进,截取方法局部):CopyOnWriteArrayList.java,jdk-17.0.12+7,L596–L610。
private boolean addIfAbsent(E e, Object[] snapshot) {
synchronized (lock) {
Object[] current = getArray();
int len = current.length;
if (snapshot != current) {
// Optimize for lost race to another addXXX operation
int common = Math.min(snapshot.length, len);
for (int i = 0; i < common; i++)
if (current[i] != snapshot[i]
&& Objects.equals(e, current[i]))
return false;
if (indexOfRange(e, current, common, len) >= 0)
return false;
}
Object[] newElements = Arrays.copyOf(current, len + 1);进锁后发现数组已被别人换过(snapshot != current),就在锁内重新查一遍增量区间再决定。这是"乐观检查 + 锁内复核"的模板,与 CHM 桶头复核同一思想。set 也复制整个数组(即使只改一个元素),实测数组引用在 add 与 set 后都发生更换。
ConcurrentLinkedQueue:CAS 尾插与 O(n) 的 size
ConcurrentLinkedQueue(CLQ)是无界、非阻塞的 FIFO 队列,基于 Michael-Scott 链表。写侧核心在 offer:
原码连续摘录(仅统一公共缩进,截取方法局部):ConcurrentLinkedQueue.java,jdk-17.0.12+7,L354–L369。
public boolean offer(E e) {
final Node<E> newNode = new Node<E>(Objects.requireNonNull(e));
for (Node<E> t = tail, p = t;;) {
Node<E> q = p.next;
if (q == null) {
// p is last node
if (NEXT.compareAndSet(p, null, newNode)) {
// Successful CAS is the linearization point
// for e to become an element of this queue,
// and for newNode to become "live".
if (p != t) // hop two nodes at a time; failure is OK
TAIL.weakCompareAndSet(this, t, newNode);
return true;
}
// Lost CAS race to another thread; re-read next三件事:
- 线性化点是
NEXT.compareAndSet(p, null, newNode)成功那一刻——元素从这一刻起"在队列里",源码注释原话。多写者竞争同一尾位时输者重读重试(也是JMM 正文里 lock-free 的写侧体现)。 - 松弛尾指针:
tail允许落后真实尾(hop two nodes);推进尾指针用的是 weak CAS 且"失败没关系"(p != t才尝试)。尾指针只是优化,不是不变量——这正是CAS 正文"weak CAS 可用于失败可重试且无依赖路径"的内置范例。 - 无界 + 不阻塞:
offer永不返回 false、永不等待。要容量上限与阻塞语义,用BlockingQueue家族(put/offer/带时限offer的分层契约归线程池与队列正文)。
size() 是这条队列最常被误用的方法,文档直接警告:
原码连续摘录(仅统一公共缩进,截取方法局部):ConcurrentLinkedQueue.java,jdk-17.0.12+7,L455–L466。
* <p>Beware that, unlike in most collections, this method is
* <em>NOT</em> a constant-time operation. Because of the
* asynchronous nature of these queues, determining the current
* number of elements requires an O(n) traversal.
* Additionally, if elements are added or removed during execution
* of this method, the returned result may be inaccurate. Thus,
* this method is typically not very useful in concurrent
* applications.
*
* @return the number of elements in this queue
*/
public int size() {实现遍历链表计数,还可能因并发变更重启(restartFromHead)。实测 30 万元素 size() 耗时 ~3ms(观察值)且无并发时准确;但它既慢又只是估计,做容量闸门既贵又不可靠。需要精确计数时用独立的原子计数器(在入队/出队路径上维护),需要"不超 N"时用有界队列的阻塞/拒绝语义。空队列分支实测:peek()/poll() 返回 null,element() 抛 NoSuchElementException,offer(null) 抛 NPE。
删除是松弛的:poll 把头节点出队后,已出队节点仍短暂留在链上由后续操作物理摘除——所以遍历要用 item != null 判断活跃元素;迭代器弱一致,创建后可能仍看到已 poll 的头(实测观察值,非契约)。
图:写入路径、扩容与分散计数
图中三栏分别对应空桶 CAS、冲突桶锁头与迁移桶协助;计数栏展示 baseCount 与 CounterCell[] 的分散求和。读图时先找桶状态,再找该状态对应的同步手段,不要把整张图读成"全程无锁"。
竞争时间线:三组可检查交错
| 时间 | 写线程 A | 扩容线程 B | 结果 |
|---|---|---|---|
| t1 | 读 tab,定位桶 i | 开始 transfer | 旧表仍可读 |
| t2 | casTabAt 安装新节点 | 领取段并迁移 | 成功或失败重试 |
| t3 | 重试时桶头为 MOVED | 旧桶已写 ForwardingNode | A 调用 helpTransfer |
| t4 | 在新表重试 | 最后完成者提交 table | 写操作继续推进 |
| 时间 | T1(computeIfAbsent 慢映射) | T2(put 同键) | T3(put 异键) |
|---|---|---|---|
| t1 | CAS 安装占位节点,锁内执行 600ms 映射 | — | — |
| t2 | — | synchronized(占位) 阻塞 | 不同桶 CAS 直接安装 |
| t3 | finally setTabAt 写入结果 | 获得监视器,写入 | 已完成 ~0ms |
| 结论 | 单键原子 = 同键串行化 | 等待 ~606ms(实测) | 不受影响(实测 ~0ms) |
| 时间 | COW 写者 1 | COW 写者 2 | 读线程 |
|---|---|---|---|
| t1 | 拿锁,复制数组 | 等锁 | 读旧数组快照 |
| t2 | 改副本,setArray 发布 | — | 此后读到新数组 |
| t3 | — | 拿锁,再次复制 | 再读又是另一快照 |
| 结论 | 写写互斥串行、成本 O(n)×2 | — | 读永远完整,从不阻塞 |
实验:输入、步骤、预期与边界
公开入口(仓库根):
JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home \
bash labs/java-senior/run.sh jmm探针为 labs/java-senior/JmmProbe.java(反射读取 COW array 字段需 --add-opens java.base/java.util.concurrent=ALL-UNNAMED,仅实验用)。本节相关输出(2026-10-24,Oracle JDK 17.0.12+8,macOS 26.7.1 x86_64):
== 7. ConcurrentHashMap write-side failure branches ==
computeIfAbsent mapping mutates same map -> IllegalStateException(映射函数不得在计算中修改本映射)
computeIfAbsent mapping throws -> propagated, mapping absent=true
compute on existing key throws -> old value kept=true
merge remapping returns null -> removed=true returned=null
put(null, v) -> NullPointerException
put(k, null) -> NullPointerException
== 8. computeIfAbsent progress: same key serialized, other key free ==
put(same key A) waited ~606ms(与锁内映射函数串行:单键原子性的进度代价)
put(other key B) waited ~0ms(不同桶,不受该桶锁影响)
== 9. CopyOnWriteArrayList write side: copy on every write ==
array identity changes on add=true on set=true(每次写都全量复制底层数组)
iterator created before add sees 3 elements; list now has 4
snapshot iterator.remove() -> UnsupportedOperationException
addIfAbsent(fresh) first=true duplicate=false size=5
two writers x1000 concurrent adds -> size=2005(写写在锁上互斥;读无锁)
== 10. ConcurrentLinkedQueue write side ==
offer/poll FIFO order=[0, 1, 2, 3, 4]
empty: peek=null poll=null element() -> NoSuchElementException
offer(null) -> NullPointerException
size() on 300000 elements=300000 took 3013µs(遍历计数 O(n);非并发下准确,并发更新中只是估计)
iterator created then head polled: iterator still returns head=true(弱一致观察值,非契约)断言口径:异常分支、null 拒绝、merge-null 删除、FIFO 顺序、COW 复制与快照、同键串行化阈值(≥450ms/<350ms)全部断言(确定性或大裕度时序);耗时与弱一致迭代输出按观察值记录。历史基线:CHM 懒初始化/扩容/并发 merge/null 的六组输出在labs/concurrency 探针(本轮用 Oracle JDK 17.0.12 复跑仍一致,见实测证据)。
边界:反射读取内部字段只对固定 tag 负责;时序断言有百毫秒级裕度,不当作精确性能测量;不覆盖 ConcurrentSkipListMap、ConcurrentLinkedDeque(未纳入本票范围,全站亦无权威讲解,留给后续票登记);COW 的 GC 压力、CLQ 的多生产者尾竞争吞吐未测量。
设计案例:本地热点视图的三种容器(算例,非实测)
假设风控服务需要一份"命中规则列表 + 每规则计数 + 事件队列"的进程内状态:规则集 200 条、每天变更 2 次、每请求读全部规则;计数按规则键聚合、峰值 10 万 QPS;事件队列供异步消费、无界会拖垮内存。以下为假设算例。
三个组件的选型与责任:
- 规则列表用
CopyOnWriteArrayList(或不可变List+ volatile 引用):读路径每请求 O(n) 无锁扫描,写每天 2 次 O(n) 复制可忽略;快照语义保证一次扫描内规则集自洽。若规则集涨到数万条且变更频繁,复制成本与 GC 压力触发演进(换不可变快照引用、分片或外部配置中心推送)。 - 计数用
ConcurrentHashMap<String, LongAdder>+computeIfAbsent:单键原子建计数器,高并发写分散;告警读容忍近似。若某个计数要参与"拒绝判定"(硬阈值),换该键的精确原子或令牌桶——sum 非快照(见CAS 正文)。映射函数只做new LongAdder(),任何 I/O 都会串行化同桶写。 - 事件队列不用无界 CLQ:无界队列把背压问题转成内存问题。需要容量上限时用有界
BlockingQueue+ 拒绝/落盘策略(契约见线程池与队列正文);CLQ 适合"消费者总是更快、只需无锁交接"的场景,且监控长度不该调size()(O(n) 且是估计),用独立计数器。
不变量与恢复:进程内状态重启清零,规则事实源在配置侧、计数事实源按业务定(监控口径可丢,计费口径不可丢);队列溢出的丢弃/持久化责任必须显式。验证方式:按真实键分布测头部键争用、按真实规则规模测 COW 复制停顿,而不是均匀负载下的平均数。
条件变化与边界
| 条件变化 | 结论如何变化 |
|---|---|
| 键分布从均匀变热点 | CHM 桶级并行的收益消失于单键串行;热点键需要分片或外部顺序化 |
| 映射函数加入远程调用 | 同键写者全部排队等待外部延迟;进度边界从纳秒级变百毫秒级(实测口径) |
| COW 列表规模增长两个数量级 | 每写复制从可忽略变可见停顿与 GC 源;触发快照引用/分片演进 |
| 需要精确"当前长度"做闸门 | CHM size() 与 CLQ size() 都不合格;换独立原子计数或有界队列语义 |
| 升级 JDK | 桶协议、ReservationNode、松弛尾是实现细节,逐版本核对源码后再引用;写侧契约(null 拒绝、compute 原子性、快照迭代)来自 API 文档,相对稳定 |
追问链
第一层:CHM 的 put 为什么锁桶头还要复核?
进入 synchronized (f) 前,桶头可能已被并发写替换或迁移(f 不再是桶头)。复核 tabAt(tab, i) == f 失败就重新走循环,避免在已失效的链表上插入。这是"乐观路径 + 互斥段内校验"的组合,与 COW addIfAbsent 的锁内复核同构。
第二层:为什么 computeIfAbsent 里不能做远程调用?
映射函数在桶(或占位节点)监视器内执行:同键写者会被你的外部延迟串行化(实测 600ms 映射让同键 put 等 606ms),高并发下等于把外部调用的尾延迟注入所有同键写路径。先在外面准备好值再 computeIfAbsent(k, x -> prepared),或用 CompletableFuture 占位再补值(异步边界见异步正文)。
第三层:读多写少为什么不是 COW 的充分条件?
还要看总量与写频率的乘积:每次写 O(n) 复制,n 大或写密都会把成本放大成停顿与 GC 压力;且 COW 只保证读到快照,不保证读到"最新"(写后旧迭代器仍是旧快照)。COW 的适用判据是"读极多 × 写极少 × 集合小 × 可接受短暂陈旧",四条都要成立。
第四层:三个容器混在一个服务里,怎么向架构评审证明选型?
分别给出:读写比例与规模数据、每次操作的成本模型(桶锁 vs 全量复制 vs CAS 尾插)、失败分支实测(同键串行、迭代器不支持删、size 估计)、以及每个组件的溢出/热点/重启恢复责任。选型论证的证据是这些数字与契约,不是"都是线程安全的"。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Java SE 17 ConcurrentHashMap API | Java SE 17 | 无锁读、弱一致迭代、size 估算、null 禁止、compute/merge 单键原子与函数约束 |
| Java SE 17 java.util.concurrent 包文档 | Java SE 17 | 容器级 happens-before 承诺 |
| Java SE 17 CopyOnWriteArrayList API | Java SE 17 | 快照迭代器、写成本、addIfAbsent |
| Java SE 17 ConcurrentLinkedQueue API | Java SE 17 | 无界 FIFO、size O(n) 与估计、空分支异常 |
| Java SE 17 BlockingQueue API | Java SE 17 | 有界/阻塞边界(归线程池正文,此处对照) |
| OpenJDK 17u ConcurrentHashMap | jdk-17.0.12+7 | 节点状态编码(L588–L597)、sizeCtl/transferIndex(L792–L805)、computeIfAbsent 占位路径(L1691–L1714)、Recursive update(L1165–L1167) |
| OpenJDK 17u CopyOnWriteArrayList | jdk-17.0.12+7 | add 全量复制(L428–L436)、addIfAbsent 锁内复核(L596–L610) |
| OpenJDK 17u ConcurrentLinkedQueue | jdk-17.0.12+7 | offer CAS 线性化点与松弛尾(L354–L369)、size O(n) 警告(L455–L466) |
本地 JmmProbe 实验 | Oracle JDK 17.0.12+8 | 写侧失败分支、同键串行化、COW 复制/快照、CLQ 边界实测输出 |
本地 ConcurrencyProbe 历史 | Oracle JDK 17.0.12(本轮复跑) | CHM 懒初始化/扩容/merge/null 基线输出 |
访问日期:2026-10-24。源码行号以 jdk-17.0.12+7 标签为准;运行环境为 Oracle JDK 17.0.12+8。桶协议与内部字段是实现细节,跨版本需重新核对。
闭卷验收
本页 11 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。