集合迭代安全:不是不让你改,是改的时候必须让迭代器知道(026)

集合框架、泛型与函数式编程约 10 分钟阅读5,268 字符
知识库文档 27 / 251

覆盖考点:026|集合迭代安全:fail-fast 与 fail-safe、modCount 机制与漏检陷阱

适合读者:高级 Java 工程师、技术专家、架构师候选人

预计阅读时长:22 分钟(文末问答不计入)

上一篇讲的 BlockingQueue 解决的是并发环境下"生产者-消费者"该怎么协作的问题,这一篇要讲的是几乎每个 Java 工程师都踩过的一个坑:遍历集合的时候修改集合,为什么有时候直接报错,有时候悄无声息地漏掉数据。很多人以为 ConcurrentModificationException 只会在多线程场景下出现,但即使单线程环境下,只在 for-each 里直接调用一次 remove(),异常照样会抛出。这篇要讲清楚 JDK 用什么机制检测"遍历时被修改",fail-fast 和 fail-safe 两种策略分别是怎么实现的,以及一个连很多资深工程师都会忽略的漏检陷阱。

面试官在这个考点上通常这样追问:为什么在 for-each 里直接调用 remove() 会抛出 ConcurrentModificationException → 这个异常是不是只在多线程场景下才会出现 → fail-fast 检测机制的原理是什么,modCount 具体是怎么工作的 → 正确删除元素应该怎么写 → fail-fast 检测是不是百分之百可靠 → fail-safe(弱一致性)容器是怎么规避这个问题的。本文按这条链展开。

一、为什么会有"迭代安全"问题

遍历一个集合的过程中,如果集合的结构被修改(增加或删除元素),会发生什么?直觉上大家最容易踩的坑是这样写:

java
for (String s : list) {
    if (s.equals("target")) {
        list.remove(s); // 抛出 ConcurrentModificationException
    }
}

这段代码在运行时会直接抛出 ConcurrentModificationException(简称 CME)。很多人第一次遇到这个异常时会以为是并发问题——但即使这段代码从头到尾只有一个线程在跑,异常照样会抛出。这就是"迭代安全"要解决的核心问题:集合的迭代器在遍历过程中,如何应对集合结构被修改(无论是同一线程还是别的线程改的)。

JDK 对这个问题给出了两种完全不同的应对策略:fail-fast(快速失败)和 fail-safe(安全失败,更准确的说法是"弱一致性")。理解这两种策略的实现原理,才能真正明白什么时候该用哪个集合,以及为什么某些看起来"合理"的写法会在生产环境里炸掉。

文章配图
文章配图

二、fail-fast:用一个计数器"抓现行"

ArrayList、HashMap、HashSet 等常规集合用的都是 fail-fast 策略,核心机制是一个叫 modCount 的计数器:

  • 集合内部维护 modCount,每次结构性修改(add/remove,注意仅通过 set() 改值不算结构性修改)都会让 modCount 自增。
  • 创建迭代器时,迭代器会把当前的 modCount 值存一份快照,记为 expectedModCount。
  • 每次调用迭代器的 next()(ArrayList 还包括 remove())时,都会先执行 checkForComodification():比较当前的 modCount 和 expectedModCount 是否相等,不相等就立刻抛出 ConcurrentModificationException。

也就是说,list.remove(s) 直接改了 list 的 modCount,但迭代器手里的 expectedModCount 还是旧值——下一次调用 next() 检测到两者不一致,直接抛异常。这是一种"尽力检测、快速暴露问题"的策略,而不是真正的并发控制手段:它检测到的只是"有没有发生结构修改",并不能阻止修改本身,也不能保证100%检测到所有修改(后面会讲到一个经典的漏检陷阱)。

文章配图
文章配图

三、正确的删除方式:Iterator.remove()

既然直接用集合的 remove() 方法会破坏 modCount 和 expectedModCount 的一致性,正确的做法是用迭代器自己提供的 remove() 方法:

