尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ArrayList 的 Iterator 删除为啥不会出现 ConcurrentModificationException:从 modCount 到 Itr 源码逐行拆解

ArrayList 的 Iterator 删除为啥不会出现 ConcurrentModificationException:从 modCount 到 Itr 源码逐行拆解 1. 从一次线上事故说起为什么 Iterator.remove() 能逃过检查先说结论ArrayList的Iterator.remove()并不是逃过了ConcurrentModificationException检查而是它在删除元素之后主动把expectedModCount同步成了最新的modCount让下一次检查重新对齐。而for-each循环里直接调用list.remove()只改了modCount没改迭代器内部的expectedModCount于是下一次next()时两个值对不上直接抛异常。这个区别是 Java 集合源码面试里的高频题也是很多人写业务代码时踩过的坑。我见过一个真实场景某订单服务在遍历ArrayList做批量过滤时用for (Order o : list) { if (xxx) list.remove(o); }本地测试数据量小没触发上线后偶发ConcurrentModificationException排查了半天才发现是 fail-fast 机制在作祟。这篇文章会从modCount和expectedModCount两个字段入手把 JDK 17 里ArrayList.Itr的关键源码逐行拆开配合断点调试步骤和一段可运行的对比 Demo帮你彻底搞清楚 fail-fast 的边界到底在哪。适合正在准备 Java 面试、或者被这个异常折磨过的同学。2. 前置准备TaoToken 接入与调试环境在动手调试源码之前先把环境准备好。我平时读 JDK 源码、跑对比 Demo 的时候习惯用 TaoToken 来辅助理解一些边界问题——比如让模型解释某段源码的执行顺序或者对比不同 JDK 版本的实现差异。它的模型对话入口可以直接贴源码片段提问省去自己翻文档的时间。如果你也想用这种方式辅助学习可以按下面的步骤拿到 API Key访问官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册账号然后在控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsole 里创建一个 API Key。创建完成后在 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keys 可以查看和管理你的密钥。拿到 Key 之后模型对话的接入地址是 https://taotoken.net/api请求格式兼容 OpenAI 风格。如果你想在 IDE 里做长期编码辅助可以看看 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-plan它更适合 Agent 类的持续调用场景。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdoc里面有完整的参数说明。注意API Key 属于敏感凭证不要硬编码到提交到 Git 的代码里建议用环境变量或本地配置文件管理。环境方面你需要 JDK 17其他版本源码略有差异但核心逻辑一致、一个能打断点的 IDEIDEA 或 Eclipse 都行以及一段可运行的测试代码。下面进入正题。3. 可复制配置JDK 17 ArrayList.Itr 关键源码逐行拆解先看ArrayList里和 fail-fast 相关的两个字段。modCount继承自AbstractList记录的是结构性修改的次数——注意set()这种只改值不改结构的操作不会增加它只有add、remove、clear这类会改变 size 的操作才会。// AbstractList protected transient int modCount 0;然后是Itr内部类的核心部分我把和删除相关的字段和方法摘出来private class Itr implements IteratorE { int cursor; // 下一个要返回的元素下标 int lastRet -1; // 上一个返回的元素下标-1 表示没有 int expectedModCount modCount; // 创建迭代器时快照的 modCount public boolean hasNext() { return cursor ! size; } SuppressWarnings(unchecked) public E next() { checkForComodification(); // 关键检查点 int i cursor; if (i size) throw new NoSuchElementException(); Object[] elementData ArrayList.this.elementData; if (i elementData.length) throw new ConcurrentModificationException(); cursor i 1; return (E) elementData[lastRet i]; } public void remove() { if (lastRet 0) throw new IllegalStateException(); checkForComodification(); // 删除前也检查一次 try { ArrayList.this.remove(lastRet); // 调用外部类的 remove cursor lastRet; // 游标回退 lastRet -1; // 重置 lastRet expectedModCount modCount; // 关键同步 modCount } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } } final void checkForComodification() { if (modCount ! expectedModCount) throw new ConcurrentModificationException(); } }逐行看几个关键点expectedModCount modCount这行在迭代器创建时执行相当于给当前状态拍了个快照。之后每次next()和remove()都会调用checkForComodification()只要modCount变了而expectedModCount没跟上就抛异常。Itr.remove()里最精髓的是最后那行expectedModCount modCount。它调用ArrayList.this.remove(lastRet)时外部的modCount会自增 1紧接着迭代器把expectedModCount也更新成新的modCount两者重新相等。所以下一次next()检查时不会报错。再看cursor lastRet这行。假设当前cursor3、lastRet2说明刚返回了下标 2 的元素。删除下标 2 之后后面的元素整体前移一位原来下标 3 的元素现在跑到了下标 2。把cursor设回lastRet也就是 2下次next()就会重新读取这个位置不会漏掉元素。这就是为什么迭代器删除是安全的——它自己维护了游标的正确性。对比一下for-each的展开形式编译器会把for (E e : list)翻译成for (IteratorE it list.iterator(); it.hasNext(); ) { E e it.next(); // 循环体 }如果你在循环体里写list.remove(e)调用的是ArrayList自己的remove它只会modCount完全不碰迭代器的expectedModCount。等下一轮it.next()执行checkForComodification()时modCount ! expectedModCount成立异常就抛出来了。4. 验证请求断点调试与可运行对比 Demo光看源码不够直观我们写一段能跑的代码把两种写法放在一起对比。import java.util.ArrayList; import java.util.Iterator; import java.util.List; public class FailFastDemo { public static void main(String[] args) { // 场景一for-each list.remove()会抛异常 ListString list1 new ArrayList(List.of(A, B, C, D)); try { for (String s : list1) { if (B.equals(s)) { list1.remove(s); } } System.out.println(场景一未抛异常: list1); } catch (Exception e) { System.out.println(场景一抛出: e.getClass().getSimpleName()); } // 场景二Iterator.remove()正常执行 ListString list2 new ArrayList(List.of(A, B, C, D)); IteratorString it list2.iterator(); while (it.hasNext()) { String s it.next(); if (B.equals(s)) { it.remove(); } } System.out.println(场景二结果: list2); } }运行结果场景一抛出: ConcurrentModificationException 场景二结果: [A, C, D]场景一在删除 B 之后下一轮next()读取 C 时触发检查modCount已经从 4 变成 5而expectedModCount还是 4异常抛出。场景二则顺利删掉了 B剩下[A, C, D]。接下来是断点调试步骤用 IDEA 演示第一步在Itr.next()的checkForComodification()那行打断点条件设为modCount ! expectedModCount。这样只有真正要抛异常的时候才会停下来。第二步在Itr.remove()的expectedModCount modCount那行也打个断点。运行场景二你会看到执行到这行时modCount是 5expectedModCount还是 4赋值之后两者都变成 5。第三步观察cursor和lastRet的变化。删除前cursor2、lastRet1执行ArrayList.this.remove(1)后cursor被设回 1lastRet重置为 -1。下一轮hasNext()判断cursor ! size此时 size 从 4 变成 3cursor1继续循环next()返回下标 1 的元素 C。如果你想用 TaoToken 的模型对话来辅助验证可以把这段源码贴进去问它为什么cursor lastRet不会导致死循环它会结合lastRet -1的重置逻辑给你解释。模型对话入口在 https://taotoken.net/api按 OpenAI 格式发请求即可。5. 本篇常见错排查fail-fast 的边界与坑坑一以为Iterator.remove()是线程安全的。不是。fail-fast 只是快速失败它检测的是单线程内的结构性修改不是并发安全机制。多线程环境下一个线程用迭代器删除另一个线程调用list.add()照样可能抛异常甚至出现更隐蔽的数据错乱。真要多线程用CopyOnWriteArrayList或加锁。坑二lastRet没重置导致IllegalStateException。连续调用两次it.remove()会抛IllegalStateException因为第一次删除后lastRet被设成 -1第二次进来if (lastRet 0)直接拦截。正确姿势是next()和remove()交替调用。坑三set()不触发 fail-fast。因为set()只改元素值不改modCount。所以for-each里调用list.set(i, x)不会抛异常但这不代表它是安全的——迭代器返回的元素可能已经被替换逻辑上要自己保证正确性。坑四removeIf()和Iterator.remove()的关系。list.removeIf(predicate)内部就是用迭代器的remove()实现的所以它不会抛ConcurrentModificationException。如果你只是想按条件批量删除直接用removeIf更简洁。坑五subList的迭代器行为不同。ArrayList.subList()返回的是内部类SubList它的modCount检查逻辑和Itr不完全一样修改原列表再遍历子列表容易出问题。这个属于进阶话题面试里偶尔会追问。排查这类异常时最快的定位方法是看异常栈里checkForComodification的调用位置然后反推是哪次next()触发的再检查循环体里有没有直接调用集合的增删方法。6. 语义一致收尾把 fail-fast 用对地方回到最初的问题Iterator.remove()之所以不抛ConcurrentModificationException核心就一句话——它在删除后把expectedModCount同步成了modCount并且用cursor lastRet修正了游标位置。而for-each里的list.remove()只改了modCount迭代器的快照没更新下一次检查自然对不上。实际写代码时我的习惯是需要边遍历边删除优先用Iterator或removeIf如果只是遍历读取for-each完全够用多线程场景别指望 fail-fast 保平安老老实实换并发容器。如果你在调试过程中想让模型帮你分析某段源码的执行路径可以用 TaoToken 的模型对话 https://taotoken.net/api 贴代码提问长期做 Java 源码阅读或 Agent 开发的话Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-plan 的额度更适合持续调用。接入细节参考文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocKey 在 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keys 管理。下次再遇到这个异常别急着加synchronized先看看是不是在for-each里动了集合结构。
返回列表