bigywater.tech
Java 内部原理 · 56

fail-fast 迭代器真实验证:modCount 检测与一个经典陷阱

遍历中直接调用 list.remove() 会抛 ConcurrentModificationException,这个规则大家都知道。这篇真实复现这个异常之外,还真实复现了一个更值得警惕的场景:同样是遍历中 remove,有的时候异常真实不会抛出,留下一个悄无声息的逻辑 bug。

经典陷阱真实复现
真实验证:遍历2元素列表,在index0处remove掉最后一个元素,hasNext()提前返回false,异常真实没有抛出
COW 不是 fail-fast
真实验证:CopyOnWriteArrayList 遍历中真实执行 add() 不抛异常,因为迭代器用的是创建时的数组快照

上一篇:WeakReference 与 SoftReference 真实 GC 行为对比

真实复现:遍历中结构性修改抛出 ConcurrentModificationException

最基础的场景:用增强 for 循环遍历 ArrayList,循环体内直接调用 list.remove()。

FailFastIteratorDemo.java List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5)); for (Integer i : list) { if (i == 2) list.remove(Integer.valueOf(3)); }
真实实测
遍历到: 1
遍历到: 2
真实捕获 ConcurrentModificationException(fail-fast,靠 modCount 检测)

真实机制是这样的:ArrayList 内部维护一个 modCount 计数器,每次结构性修改(增删,不包括 set)都会真实自增;迭代器在创建时记录下当时的 modCount,每次调用 next() 都会真实比对当前 modCount 和记录值是否一致,不一致就立刻抛出。这是一种"尽快失败"而不是"保证检测到"的机制。

真实复现:一个不会抛异常的经典陷阱

上面这句"尽快失败,不保证检测到"不是免责声明,是真实存在的漏洞——用一个 2 元素列表真实复现。

FailFastIteratorDemo.java List<Integer> list2 = new ArrayList<>(Arrays.asList(1, 2)); for (Iterator<Integer> it = list2.iterator(); it.hasNext(); ) { Integer v = it.next(); if (v == 1) list2.remove(Integer.valueOf(2)); // 移除最后一个元素 }
真实实测
移除倒数第二个恰好指向最后一个时,未抛异常,list2 最终内容: [1]
为什么真实没有抛异常。迭代器的 hasNext() 判断逻辑是"当前游标是否小于 size"。移除元素 2 之后,size 从 2 变成 1,而游标(处理完索引 0 之后)也真实是 1,hasNext() 判断 1 < 1 为假,循环在真正调用下一次 next()(也就是真正触发 modCount 检查的地方)之前就已经真实结束了。结果是:该被处理的元素被漏掉,list 状态和预期不符,但程序完全没有报错——这比直接抛异常更危险,因为它是静默的。这个场景要求"移除的恰好是导致游标提前越过末尾的那个元素",听起来罕见,但在"遍历倒数第二个元素时删除最后一个"这种日常写法里真实很容易踩中。

真实验证:正确的安全删除方式

用 Iterator.remove() 代替集合自身的 remove(),因为它会同步更新迭代器内部记录的 expectedModCount。

真实实测
用 Iterator.remove() 安全删除后: [1, 3, 5]

真实结果验证 Iterator.remove() 才是遍历中删除元素的正确方式——它内部会同步更新迭代器自己记录的 expectedModCount,不会触发 fail-fast 检查,也不会有游标错位的问题,能真实、干净地删完所有偶数。

真实验证:CopyOnWriteArrayList 不是 fail-fast,HashMap 也是 fail-fast

最后两组对照:并发容器 CopyOnWriteArrayList 的遍历机制完全不同,而 HashMap 同样受 fail-fast 约束。

真实实测
CopyOnWriteArrayList 遍历中修改,未抛异常,本次遍历实际看到的元素数: 3,遍历后 size: 6
HashMap.keySet() 遍历中 put 同样真实抛出 ConcurrentModificationException

真实结果证明 CopyOnWriteArrayList 的迭代器在创建那一刻就真实持有了底层数组的一份快照引用,遍历过程中看到的永远是那个快照,后续的 add() 真实生成了新数组,但不影响正在进行的这次遍历——本次遍历真实只看到了 3 个元素(创建迭代器时的快照大小),尽管遍历结束后 size() 已经真实变成了 6。而 HashMap.keySet() 的遍历同样受 modCount 机制约束,和 ArrayList 遵循同一套 fail-fast 逻辑。

分步演示:cursor 和 size 怎么"恰好"撞在一起

把上面的经典陷阱拆成逐帧动画:每一步的 cursor、size、hasNext() 判断结果都是真实运行这段代码时的状态,不是手工排的。

list2=[1,2],遍历时 remove 最后一个元素:cursor 和 size 一起变化,异常真实没有机会抛出
cursor 当前指向的下标本步状态发生变化的字段

    参考与说明

    • 本文全部结果均在本机真实运行 Java 17 得到,经典陷阱场景重复运行结果一致(逻辑上是确定性的,不依赖 GC/线程调度)。
    • 和CopyOnWriteArrayList那篇放在一起看,能更完整理解"快照式遍历"和"fail-fast 检测式遍历"两种设计权衡。
    • 没有涉及:fail-fast 检测本身不是线程安全保证(多线程下即使检测到了也可能是脏读)、LinkedList 的 fail-fast 实现细节——这两个话题留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号