java
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String s = it.next();
    if (s.equals("target")) {
        it.remove(); // 正确:迭代器内部会同步更新 expectedModCount
    }
}

Iterator.remove() 在删除元素之后,会把当前最新的 modCount 重新赋值给 expectedModCount,让两者重新保持一致,所以下一次 next() 的检测不会误报。这也是为什么 Java 的 for-each 语法糖底层虽然也是用迭代器实现的,但没有暴露 remove() 方法给你直接调用——你必须显式拿到 Iterator 对象,手动调用它的 remove()。

文章配图
文章配图

四、fail-fast 不是万能的:一个经典的漏检陷阱

fail-fast 只是"尽力检测",并不保证一定能检测到修改。最经典的例子:一个 5 个元素的 ArrayList,在 for-each 中删除倒数第二个元素(索引 3):

java
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
for (Integer i : list) {
    if (i == 4) {
        list.remove(i); // 删除的正好是倒数第二个元素
    }
}
// 不会抛异常!但实际上元素 5 被跳过了,没有被遍历到

原因在于 ArrayList 迭代器的 hasNext() 判断逻辑是 cursor != size,而不是去检测 modCount——modCount 的检测只发生在 next() 里。删除索引 3 的元素后,size 从 5 减到 4,而 cursor 此时也恰好是 4,hasNext() 判断 4 != 4 为假,循环直接结束,根本不会再调用一次 next() 去触发 checkForComodification()——异常被"跳过"检测了,但代价是最后一个元素 5 被永远漏掉,遍历结果不完整。这个陷阱的可怕之处在于:它不报错,却悄悄产生了错误的业务结果,比异常更难排查。

文章配图
文章配图

五、fail-safe(弱一致性):迭代器只认自己手里的快照

CopyOnWriteArrayList、ConcurrentHashMap 等并发容器走的是另一条路线,通常被称为 fail-safe,更严谨的说法是"弱一致性迭代器":

  • CopyOnWriteArrayList 的迭代器在创建时,直接持有当时那份底层数组的引用作为快照。之后不管 list 本身发生多少次写操作(每次写都会生成新数组并替换引用),迭代器手里的这份快照完全不受影响,遍历过程中永远不会抛 ConcurrentModificationException。
  • ConcurrentHashMap 的迭代器不持有完整快照,但保证遍历时不会抛 CME,也不会重复遍历同一个桶两次,遍历过程中即使有并发的增删,读到的数据可能是修改前的、也可能是修改后的(弱一致性),但绝不会导致遍历本身崩溃或死循环。

这里必须澄清一个常见误解:fail-safe 不代表"数据更准确",恰恰相反——它牺牲的是数据的实时性,换来的是遍历过程本身不会出错。如果你的业务逻辑要求"必须读到遍历那一刻集合的绝对最新状态",fail-safe 容器反而不满足这个要求。

文章配图
文章配图

六、fail-fast 与 fail-safe 对比

典型集合 检测机制 并发修改时的表现 代价
fail-fast ArrayList、HashMap、HashSet modCount 计数器比对 抛出 ConcurrentModificationException 检测不保证100%,且完全不能在多线程场景下当并发控制手段用
fail-safe(弱一致性) CopyOnWriteArrayList、ConcurrentHashMap 快照隔离 / 分段遍历 不抛异常,正常遍历完成 遍历到的数据可能不是最新的

七、常见误区

  1. 以为 CME 只会在多线程场景下发生。 单线程 for-each 里直接调用 list.remove() 一样会抛,这跟并发完全无关,纯粹是 fail-fast 检测机制在起作用。
  2. 以为给 for-each 循环加**synchronized**就能避免 CME。synchronized 只能保证同一时刻只有一个线程在访问,但如果这个线程自己在遍历时修改了集合,一样会触发 fail-fast 检测,synchronized 解决不了这个问题。
  3. 误以为 fail-fast 检测很可靠,可以拿来判断"集合是否被修改过"。 前面倒数第二个元素的例子已经证明了这一点是错的——fail-fast 只是尽力而为,漏检的场景是真实存在的。
  4. 以为 fail-safe 容器遍历到的数据一定是最新的。 恰恰相反,fail-safe 换来的是"不会崩",代价是可能读到旧数据,这是设计上的取舍,不是缺陷。

