集合框架中fail-fast和fail-safe分别是如何实现的?

文章导读
平时写代码经常碰到 ConcurrentModificationException,这就是典型的 fail-fast 机制在“报警”。它设计出来不是为了修复问题,而是提前暴露风险。另一个是 fail-safe,它更倾向于“容忍遍历时的修改”,但代价是数据不一定实时准确。下面我根据自己的排查经验,拆一下这两种机制在 JDK 集合里的具体实现和适用边界。
📋 目录
  1. fail-fast 和 fail-safe 在集合框架中的实现机制
A A

fail-fast 和 fail-safe 在集合框架中的实现机制

平时写代码经常碰到 ConcurrentModificationException,这就是典型的 fail-fast 机制在“报警”。它设计出来不是为了修复问题,而是提前暴露风险。另一个是 fail-safe,它更倾向于“容忍遍历时的修改”,但代价是数据不一定实时准确。下面我根据自己的排查经验,拆一下这两种机制在 JDK 集合里的具体实现和适用边界。

fail-fast:通过 modCount 字段快速抛出异常

ArrayList、HashMap 等非并发集合使用 fail-fast。核心是一个 int 类型的 modCount 字段,记录结构修改次数(add、remove 等操作会加一)。当迭代器创建时,会记录当前的 modCount 到 expectedModCount 中。每次调用 next() 或 remove() 时,先检查两个值是否相等,不等就直接抛 ConcurrentModificationException。

这段逻辑在 AbstractList.Itr 和 HashMap.HashIterator 里都能看到。举个例子,遍历 ArrayList 时另一个线程执行了 add,modCount 变了,迭代器检测到不相等,立即报错。这种设计的好处是“尽早失败”,避免后续出现更诡异的数据不一致错误。缺点是单线程下如果自己用迭代器做了结构性修改(比如在 for-each 里调 list.remove),也会触发异常。

适用场景:大多数单线程迭代,或者你能保证迭代期间集合不被修改。如果你需要边遍历边修改,应该用迭代器自己的 remove 方法,或者用显式的锁控制。

操作动作:判断是否触发 fail-fast 可以看异常堆栈里有没有 modCount 的字样。如果想主动避免,可以用 CopyOnWriteArrayList 或 ConcurrentHashMap。

验证方式:写一个简单的测试:启动两个线程,一个遍历 ArrayList,另一个延迟 100ms 后 add 一个元素。观察是否抛出 ConcurrentModificationException。

集合框架中fail-fast和fail-safe分别是如何实现的?

风险边界:注意 modCount 是 int 类型,溢出后会从负值绕回,但实际很少遇到。另外,这种检测不依赖内存可见性(没有 volatile 或锁),所以多线程下即使没抛异常,也可能读到脏数据。fail-fast 只保证“检测到并发修改时快速失败”,不保证线程安全。

fail-safe:拷贝副本或弱一致性迭代

java.util.concurrent 包下的集合(如 CopyOnWriteArrayList、ConcurrentHashMap)采用 fail-safe 机制。它们不在原集合上遍历,而是基于一个“快照”或“弱一致性”视图。

以 CopyOnWriteArrayList 为例,它的迭代器(COWSubListIterator)内部持有一个数组快照,这个快照是创建迭代器时从原集合 copy 出来的。后续对原集合的 add、remove 操作,都会通过生成新数组(写时复制)来实现,迭代器拿到的旧数组不会被影响。所以遍历过程中集合被修改,迭代器不会抛异常,但读到的数据是迭代器创建时刻的“过期数据”。

ConcurrentHashMap 的实现不同:它的迭代器(KeyIterator、ValueIterator 等)遍历时直接访问底层 table,但通过分段锁或 CAS 保证遍历过程中看到的键值对不是“脏的”。如果遍历过程中其他线程插入了新节点,迭代器可能会看到,也可能看不到,取决于插入位置是否已经遍历过——这就是弱一致性。它也不会抛 ConcurrentModificationException。

适用场景:读多写少的场景用 CopyOnWriteArrayList(写操作复制数组成本高)。并发程度高且需要高吞吐的场景用 ConcurrentHashMap。

操作动作:如果看到迭代器一直不抛异常但数据不全,先检查集合类型是否是 Concurrent 系列。另外,使用 CopyOnWriteArrayList 时要注意迭代器持有的快照数量,如果写频繁,会生成大量数组对象,可观察 GC 日志。

集合框架中fail-fast和fail-safe分别是如何实现的?

验证方式:写一个类似上面的测试,用 ConcurrentHashMap 和 CopyOnWriteArrayList 对比。观察一边遍历一边修改时,前者不会抛异常,但可能看到部分新数据,后者完全看不到新数据。

风险边界:fail-safe 不是“安全”的保底,它牺牲了实时一致性。如果你需要严格的“读-读一致性”或“写后立即读可见”,fail-safe 可能不够。比如一个线程遍历完后根据结果做决策,另一个线程刚好修改了关键状态,而这个状态没有被遍历到——这可能导致业务逻辑错误。需要结合业务对一致性的容忍度来选择。

如何选择:看对实时性和修改频率的要求

我个人通常这么判断:如果迭代过程中不允许任何并发修改,或者我能通过同步块保证不冲突,就用 ArrayList/HashMap 这种 fail-fast 集合,尽早发现 bug。如果迭代操作是热点,且写操作很少(比如配置缓存、事件监听器列表),CopyOnWriteArrayList 很适合。如果高并发读写都需要高性能,ConcurrentHashMap 是最稳妥的。另外还要注意,fail-fast 的检测依赖于对 modCount 的预期,如果你自己通过反射修改了 modCount(某些库会这么干),会导致迭代器行为异常,慎用。

日志观察和回滚边界

线上遇到 ConcurrentModificationException,可以先从堆栈定位到集合变量,检查该集合是否被多个线程共享且没有同步。如果是单线程产生的,大概率是在 for-each 里调用了集合的 remove 方法。修复方式:用 Iterator.remove() 或者将需要删除的元素收集到另一个列表,遍历完后统一 remove。如果改成 CopyOnWriteArrayList,要评估写频率和内存开销:写操作多会导致频繁的数组复制和垃圾回收,可能需要调大堆内存或考虑其他方案。回滚时直接改回原来的集合类型,但要确保同步逻辑正确。

总之,fail-fast 和 fail-safe 是两种设计哲学,不是哪个更好,而是看业务场景对“尽早暴露问题”和“容忍不一致”的权衡。