" />
文章

Fail-Safe 与 Fail-Fast 机制分别有什么作用

Fail-Safe 与 Fail-Fast 机制分别有什么作用

面试指北 · 蔚来

题意

这道题表面是在问两个英文名词,实际考察的是:Java 集合在遍历过程中如果被修改,不同容器会采用什么处理策略,以及这些策略背后的原理、代价和适用场景。

也就是说,面试官通常希望你讲清楚:

  • 什么是 Fail-Fast,什么情况下会触发;
  • 什么是 Fail-Safe,它为什么不会抛 ConcurrentModificationException;
  • 两类机制分别对应哪些常见容器;
  • 它们和“线程安全”“强一致性”是不是一回事;
  • 实际开发中应该怎么选,容易踩哪些坑。

结论

Fail-Fast 和 Fail-Safe 是集合遍历时对“遍历期间集合被修改”的两种不同处理机制。

  • Fail-Fast:快速失败。 在遍历过程中一旦发现集合发生了结构性修改,就立刻抛出 ConcurrentModificationException,让问题尽早暴露。典型容器有 HashMap、ArrayList 等 java.util 下的常见集合。
  • Fail-Safe:失败安全。 遍历过程中即使原集合被修改,也不会抛出 ConcurrentModificationException。它通常通过快照或弱一致性遍历实现,因此遍历时可能看不到最新修改。典型容器有 CopyOnWriteArrayList、ConcurrentHashMap 等并发容器。
  • 关键区别: Fail-Fast 更强调“发现并发修改就报错”,Fail-Safe 更强调“允许并发修改继续遍历”。但要注意,Fail-Fast 不等于线程安全,Fail-Safe 也不等于强一致。

要点展开

1. Fail-Fast:快速失败,尽早暴露修改

Fail-Fast 的核心表现是:使用迭代器遍历集合时,如果集合在遍历过程中被结构性修改,迭代器会尽快抛出 ConcurrentModificationException,从而导致遍历失败。

例如定义一个 Map,用 Iterator 遍历,在遍历过程中向集合里新增元素:

Map<String, Integer> map = new HashMap<>();
map.put("a", 1);

Iterator<String> iterator = map.keySet().iterator();
while (iterator.hasNext()) {
    String key = iterator.next();
    if ("a".equals(key)) {
        map.put("b", 2); // 结构性修改,下一次迭代器检查时可能抛出 CME
    }
}

这种情况就属于 Fail-Fast。原文中的示意图如下:

img

它背后的常见原理是:集合内部维护一个类似 modCount 的修改次数,迭代器创建时记录一个期望值 expectedModCount。每次调用 next()、remove() 等操作时,迭代器会检查 modCount 是否还等于 expectedModCount,如果不等,就抛出 ConcurrentModificationException。

这里有几个容易忽略的点:

  • add、remove、clear、向 HashMap 中插入新 key 等,通常属于结构性修改;
  • 如果只是替换已有 key 的 value,一般不算结构性修改,不一定会触发 Fail-Fast;
  • 使用迭代器自己的 iterator.remove() 删除元素是允许的,因为它会同步更新 expectedModCount;
  • 但如果直接调用集合的 map.remove()、list.remove(),就可能触发异常。

java.util 包下的常见集合类大多采用快速失败机制,例如 HashMap、ArrayList、HashSet、LinkedList 等。它们适合在单线程场景下使用;如果在多线程环境共享修改,应该使用并发容器或额外加锁,而不是依赖 Fail-Fast 来保证安全。

2. Fail-Safe:失败安全,基于快照或弱一致性遍历

Fail-Safe 的核心表现是:遍历过程中修改集合,不会抛出 ConcurrentModificationException,遍历可以继续完成。

但“不抛异常”不代表“能看到最新数据”。Fail-Safe 通常有两种实现思路:

  1. 快照式遍历:遍历开始时基于原集合的一份拷贝进行,后续对原集合的修改不会影响这次遍历。CopyOnWriteArrayList 就是典型代表。
  2. 弱一致性遍历:迭代器不保证看到遍历开始后的所有修改,也不保证看不到部分修改,但不会因为并发修改而抛出 ConcurrentModificationException。ConcurrentHashMap 的迭代器就是弱一致性的。

以 CopyOnWriteArrayList 为例:

List<String> list = new CopyOnWriteArrayList<>();
list.add("A");

Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String value = iterator.next();
    if ("A".equals(value)) {
        list.add("B");
    }
    System.out.println(value);
}

System.out.println(list);