八、面试追问链

文章配图
文章配图

延伸阅读

  • ArrayList.Itr、AbstractList.Itr 源码中的 checkForComodification() 实现
  • CopyOnWriteArrayList.COWIterator、ConcurrentHashMap 迭代器源码
  • 上一期:《阻塞队列体系:不是队列满了才处理,是从设计上就不让你空转》

问答(不计入阅读时长)

Q1:为什么在 for-each 里直接调用 list.remove() 会抛出 ConcurrentModificationException?

A:for-each 底层用的还是迭代器,迭代器在创建时把当时的 modCount 存了一份快照 expectedModCount。list.remove() 会让集合真实的 modCount 自增,但不会通知迭代器同步更新它手里的快照。下一次调用迭代器的 next() 时,会先比较 modCount 和 expectedModCount,发现不一致,就抛出异常——这是一种"检测到结构被意外修改"的保护机制。

Q2:这个异常是不是只在多线程场景下才会出现?

A:不是,这是最容易被误解的一点。fail-fast 检测的是"结构修改发生了没有",跟是不是多线程完全无关。单线程里自己在遍历时调用 list.remove(),一样会触发这个检测并抛出异常。

Q3:正确删除元素应该怎么写?

A:拿到 Iterator 对象,调用它自己的 remove() 方法,而不是调用集合本身的 remove()。Iterator.remove() 在完成删除之后,会把最新的 modCount 重新赋值给 expectedModCount,让两者保持同步,所以后续的 next() 调用不会误报异常。

Q4:为什么 for-each 语法糖不直接暴露删除方法给我们用?

A:因为 for-each 只是编译器帮你自动生成了 hasNext()/next() 的调用代码,它不知道你想不想删除元素,也没有暴露迭代器变量给你操作。如果你需要在遍历时删除元素,就必须放弃 for-each 语法糖,显式地拿到 Iterator 对象来调用它的 remove()。

Q5:fail-fast 检测是不是百分之百可靠?

A:不是。经典的反例是:一个 5 个元素的列表,在 for-each 中删除倒数第二个元素。删除后 size 减 1,恰好和迭代器的 cursor 相等,导致 hasNext()(判断依据是 cursor != size)直接返回 false,循环提前结束,next() 从未被再调用一次,checkForComodification() 根本没有机会执行。结果是不抛异常,但最后一个元素被静默漏掉了,这比抛异常更危险,因为它不会被立刻发现。

Q6:CopyOnWriteArrayList 的迭代器为什么不会抛这个异常?

A:因为它走的不是 modCount 检测这条路。它的迭代器创建时直接持有当时那份底层数组的引用作为快照,之后不管原集合发生多少次写操作(每次写都是生成新数组再替换引用),迭代器手里的这份快照完全不受影响,遍历过程中根本不需要检测"有没有被修改",自然也不会有 CME 这种问题。

Q7:ConcurrentHashMap 的迭代器遍历时读到的数据一定是最新的吗?

A:不一定。它保证的是"弱一致性":遍历过程不会崩溃、不会抛异常、不会重复遍历同一个桶两次,但如果遍历过程中有其他线程在修改,你读到的具体数据可能是修改前的状态,也可能是修改后的状态,这是设计上主动做的取舍,用"可能读到旧数据"换来"遍历绝对不会出错"。

Q8:加了 synchronized 包裹 for-each 循环,能避免 CME 吗?

A:不能。synchronized 解决的是"同一时刻只允许一个线程访问"的问题,但如果这个持有锁的线程自己在遍历过程中调用了集合的 remove(),一样会触发 fail-fast 检测机制抛出异常——这跟有没有多线程竞争完全无关,synchronized 从根本上就管不到这个问题。