并发容器:线程安全之后,复合语义仍要自己负责
把 HashMap 换成 ConcurrentHashMap,只能证明单次容器操作具备线程安全语义,不能证明“先判断、再加载、再写入”这段业务是原子的。把 ArrayList 换成 CopyOnWriteArrayList,遍历不会互相干扰,却可能在高频写入时制造巨量数组复制。并发容器替你解决特定数据结构的并发访问,不替你定义业务生命周期、容量和复合不变量。
注册表重复创建、监听器列表写放大、任务队列无界增长分别暴露复合原子性、快照复制和容量边界问题。容器选择必须由读写模式、快照语义、阻塞方式和过载合同决定,而不是按 JUC 类型名称决定。
containsKey 加 put 仍然会重复创建
下面每个方法调用本身都线程安全,但组合起来不是:
if (!clients.containsKey(tenantId)) {
clients.put(tenantId, createClient(tenantId));
}
return clients.get(tenantId);两个线程可能同时判断不存在,各自创建昂贵客户端,再相互覆盖。若创建函数轻量且无递归依赖,可使用原子复合操作:
Client client = clients.computeIfAbsent(tenantId, this::createClient);但 computeIfAbsent 不是“任意慢操作保护罩”。加载函数在映射计算过程中运行,若它访问同一 map 的相关 key、发生递归更新、调用慢下游或永不返回,会放大局部竞争。外部资源创建还涉及失败重试和关闭:计算失败是否缓存,创建出的旧对象谁销毁,配置更新时怎样替换,都要另行设计。
一个常见方案是把并发创建状态显式化,例如保存 CompletableFuture<Client>,让并发请求共享同一次加载,并在失败后按策略移除。但这会引入异常传播、超时和取消,不能只靠一行 computeIfAbsent 草率完成。
ConcurrentHashMap 的一致性边界
ConcurrentHashMap 支持高并发读取与更新,单个 key 的更新操作有明确原子边界。一次成功 get(key) 读取到某个非空值,与对应插入或更新之间存在 happens-before 关系。它不提供锁住整张表阻止所有访问的能力。
迭代器和 bulk 操作反映创建以来某个时刻或期间的状态,适合监控、搜索和近似汇总,不是数据库式事务快照。遍历期间并发更新,结果可能包含部分新值、部分旧值,但不会因为普通结构性修改抛 ConcurrentModificationException。
long enabled = routes.reduceValuesToLong(
1,
route -> route.enabled() ? 1L : 0L,
0L,
Long::sum
);这个结果适合指标,不适合做“只有全部租户都启用才能发布”的强一致门禁。需要一致视图时,构造不可变快照并原子替换引用,或在更高层建立锁/版本协议。
初始化容量仍然影响扩容成本
并发容器没有消除容量规划。ConcurrentHashMap 在并发扩容时可以由多个线程协助迁移,但扩容仍消耗 CPU 和内存带宽,并改变尾延迟。已知大致 key 数时给出合理初始容量,避免流量高峰才连续扩容。
旧文章经常把 concurrencyLevel 解释为固定分段锁数量。现代实现只把相关构造参数作为 sizing hint,不能用 JDK 7 的 Segment 模型解释 JDK 17/25 行为。源码分析要绑定版本;业务选型更应关注 key 分布、热点、对象生命周期和最大容量。
热点 key 仍会成为竞争点。把 map 分成很多桶不能让同一个租户、同一个 SKU 的复合更新自动并行;需要按业务键分片、串行化或把提交点下沉到数据库。
沿一次 computeIfAbsent 看桶级冲突
ConcurrentHashMap 不是一把覆盖整张表的全局锁。表尚未初始化时,线程通过 sizeCtl 协调初始化;空桶可用 CAS 安装节点;桶已有节点时,更新在桶级协调下遍历链表或树;扩容期间旧桶出现转发节点,访问线程据此进入新表,部分线程还会协助迁移。sizeCtl 在不同阶段承担阈值、初始化竞争与扩容协作信息,不是简单的“下一次扩容容量”。这些是 OpenJDK 实现的阅读坐标,业务正确性仍应建立在 API 契约上。
computeIfAbsent 为某个 key 提供原子计算入口,但映射函数不适合长时间阻塞、修改同一映射的递归调用或依赖其他热点 key。空桶计算时,实现要留下正在计算的占位;映射函数再次递归计算同一个 key,当前线程等于等待自己完成。OpenJDK 会识别这种无法推进的递归更新并抛出 IllegalStateException,而不是永久挂住。
cd examples/backend-development/concurrency/concurrent-containers
javac --release 17 -Xlint:all -Werror ConcurrentMapFailureDemo.java
java ConcurrentMapFailureDemo
# recursive-update=IllegalStateException
# iterator=v1, current=v2第一行是稳定失败实验:问题不在容器“不够线程安全”,而在映射函数破坏了原子计算的推进条件。远程加载放进映射函数,还会让同 key 请求排在不可控的网络等待后面。更可控的设计通常把值建模为有超时、失败和清理语义的 Future/占位对象,或在容器外做 single-flight;失败占位必须移除,否则一次错误会永久污染缓存。
扩容也不是免费的背景动作。容量估计偏小会让迁移与业务访问交叠;哈希分布差会把并发访问重新压到少数桶。诊断时应一起观察条目规模、初始容量、热点 key、映射函数耗时和分配,不能看到类名是 ConcurrentHashMap 就宣布容器层没有竞争。
CopyOnWrite 的“读不加锁”来自每次写复制
监听器列表、路由规则小集合常见 CopyOnWriteArrayList:
CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
for (Listener listener : listeners) {
listener.onEvent(event);
}修改操作创建底层数组的新副本,迭代器持有创建时的快照,因此遍历不受后续增删干扰,也不支持通过迭代器修改。代价很直接:集合越大、写越频繁,复制与垃圾越多。
适合它的条件是:集合不大、遍历远多于修改、读取需要稳定快照、允许新监听器从下一轮事件才可见。配置每秒刷新、列表数十万或业务高频增删时,应考虑不可变批量快照、读写锁或其他数据结构。
还有一个生命周期陷阱:删除监听器不影响已经创建的迭代快照,本轮遍历仍可能调用它。注销若要求“返回后绝不再被调用”,需要额外状态或等待在途调用清空,容器本身不提供这个业务承诺。
CopyOnWrite 的快照一致性是数组引用的一次切换
CopyOnWriteArrayList 的写者在锁内复制当前数组、修改副本,再发布新的数组引用;读者先取得某一版数组后便在那一版上读取。迭代器因此不会抛出并发修改异常,也不会看到创建迭代器之后的写入。实验中的 iterator=v1, current=v2 不是短暂延迟,而是两个合法快照同时存在:旧迭代器持有旧数组,容器入口已经指向新数组。
代价也应沿这条引用链计算。每次写分配与元素数量同阶的新数组,大数组高频写会同时带来复制 CPU、分配速率和旧快照回收压力;长寿命迭代器还会延长旧数组存活。评估它不能只测 get 的纳秒数,要把写比例、元素数、迭代器寿命和 GC 分配一起测。监听器列表、路由快照这类读多写极少且规模受控的结构适合;实时队列、会话表或高频注册表通常不适合。
阻塞队列决定压力放在哪里
BlockingQueue 不只是线程安全队列,它定义生产者和消费者在空/满时如何等待。四组操作表达不同失败策略:
| 行为 | 插入 | 取出 | 适用判断 |
|---|---|---|---|
| 抛异常 | add | remove | 违反容量即编程错误 |
| 特殊值 | offer | poll | 调用方立即决定降级 |
| 无限等待 | put | take | 必须确认停机与取消能中断 |
| 限时等待 | offer(timeout) | poll(timeout) | 让排队服从截止时间 |
生产线程池通常需要有界队列;无界队列会把过载转成内存和延迟积累。SynchronousQueue 不存元素,每次交付必须与接收配对,适合直接移交;PriorityBlockingQueue 默认无界,而且低优先级任务可能饥饿;DelayQueue 表达延迟到期,不是持久调度平台。
队列的内存一致性效果很重要:放入元素前的动作 happens-before 另一个线程访问或移除该元素后的动作。但对象交接后仍被生产者修改,会重新产生竞争。最清晰的契约是交接不可变任务,或规定 put 后生产者放弃修改所有权。
阻塞队列的四组 API 是四种过载合同
以插入为例,add 满时抛异常,offer 立即返回失败,带时限 offer 等到预算耗尽,put 可以无限等待;取出侧也有对应的异常、特殊值、超时和阻塞形式。它们不是语法偏好,而是生产者在容量耗尽时的业务合同。请求线程使用无期限 put,相当于把队列背压变成入口线程堆积;消费者允许任务过期时使用无限等待,又可能执行早已失去价值的工作。
有界队列还要为每个元素附带入队时间或截止时间。只看 size/capacity 无法区分刚刚突发与已经等待一分钟的陈旧任务。消费前若截止时间已过,应执行明确的丢弃、补偿或失败回传,而不是继续占用下游。队列容量应从允许等待时长与稳定完成率推导,并通过拒绝数、等待年龄分位数和生产者阻塞时间验证。
非阻塞队列也可能无限长
ConcurrentLinkedQueue 提供可扩展的非阻塞 FIFO 操作,适合多个生产者消费者的轻量交接。但它没有容量限制和阻塞背压;生产速度长期大于消费速度,节点会持续占用堆。
size() 对某些并发队列可能需要遍历,并且返回时已经过时,不适合高频容量控制。需要硬上限时选择有界阻塞队列、信号量配额,或在架构上限制生产速率。
队列中每个任务还可能捕获请求体、ThreadLocal 上下文、文件缓冲或大对象。容量不能只用“元素个数”估算,要测平均与 P99 retained size。10000 个各自持有 1MB 数据的任务,足以让所谓缓冲区变成 OOM 现场。
并发容器不会替你处理过期和清理
把对象放进并发 map 后,如果没有过期、淘汰、引用释放和关闭协议,它就是一个线程安全的内存泄漏候选。常见案例包括按租户缓存客户端、按请求 ID 保存 Future、注册监听器却不注销、用队列保存永不过期补偿任务。
工程上需要同时定义:
最大 key/元素数和单元素内存预算。创建、替换、删除时谁关闭资源。失败值是否缓存,多长时间后允许重试。
过期是访问触发、定时清理还是外部事件驱动。遍历与快照是否允许看到并发变化。进程关闭时队列任务怎样排空、持久化或放弃。
缓存场景若需要时间过期、权重淘汰、刷新与统计,成熟缓存库通常比自己在 ConcurrentHashMap 上补一套生命周期可靠。并发容器解决访问,不是完整缓存产品。
三个反向实验比背实现更有用
第一个实验用起跑门让多个线程同时执行 containsKey + put,统计创建函数调用次数,再换 computeIfAbsent 对照。
第二个实验在 CopyOnWriteArrayList 中放入较大集合,分别测试高频遍历与高频写入,观察分配率和 GC;结果会直观看出它为何只适合读多写少。
第三个实验让生产者速率高于消费者,比较无界 ConcurrentLinkedQueue 与有界 ArrayBlockingQueue:前者队列和堆持续增长,后者在容量边界处通过 offer 失败或阻塞暴露过载。
这些实验必须有超时保护和最终断言,不能为了制造竞争让测试自己永久挂住。
线程安全容器的评审问题
单次操作安全是否被误写成一串复合操作安全?跨 key 或遍历是否要求一致快照,当前容器是否提供?读写比例、集合大小、热点分布和扩容成本如何?
队列是有界还是无界,满时阻塞、失败还是丢弃?元素交接后谁拥有修改权,是否仍会并发写对象内部?创建失败、替换、过期、删除和停机由谁清理资源?
指标能否看到 key 数、队列长度、等待时间、拒绝和内存增长?
两份实验先证明复合语义,再谈容器性能
完整源码位于 examples/backend-development/concurrency/concurrent-containers/。ConcurrentContainersDemo.java 验证原子创建和 CopyOnWrite 快照;ConcurrentMapFailureDemo.java 稳定暴露递归 compute 与弱一致迭代。进入目录执行:
mkdir -p out
javac --release 17 -Xlint:all -Werror -d out ConcurrentContainersDemo.java ConcurrentMapFailureDemo.java
java -cp out ConcurrentContainersDemo
java -cp out ConcurrentMapFailureDemo稳定输出为:
singleCreation=true snapshotSize=1 currentSize=2
recursive-update=IllegalStateException
iterator=v1, current=v2snapshotSize=1 currentSize=2 证明迭代快照与当前集合可以同时正确却不同,不能把弱一致结果误判为数据丢失。工程验收需要按容器职责分开:注册表断言同一 key 的外部资源只创建一次且失败不会留下半成品;CopyOnWrite 记录写频率、集合大小、复制分配与 GC;任务队列记录生产/消费速率、最老任务年龄、容量占用和拒绝。队列长度持续增长时,即使每次 offer 都线程安全,系统仍未满足容量合同。
先把这些语义写清,再选择容器,通常比记住某版源码的所有字段更能防事故。
可继续核对 ConcurrentHashMap、CopyOnWriteArrayList 与 BlockingQueue 的一致性和快照说明。
