跳到主要内容
约 9 分钟3 张卡

迭代一致性

迭代一致性描述遍历与更新同时发生时,读者能看到哪些元素和状态。本文先比较弱一致遍历与固定结构快照,再解释聚合结果和一致快照协议。最后说明条件删除与复制并发 Map 的边界。内容采用 Java 21 API。

1. 面试核心​

先记住这几条,再读正文
  • 弱一致迭代。 ConcurrentHashMap 使用弱一致迭代,遍历可以与更新同时进行,并看到其中部分更新。卡 · 并发遍历的视图需求
  • 快照迭代。 CopyOnWriteArrayList 的迭代器固定创建时的引用数组,后续增删与本次遍历隔离。
  • 聚合结果。 ConcurrentHashMap 在并发更新期间执行 size、forEach 或 reduce,结果可以混合多个时刻的状态。
  • 一致快照。 所有访问遵守同一锁时,锁内复制可取得一致的结构快照。卡 · 并发 Map 的复制边界
  • 条件删除。 remove(key, value) 按值相等条件删除,使用不可变值并将版本号纳入 equals 可以识别版本替换。卡 · 遍历中的条件删除

2. 并发遍历的视图需求​

遍历前,先确定业务需要观察怎样的状态。以下四类视图需求对同步的要求不同:

  1. 允许近似观察:监控、日志和后台统计可接受漏掉刚写入的元素。
  2. 固定结构快照:一次广播只处理开始时已有的监听器。
  3. 线性一致快照:结果必须对应某个明确瞬间。
  4. 跨多个数据结构一致:集合、计数和外部状态必须属于同一版本。

本文讨论的并发容器直接提供前两类视图。后两类要求把相关更新作为整体协调,可以使用共同锁、不可变版本或数据库事务。也可以由单个写线程统一处理更新和快照请求,让读取对应一次完整更新之后的状态。

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 的复制结果​

10.3 并发迭代与修改检测​

10.4 结构快照与元素字段​

11 · 面试题卡片​

11.1 ConcurrentHashMap 的迭代器是什么一致性​

出现公司:快手、阿里巴巴

考察重点

  • 说明弱一致遍历允许观察什么。
  • 解释为什么并发更新不会触发 ConcurrentModificationException。
  • 说明近似统计与严格快照怎样区分。

相关内容:遍历需要的视图、ConcurrentHashMap 的弱一致遍历、聚合操作的一致性。

参考回答

ConcurrentHashMap 的迭代器是弱一致的,可以与更新同时进行,并观察其中部分变化。

更新属于支持的用途,因此不会触发 ConcurrentModificationException。迭代器已经经过的位置发生变化时,这次遍历可以错过它,所以结果可以来自多个时刻。

这种语义适合监控与可再次确认的后台候选扫描。业务需要精确快照时,应让全部写入与复制使用共同锁,或发布完整的不可变状态。涉及持久数据时,也可以由数据库事务确定读取版本。

11.2 弱一致迭代时怎样安全删除过期数据​

出现公司:阿里巴巴

考察重点

  • 说明扫描结果可能在使用前已经过期。
  • 说明条件 remove 或 replace 怎样避免覆盖新值。
  • 说明外部副作用为何还需要幂等和补偿。

相关内容:遍历中的条件删除。

参考回答

可以用 remove(key, observedValue) 条件删除,并让不可变值的版本号参与 equals。

扫描先得到候选值,删除时再原子地比较当前值。其他线程刷新为新版本后,两者不再相等,旧扫描结果就无法删掉新版本。

只更换对象但保持相等,或原地修改同一个对象,都会绕过这种版本检测。清理涉及数据库、文件或远程租约时,还要用幂等操作和补偿处理部分失败。

11.3 复制 ConcurrentHashMap 就能得到同一时刻的快照吗?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:区分复制后的结构独立与复制期间状态一致,并说明取得一致快照所需的同步。

相关内容:并发 Map 的复制边界。

参考回答

并发更新时,复制 ConcurrentHashMap 无法保证得到同一时刻的快照。

复制逐项读取源 Map,中间穿插更新时,就会组合多个时刻的观察。独立副本只隔离复制完成后的结构变化。

一致快照需要让全部更新与复制遵守共同锁,或者一次读取已经发布的完整不可变版本。值对象本身也应不可变,才能让字段状态一起固定。

12. 参考资料​