迭代一致性
迭代一致性描述遍历与更新同时发生时,读者能看到哪些元素和状态。本文先比较弱一致遍历与固定结构快照,再解释聚合结果和一致快照协议。最后说明条件删除与复制并发 Map 的边界。内容采用 Java 21 API。
1. 面试核心
先记住这几条,再读正文- 弱一致迭代。 ConcurrentHashMap 使用弱一致迭代,遍历可以与更新同时进行,并看到其中部分更新。卡 · 并发遍历的视图需求
- 快照迭代。 CopyOnWriteArrayList 的迭代器固定创建时的引用数组,后续增删与本次遍历隔离。
- 聚合结果。 ConcurrentHashMap 在并发更新期间执行
size、forEach或reduce,结果可以混合多个时刻的状态。 - 一致快照。 所有访问遵守同一锁时,锁内复制可取得一致的结构快照。卡 · 并发 Map 的复制边界
- 条件删除。
remove(key, value)按值相等条件删除,使用不可变值并将版本号纳入equals可以识别版本替换。卡 · 遍历中的条件删除
2. 并发遍历的视图需求
遍历前,先确定业务需要观察怎样的状态。以下四类视图需求对同步的要求不同:
- 允许近似观察:监控、日志和后台统计可接受漏掉刚写入的元素。
- 固定结构快照:一次广播只处理开始时已有的监听器。
- 线性一致快照:结果必须对应某个明确瞬间。
- 跨多个数据结构一致:集合、计数和外部状态必须属于同一版本。
本文讨论的并发容器直接提供前两类视图。后两类要求把相关更新作为整体协调,可以使用共同锁、不可变版本或数据库事务。也可以由单个写线程统一处理更新和快照请求,让读取对应一次完整更新之后的状态。
3. ConcurrentHashMap 的弱一致遍历
ConcurrentHashMap 允许扫描期间继续更新,适合持续变化的共享映射。下面遍历会话时,其他线程仍可以新增或删除会话:
for (Map.Entry<String, Session> entry : sessions.entrySet()) {
inspect(entry);
}
这种遍历称为弱一致遍历:迭代器能观察部分并发更新,也会错过已经经过位置的后续变化。它沿内部节点前进,无需复制整个 Map,也无需把所有写线程锁住。并发修改属于支持的行为,因此不会触发 ConcurrentModificationException。
允许近似观察的业务可以使用它,例如:
- 监控指标只需反映当前趋势,可以接受少量并发变化带来的差异。
- 后台清理先扫描候选,再用条件原子方法确认是否仍应删除。
- 管理页面展示非事务列表时,可以接受条目来自不同读取时刻。
例如,遍历得到的总和与随后调用 size 得到的数量,来自两次独立观察。两者之间的差异可以由并发更新造成,直接比较它们会误判业务状态。严格约束需要让相关读取对应同一次完整更新后的状态。
4. CopyOnWrite 的数组快照
CopyOnWrite 适合一次遍历只处理开始时已有元素的场景,例如向当时注册的监听器广播。迭代器固定创建时的引用数组,所以中途新增和删除的元素与本次遍历隔离。元素对象本身仍然共享,可变字段需要另行同步。
数组快照把复制成本放到写入时,长期持有迭代器还会保留旧数组。因此,大列表或高频更新的成本较高,机制见 CopyOnWrite 容器。
5. 并发队列的迭代
并发队列需要区分观察任务与领取任务。ConcurrentLinkedQueue 的迭代器是弱一致的,下面的循环只观察元素,任务仍留在队列里:
for (Job job : queue) {
observe(job); // 不会把 job 从队列移除
}
多个消费者应通过原子的 poll 领取任务,BlockingQueue 还可以用 take 等待并领取。遍历找到元素后再删除,会在两个动作之间留下其他消费者抢先领取的机会。
PriorityBlockingQueue 和 DelayQueue 的遍历也没有排序保证,所以观察到的次序可以与实际出队次序不同。
6. 聚合操作的一致性
统计整个 Map 时,要区分单个值有效与整体结果来自同一时刻。ConcurrentHashMap 提供 forEach、search 和 reduce 等批量操作,也支持并行执行。它们可以在更新期间运行,但最终结果会组合不同时间读到的值:
long total = counters.reduceValuesToLong(
1_000,
LongAdder::sum,
0L,
Long::sum
);
上例把各个 LongAdder 的观察值相加,适合近似统计。这里的 LongAdder 是分散并发计数竞争的累加器,它的 sum 本身也会受到并发更新影响。参数 1_000 用作尝试并行执行的规模阈值。
mappingCount、size 和 isEmpty 同样只是各自调用期间的观察。先检查数量再插入时,其他线程可以在两次调用之间增加元素。严格容量限制需要把检查与修改放进同一协议,例如在同一锁内维护集合与容量,或直接使用有界队列。
使用独立计数器时,还要协调名额预占、插入失败时归还名额和删除后的释放。单独让计数器原子递增,仍不足以保证集合的容量上限。
7. 一致快照协议
业务要求同一版本的完整状态时,需要在集合之外协调读取与更新。下面几种方式分别适合共享可变状态、不可变配置和跨进程数据。
7.1 外部锁
所有读写都遵守同一锁时,可以在锁内复制:
synchronized (stateLock) {
return Map.copyOf(mutableState);
}
所有读写入口都需获取 stateLock,这样复制期间就没有其他线程修改源数据。ConcurrentHashMap 的内部方法使用自己的同步机制,因此使用外部锁时还要统一封装访问入口。
Map.copyOf 要求键和值均非空,得到的结果固定映射结构。值对象仍与原 Map 共享,因此要保存完整状态快照,还需使用不可变的键和值。
7.2 不可变状态与原子替换
读多写少、状态大小可控的配置,可以通过完整版本发布。写者先基于旧值构造新的不可变状态,再原子替换引用:
AtomicReference<State> state = new AtomicReference<>(State.empty());
读者只读取一次引用,就能从这个对象中访问同一版本的多个字段。多个写者同时基于旧值更新时,需要用 CAS 检测冲突。CAS 即比较并交换:只有引用仍是预期的旧值时才替换,否则重新计算,避免覆盖其他写者的更新。
7.3 版本号与重试
版本校验让读者检查读取期间是否有写入,发生冲突时重新读取。它必须配合完整的写入和可见性协议。仅在写完后递增计数,会让读者漏掉正在进行的修改,读到一半新、一半旧的状态。
自定义版本协议还要处理版本号回绕,也就是计数达到上限后再次使用旧数值。写入频繁时,读取还会反复失败,导致长期无法完成。Java 可以使用 StampedLock 的乐观读,并在校验失败后加读锁。
乐观读适合允许先读取再校验的字段;遍历可变集合时,复制过程本身也需要安全保证。因此,复制这类集合时直接在锁内完成更清楚。
7.4 数据库或事件日志
涉及持久化或跨进程数据时,需要让数据存储系统确定读取版本。数据库可以通过事务隔离提供一致读取,事件日志可以按确定的日志偏移构造快照。这样,结果对应已处理到某个位置的完整状态,单进程集合只负责保存本地结果。
8. 遍历中的条件删除
扫描到过期会话后,另一个线程可以在删除前刷新它。因此,后台清理需要把扫描结果当作候选,再通过条件删除确认:
sessions.forEach((id, session) -> {
if (session.expired(clock.instant())) {
sessions.remove(id, session);
}
});
remove(id, session) 会原子地比较并删除:当前值与观察值按 equals 相等时才删除。这里比较的是相等关系。换了对象但新旧值仍相等时,删除仍会成功;原地修改同一个对象也无法检测版本变化。
可以把会话定义成不可变值,并让版本号参与 equals。刷新时替换成新版本,这时旧扫描结果与当前值不同,条件删除就会失败。
清理还涉及数据库租约、文件或远程资源时,Map 删除只完成了其中一步。外部操作需要幂等,也就是重复执行仍得到同一结果。部分操作失败时还需要重试或补偿,恢复各项资源的一致状态。
9. 并发 Map 的复制边界
把 ConcurrentHashMap 复制成 HashMap,可以独立保存复制得到的条目。new HashMap<>(concurrentMap) 会逐项读取源映射,所以并发写入期间的副本可以混合多个时刻的状态。复制完成后结构独立,只说明后续增删互不影响。
要固定一次导出的状态,可以让所有更新与复制都遵守同一把锁。另一种方案是发布完整的不可变 Map,让读者一次读取引用取得一个版本。具体做法见一致快照协议。
10. 常见问题
10.1 弱一致遍历的观察范围
弱一致允许遍历与更新同时进行,一次迭代可以遗漏期间发生的变化。迭代器沿节点向前访问,已经经过的位置发生更新时,它无需返回重新扫描。后续的新读取按容器的可见性契约观察已完成更新。若业务需要追踪全部变化,应使用事件日志或版本协议。
10.2 toArray 的复制结果
ConcurrentHashMap 集合视图的 toArray 会把遍历结果复制到独立数组,便于后续单独遍历。复制期间源 Map 仍在变化时,数组内容可以混合多个时刻的状态,无法据此断定调用开始或结束时的完整内容。严格快照需要让复制与全部写入遵守共同的同步协议。
10.3 并发迭代与修改检测
并发集合把更新与遍历同时发生当作正常用途,因此用弱一致或快照契约定义可观察状态。fail-fast 则在检测到意外结构修改时尽快抛出异常,适用于另一种使用约定。修改计数 modCount 只帮助检测错误,无法固定一份一致快照。
普通集合的修改检测见 集合迭代器。
10.4 结构快照与元素字段
结构快照固定的是元素引用,元素字段仍由原对象保存。对象被其他线程修改时,遍历也会受到影响。只要求字段安全访问时,可以使用元素自身的同步协议;要求完整状态快照时,则需不可变元素或在同步保护下复制字段。
11 · 面试题卡片
11.1 ConcurrentHashMap 的迭代器是什么一致性
出现公司:快手、阿里巴巴
考察重点
- 说明弱一致遍历允许观察什么。
- 解释为什么并发更新不会触发
ConcurrentModificationException。 - 说明近似统计与严格快照怎样区分。
相关内容:遍历需要的视图、ConcurrentHashMap 的弱一致遍历、聚合操作的一致性。
参考回答
ConcurrentHashMap 的迭代器是弱一致的,可以与更新同时进行,并观察其中部分变化。
更新属于支持的用途,因此不会触发 ConcurrentModificationException。迭代器已经经过的位置发生变化时,这次遍历可以错过它,所以结果可以来自多个时刻。
这种语义适合监控与可再次确认的后台候选扫描。业务需要精确快照时,应让全部写入与复制使用共同锁,或发布完整的不可变状态。涉及持久数据时,也可以由数据库事务确定读取版本。
11.2 弱一致迭代时怎样安全删除过期数据
出现公司:阿里巴巴
考察重点
- 说明扫描结果可能在使用前已经过期。
- 说明条件 remove 或 replace 怎样避免覆盖新值。
- 说明外部副作用为何还需要幂等和补偿。
相关内容:遍历中的条件删除。
参考回答
可以用 remove(key, observedValue) 条件删除,并让不可变值的版本号参与 equals。
扫描先得到候选值,删除时再原子地比较当前值。其他线程刷新为新版本后,两者不再相等,旧扫描结果就无法删掉新版本。
只更换对象但保持相等,或原地修改同一个对象,都会绕过这种版本检测。清理涉及数据库、文件或远程租约时,还要用幂等操作和补偿处理部分失败。
11.3 复制 ConcurrentHashMap 就能得到同一时刻的快照吗?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:区分复制后的结构独立与复制期间状态一致,并说明取得一致快照所需的同步。
相关内容:并发 Map 的复制边界。
参考回答
并发更新时,复制 ConcurrentHashMap 无法保证得到同一时刻的快照。
复制逐项读取源 Map,中间穿插更新时,就会组合多个时刻的观察。独立副本只隔离复制完成后的结构变化。
一致快照需要让全部更新与复制遵守共同锁,或者一次读取已经发布的完整不可变版本。值对象本身也应不可变,才能让字段状态一起固定。