遍历过程中虽然向集合新增了 "B",但不会抛出异常。由于迭代器遍历的是创建迭代器那一刻的快照,所以本次遍历通常只打印 "A",而集合本身最后会包含 "A" 和 "B"。

原文中的示意图如下:

img

CopyOnWriteArrayList 的原理是“写时复制”:读操作直接读当前数组,写操作会加锁并复制出一个新数组,修改完成后再把引用指向新数组。这样读线程可以在旧数组快照上安全遍历,不会被写操作打断。代价是每次写都要复制数组,内存开销和写性能成本较高,因此更适合读多写少、数据量不大、允许最终一致的场景。

ConcurrentHashMap 则不是简单地复制整个 Map。它的迭代器是弱一致性的:遍历过程中不会抛出 ConcurrentModificationException,但也不保证反映遍历开始后的所有修改,size() 等结果在并发场景下也更接近估计值。它更适合高并发的键值读写场景。

原文提到“java.util.concurrent 包下的容器都是安全失败的”,可以理解为:这些并发容器在遍历时通常不会像 HashMap、ArrayList 那样抛 ConcurrentModificationException。但实现上要区分清楚:CopyOnWriteArrayList 是写时复制快照,ConcurrentHashMap 是弱一致性遍历,并不是所有并发容器都会完整复制一份集合。

3. 实际使用中怎么选

  • 如果是单线程遍历,或者希望尽早发现“边遍历边修改”的代码 bug,使用普通集合的 Fail-Fast 机制即可,让异常帮助定位问题。
  • 如果是读多写少、遍历频繁、写操作不频繁的场景,可以考虑 CopyOnWriteArrayList。
  • 如果是高并发键值读写场景,优先考虑 ConcurrentHashMap,接受迭代器的弱一致性。
  • 如果业务必须要求遍历期间看到一份完全稳定、强一致的快照,那么不能只依赖 Fail-Safe,需要根据场景加锁或在业务层复制集合。

易错点与对比

对比维度 Fail-Fast Fail-Safe
是否抛 ConcurrentModificationException 通常会抛 通常不抛
遍历对象 原集合 快照或弱一致性视图
是否能看到最新修改 单线程下一致,但并发下不可靠 可能看不到遍历开始后的修改
典型容器 ArrayList、HashMap、HashSet CopyOnWriteArrayList、ConcurrentHashMap
主要目的 尽早暴露并发修改问题 允许并发修改下继续遍历
是否等于线程安全 不等于 也不等于强一致

几个常见误区:

  1. Fail-Fast 不是线程安全机制。 它只是“尽力检测”修改,并不能保证一定发现所有并发问题,更不能替代锁或并发容器。
  2. ConcurrentModificationException 不只在多线程中出现。 单线程里边遍历边直接修改集合,同样会触发。
  3. 增强 for 循环底层也是迭代器。 所以 for (String s : list) { list.remove(s); } 也可能触发 Fail-Fast。
  4. Fail-Safe 不是强一致性。 它只是不抛异常,可能读到旧数据,也可能看不到新增元素。
  5. java.util.concurrent 下的容器不都是“复制整个集合”。 CopyOnWriteArrayList 是写时复制,ConcurrentHashMap 是弱一致性遍历。
  6. Fail-Safe 迭代器不一定支持修改。 例如 CopyOnWriteArrayList 的迭代器通常只读,调用 remove() 可能抛 UnsupportedOperationException。
  7. Fail-Fast 的检测不是绝对可靠的。 在并发环境下,它只能帮助尽早暴露问题,不能作为并发控制手段。

小结

面试时可以按这个顺序回答:

Fail-Fast 是快速失败机制,常见于 ArrayList、HashMap 等普通集合。迭代器通过 modCount 和 expectedModCount 检测遍历期间的结构性修改,发现不一致就抛 ConcurrentModificationException,目的是尽早暴露“边遍历边修改”的问题。
Fail-Safe 是失败安全机制,常见于 CopyOnWriteArrayList、ConcurrentHashMap 等并发容器。它通过快照或弱一致性遍历避免抛异常,但遍历期间可能看不到最新修改,不保证强一致。
两者不是简单的“线程安全/不安全”之分:Fail-Fast 不等于线程安全,Fail-Safe 也不等于强一致。实际使用时,要根据读写比例、数据一致性和性能要求选择。

一句话总结:Fail-Fast 是“发现修改就尽快报错”,Fail-Safe 是“允许修改继续遍历,但接受弱一致”。

License:  CC BY 4.0