什么是 fail-fast 和 fail-safe?
简化版
fail-fast(快速失败):迭代过程中发现集合被「非迭代器方式」结构性修改了,立刻抛 ConcurrentModificationException,尽早暴露问题。ArrayList、HashMap 是这种。fail-safe(安全失败):遍历的是副本或弱一致性视图,不抛异常,但也可能看不到遍历期间的最新修改。CopyOnWriteArrayList、ConcurrentHashMap 是这种。
详细版
fail-fast 怎么实现的:ArrayList、HashMap 内部维护一个 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,迭代器一检查就翻脸。这是它最高频的现实触发场景。
三、正确的「遍历时修改」姿势
- 迭代器 remove:
it.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 或明确同步后的快照,并按业务对实时性、一致性和复制成本的要求选择模型。