Java迭代器的fail-fast机制——防止遍历时 index 错位,写出「静默出错」的代码

最近也是在学习Go语言,然后发现Go没有这个机制因此你如果for...range和s = append(s[:i], s[i+1:]...)这种搭配起来就会出现错误,而Go不会显式的抛出这个错误需要你自己Debug出来,当然可能主要是Go的语言哲学主要是轻量,不会有这种检查机制

List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4));
for (Integer i : list) {        // 增强 for,底层是迭代器
    if (i % 2 == 0) {
        list.remove(i);         // ❌ 用集合本身的方法删除
    }
}
// 抛出 java.util.ConcurrentModificationException

单线程也会抛这个异常!如果没有这个fail-fast机制那么就会:

步骤cursor数组实际状态next() 返回结果
10→1[1,2,3,4]1奇数,保留
21→2[1,2,3,4]2偶数 → 删!数组变 [1,3,4],但 cursor 仍 = 2
32→3[1,3,4]4⚠️ 元素 3 被跳过了!

这样就会导致迭代器里面的元素被跳过啥的,就会导致遍历不完全的,因此Java不允许这样的事情发生就有了这个机制。

删掉 2 后,3 从下标 2 挪到了下标 1,而 cursor 已经走到 2,于是直接读到了 4——元素 3 被静默漏掉。这种「算错但不报错」的 bug 最难排查。fail-fast(下一节的 modCount)就是为了把它从「静默算错」变成「当场抛异常」。

fail-fast机制实现的原理

增强for循环的本质其实就是基于迭代器的语法糖,

Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
    Integer i = it.next();   // ← 异常在 next() 里抛出
    if (i % 2 == 0) {
        list.remove(i);
    }
}

而这个异常的来源也是Integer i = it.next();这里抛出来的错误

fail-fast ArrayList 内部维护的一个计数器 modCount(modification count,记录结构性修改次数,如 add/remove)

  • 调用 list.iterator() 时,迭代器把当前的 modCount 存进自己的 expectedModCount
  • 每次 it.next() 都会调用 checkForComodification() 检查两者是否相等。
  • list.remove() 会让 modCount++,但不会更新迭代器的 expectedModCount → 两者不等 → 抛异常。
// ArrayList 源码(简化)
final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

public E next() {
    checkForComodification();   // 每次取元素都校验
    // ... 返回元素
}

为什么不可以使用list.remove()这种方法呢?

关键点:expectedModCount 不是 ArrayList 的字段,而是迭代器(Itr)自己的私有字段。ArrayList 压根不知道它的存在,自然也改不了它。但是如果是迭代器自己去进行操作那么对应的索引就会自动对其,那么你就可以进行操作(是按照迭代器创建的时候的快照对其的)

变量属于谁作用
modCountArrayList(外部对象)记录集合被结构性修改的次数
expectedModCountIterator(迭代器实例,ArrayList 的内部类对象)迭代器创建那一刻,对 modCount 拍的「快照」
// list.remove():只改自己的 modCount,够不到迭代器的 expectedModCount
public boolean remove(Object o) {
    ...
    modCount++;   // ✅ 只动自己,没有任何一行去碰 expectedModCount
}

// it.remove():是"自己人",删完顺手把自己的快照也对齐
public void remove() {
    ArrayList.this.remove(lastRet); // 调用外部类删除,modCount++(如变成 5)
    expectedModCount = modCount;    // ✅ 自己同步,让两者重新相等
}

三层原因:

①够不到——list.remove() 执行时手里没有任何迭代器对象的引用,改不了它的私有字段;

②不知道改哪个——同一个 list 可同时存在多个迭代器,而 ArrayList 根本没维护「我有哪些迭代器」的列表;

③这就是本意——fail-fast 就是要让「绕过迭代器直接改集合」被发现并报错,如果list.remove() 真去同步了快照,fail-fast 反而失效了。

fail-fast 不能「防止」并发问题,而且主要是单线程中防止

fail-fast 不是线程安全机制,也不能解决并发问题——它防不住,也没打算防。它只是一个「尽力而为的检测器」。

根因还是一个细节:modCount 不是 volatile。

  • 真正的多线程场景下,一个线程对 modCount++ 的修改不保证对另一个线程立即可见(可能停在 CPU 缓存里)→ CME 可能漏检,也可能在不该抛时抛出。
  • 因此JDK 官方文档明确说:fail-fast 行为无法保证,绝不能编写依赖此异常来保证正确性的程序,它只应用于发现 bug
  • 它真正的价值:在「边遍历边改」这种写错的代码(尤其单线程误用)中尽早暴露问题,而不是默默返回错乱数据让 bug 潜伏。

正确的删除方式

方式 A:用迭代器自己的 remove()(它会同步更新 expectedModCount)

Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
    if (it.next() % 2 == 0) {
        it.remove();  // ✅ 内部会执行 expectedModCount = modCount
    }
}

方式 B:removeIf()(JDK 8+,最简洁)

list.removeIf(i -> i % 2 == 0); // ✅ 推荐

和Go的对比与思考

Go 没有 Java 的 fail-fast(无 modCount / CME)。

Go 的range 不是对象,它是语言关键字,编译后就是一个朴素的下标/指针循环。slice 本身只是个轻量值(指针 + len + cap 三个字段),没有任何隐藏的簿记字段,也没有一个"迭代器对象"能让你往里塞 modCount。

而没有迭代器对象因此Go的for..range会更轻量但是也会导致没有这种提醒机制来防止你不小心写错代码,还是性能和语言哲学的取舍吧

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值