← 返回题目列表

什么是 fail-fast 和 fail-safe?

中等 第 18 / 30 题 更新于 2026/07/25
迭代器并发修改集合

简化版

fail-fast(快速失败):迭代过程中发现集合被「非迭代器方式」结构性修改了,立刻抛 ConcurrentModificationException,尽早暴露问题。ArrayListHashMap 是这种。fail-safe(安全失败):遍历的是副本或弱一致性视图,不抛异常,但也可能看不到遍历期间的最新修改。CopyOnWriteArrayListConcurrentHashMap 是这种。

详细版

fail-fast 怎么实现的ArrayListHashMap 内部维护一个 modCount(结构性修改计数)。创建迭代器时,它记下当前的 modCount 作为「预期值」;每次 next() 都检查实际 modCount 是否还等于预期值,不等就说明遍历期间有人改了集合,抛 ConcurrentModificationException

// ❌ 遍历时直接 remove → 改了 modCount → 抛 ConcurrentModificationException
for (String s : list) {
    if (s.isEmpty()) list.remove(s);
}

// ✅ 用迭代器自己的 remove,它会同步更新预期值
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (it.next().isEmpty()) it.remove();
}

fail-safe 怎么实现的

  • CopyOnWriteArrayList:每次写操作都复制一份新数组,迭代器拿的是创建那一刻的快照。遍历期间的修改它看不到,但绝不抛异常。
  • ConcurrentHashMap 的迭代器是弱一致性的:允许边遍历边修改,遍历期间新增的元素可能看到、也可能看不到,但保证不抛异常、不重复、不丢已有元素。

完整版教学

一、fail-fast 不是「并发安全机制」,是「bug 探测器」

这是最容易误解的点。ConcurrentModificationException 名字里带 “Concurrent”,让人以为它是用来保证并发安全的——恰恰相反。它是一种「尽力而为」的错误检测:帮你在开发阶段尽早发现「遍历时又改了集合」这个编程错误,而不是保证你能安全并发。

而且它连检测都不保证 100% 准确:modCount 不是 volatile,多线程下存在竞争窗口,有可能漏检。所以:它只是帮你早点发现单线程里的低级错误,别指望它守护并发安全

二、单线程也会抛,不需要多线程

很多人以为 ConcurrentModificationException 只在多线程出现。其实上面那个 for-each 里 remove 的例子,单线程就会抛。因为「for-each 底层是迭代器」,你用 list.remove() 绕过迭代器改了 modCount,迭代器一检查就翻脸。这是它最高频的现实触发场景。

三、正确的「遍历时修改」姿势

  • 迭代器 removeit.remove()——迭代器会同步更新自己的预期 modCount。
  • removeIf(Java 8+)list.removeIf(String::isEmpty)——最简洁,内部正确处理。
  • 收集后再删:先把要删的放另一个集合,遍历完统一 removeAll
  • 倒序 for-i 循环:用下标从后往前删,避开迭代器(适合 ArrayList)。

四、fail-safe 的代价与适用

fail-safe 不抛异常,但不是免费的:

  • CopyOnWriteArrayList 每次写都全量复制数组,写开销极大,只适合「读极多、写极少」(如配置、监听器列表);且迭代器看不到遍历开始后的新数据(弱实时性)。
  • ConcurrentHashMap 的弱一致性迭代器几乎无额外开销,适合高并发读写,但你必须接受「遍历结果不是某个精确时刻的快照」。

记忆点:fail-fast 用「一致性换及时报错」,fail-safe 用「实时性换不抛异常」。选哪个取决于你更怕「漏改」还是更怕「异常中断」。

五、什么算结构性修改,检测窗口在哪里

modCount 记录会改变集合结构、可能让迭代位置失效的操作,例如 ArrayList 的 add/remove 或 HashMap 新增/删除键。单纯把 ArrayList 某个索引的元素替换为另一个对象,通常不改变 size 和布局,因此 set(index, value) 一般不是结构性修改,不触发同样的 modCount 变化。

创建迭代器:expectedModCount = 7
外部 add:  modCount = 8
下一次 next:8 != 7 → ConcurrentModificationException
迭代器 remove:modCount = 9,同时 expectedModCount = 9 → 可继续

检查通常发生在 next()remove() 等迭代器方法执行时,而不是外部修改发生的瞬间。因此错误可能在下一轮才暴露;如果修改后没有继续驱动迭器,也可能不抛。规范明确把 fail-fast 定义为 best-effort,业务正确性绝不能依赖“必然抛异常”。

迭代模型修改期间的表现一致性与成本
ArrayList/HashMap fail-fast尽力检测并抛 CME不提供线程安全,开销低
CopyOnWriteArrayList 快照旧迭代器看不到新写入遍历稳定,写入复制 O(n)
ConcurrentHashMap 弱一致可能看到部分并发更新不停顿全表,不保证单时刻快照
手工复制后遍历遍历独立副本复制时间和额外内存 O(n)

若业务要求某一精确时刻的全量一致快照,弱一致迭代器并不满足,需要在合适同步边界复制数据或使用数据源提供的快照机制。

六、常见误区与追问

  • 误区:ConcurrentModificationException 只会在多线程出现。 单线程 for-each 中直接调用集合 remove 就足以触发。
  • 误区:没有抛 CME 就证明并发访问安全。 fail-fast 是尽力检测,存在漏检窗口,也不提供可见性和原子性。
  • 误区:所有元素值变化都会改变 modCount。 结构性修改关注大小或布局,ArrayList 的 set 通常不属于结构变化。
  • 追问:为什么 iterator.remove 可以安全删除? 它通过迭代器自身调整游标并同步 expectedModCount,内部状态不会失配。
  • 追问:CopyOnWriteArrayList 为什么不抛 CME? 迭代器持有旧数组快照,写线程发布的是另一份新数组,双方不修改同一结构。
  • 追问:ConcurrentHashMap 迭代结果是否等于开始或结束时快照? 都不保证,它是弱一致遍历,可能混合观察遍历期间的状态。
List<String> snapshot;
synchronized (sharedList) {
    snapshot = new ArrayList<>(sharedList);
}
snapshot.forEach(this::process); // 在锁外处理一致副本

上例适用于确实需要稳定副本且能统一使用同一把锁的情况。复制完成后尽快释放锁,避免把耗时业务处理放在集合锁内。

记忆钩子:fail-fast 的“快”是尽快暴露迭代器失效,不是更快的并发算法;fail-safe 的“安全”也只是遍历不中断,不代表数据绝对最新。

并行流也会通过 Spliterator 拆分遍历区间,底层集合的并发修改语义不会因此消失。对普通 ArrayList 一边执行 parallelStream 一边结构修改,既不是安全并行,也不能依赖 CME 兜底。

List<Result> inputSnapshot;
synchronized (shared) {
    inputSnapshot = List.copyOf(shared);
}
return inputSnapshot.parallelStream().map(this::compute).toList();

先明确创建不可变快照,再并行处理,能把结构一致性与计算并行度分开,代价是 O(n) 复制和额外内存。

七、加强记忆

fail-fast 用 modCount 与 expectedModCount 比较结构版本,发现迭代器之外的结构修改便尽力抛出 CME;它单线程也会触发,却既不保证必然检测,也不提供线程安全。快照迭代通过复制获得稳定旧视图,弱一致迭代则允许并发推进但不承诺精确时刻。遍历中删除优先 iterator.remove、removeIf 或明确同步后的快照,并按业务对实时性、一致性和复制成本的要求选择模型。