深入理解ConcurrentModificationException:Iterator.remove与fail-fast机制全解析
第一次在项目里遇到ConcurrentModificationException是在一个批量清理无效数据的任务里。当时写得挺顺手一个 for-each 循环筛到条件就list.remove(item)结果一跑直接炸异常堆栈指向的那几行代码恰恰是我自认为最不可能出错的地方。后来翻源码、查资料才把 Iterator、modCount、fail-fast 这一整套机制弄明白。这个坑几乎是所有 Java 开发者的必经之路但网上很多文章只告诉你用 Iterator.remove()却没讲清楚为什么它能删、什么时候该用、以及还有哪些更省事的替代方案。这篇文章就把这些细节一次性说透。1. 从 for-each 删除失败的经典报错说起1.1 那行没毛病的代码为什么会炸先看这段几乎人人都写过一次的代码ListString list new ArrayList(Arrays.asList(a, b, c)); for (String item : list) { if (b.equals(item)) { list.remove(item); } }运行结果不是删掉了 b而是直接抛java.util.ConcurrentModificationException堆栈指向ArrayList$Itr.next()里的checkForComodification()。很多人第一反应是并发问题其实这里根本没有多线程。问题的根源是for-each 只是语法糖编译后本质上是一个 Iterator 循环。javac会把上面的代码改写成这样IteratorString it list.iterator(); while (it.hasNext()) { String item it.next(); if (b.equals(item)) { list.remove(item); } }关键在于list.remove(item)走的是集合自己的删除方法它只修改了集合内部的modCount却丝毫不知道外面还有一个迭代器正在使用这个集合。于是迭代器持有的expectedModCount和集合真实的modCount对不上了下一次调用next()时迭代器检查到这个不一致立刻抛出异常。1.2 fail-fast 不是什么线程安全机制ConcurrentModificationException背后是一套叫 fail-fast快速失败的设计哲学。Java 集合框架的选择是一旦在迭代期间发现集合的结构被修改与其让程序拿着一个已经失效的迭代器继续跑下去、产生一堆难以定位的脏数据不如当场就把问题抛出来让你在第一时间意识到自己的代码逻辑出了问题。结构被修改指的是什么对 ArrayList 来说add、remove、clear这类改变元素个数或内部容量的操作都会让modCount自增而set替换某个位置的元素不算结构性修改所以迭代期间调用set不会触发异常。这一点很多人不知道后面讲 HashMap 的时候还会用到。需要强调fail-fast 不是为了解决线程安全问题设计的。它只是一个 bug 检测机制负责在你写错代码时喊一声停它既不保证程序安全也不保证一定能拦下所有并发问题。多线程下如果真去并发修改同一个 ArrayList除了 CME你还可能遇到ArrayIndexOutOfBoundsException、读到 null、甚至数据错乱——这些都比一个干净的异常更可怕。1.3 为什么这个坑几乎是所有人的必经之路我观察到踩这个坑的人往往不是新手而是从 C、Python 转过来的同学。C 里面用下标循环边遍历边删只要自己小心控制边界就行Python 里list comprehension直接生成新列表压根没有遍历中删除这种操作模式。而 Java 的 for-each 把迭代器藏得太好了长得就像一次普通循环大家下意识觉得我在循环里调一个集合方法有什么问题。等你真正理解了集合有自己的 modCount迭代器也有自己的 expectedModCount两者必须同步这个模型很多相关报错就都能一眼看穿了。2. Iterator.remove() 内部机制它到底做对了什么2.1 三个关键字段cursor、lastRet、expectedModCount回到标题的主角 Iterator。以 ArrayList 内部类Itr为例它维护了三个状态字段cursor下一个要返回的元素下标初始为 0lastRet最近一次next()返回的元素下标初始为 -1用于标记当前元素expectedModCount迭代器创建时以及每次安全修改后从集合同步来的版本号。next()的流程是先检查expectedModCount是否等于modCount不等就抛 CME然后把cursor指向的元素返回同时把lastRet更新为这个元素的下标cursor前进一位。而remove()的流程才是它安全的真正原因。源码经过简化后大致是public void remove() { if (lastRet 0) { throw new IllegalStateException(); } checkForComodification(); ArrayList.this.remove(lastRet); cursor lastRet; lastRet -1; expectedModCount modCount; }注意最后一行删完元素后迭代器立刻把自己的expectedModCount重新赋值为集合当前的modCount。也就是说迭代器通过内部同步把自己和集合的版本号重新对齐了。下一次next()再检查时两个值相等循环自然能继续跑下去。2.2 remove() 的规则为什么那么苛刻只要用过Iterator.remove()一定会遇到两个IllegalStateException还没调用next()就直接remove()连续调用两次remove()中间没有next()。原因就在lastRet这个字段上。lastRet相当于迭代器手里的当前元素凭证next()会发一张凭证remove()用完就收回置为 -1。没有凭证就想删迭代器根本不知道你想删谁直接报错。这个设计虽然烦人但它是故意的它强迫你明确当前元素这个概念。每调用一次next()迭代器的状态前进一格每调用一次remove()消耗掉当前这个元素。这保证了删除操作永远发生在迭代器已经确认过的位置上不会因为下标错位删错元素。正确的写法是IteratorString it list.iterator(); while (it.hasNext()) { String item it.next(); if (b.equals(item)) { it.remove(); } }删除后cursor lastRet这一句也很有讲究lastRet位置上的元素被删了原本lastRet 1的元素会补位到lastRet位置把cursor回退到这个位置下一次hasNext()就能正确处理补位过来的元素不会漏掉任何一个。2.3 为什么说 remove() 只能删当前元素Iterator 的remove()没有参数它只能删除lastRet指向的那个元素。想在一个迭代循环里顺手删掉别的元素是做不到的必须让next()走到那个元素才行。这个限制乍看很死板实际是安全性和简单性的平衡。如果允许在迭代中任意删除某个其他元素迭代器的cursor、lastRet和集合的真实下标之间会迅速失去对应关系各种边界问题会把你折磨到怀疑人生。而只删当前元素配合删后重新同步 expectedModCount把整个状态机约束得非常干净逻辑上无懈可击。3. 除了迭代器还有这些更省事的删除方案3.1 removeIf简洁且性能最优Java 8 给Collection接口加了一个默认方法removeIf它接收一个Predicate凡是满足条件的元素全部删除list.removeIf(item - b.equals(item));一行代码比手动写 Iterator 循环干净得多。更关键的是性能ArrayList 重写了removeIf实现方式是先遍历一遍用BitSet记录所有要删除的位置然后一次性把剩余元素整体前移、批量清理尾部。整个过程的复杂度是 O(n)跟你删多少个元素无关。而手写Iterator.remove()每次删除都要触发一次数组搬迁每删一个元素是 O(n)总共删 k 个就是 O(k * n)。如果命中条件的数据很多两者的差距会非常明显。3.2 收集再删除条件复杂时的兜底方案有些删除条件不是一个简单的谓词能表达的比如把这批元素里和另一个集合有交集的部分删掉或者要先经过一堆业务计算才决定某条数据要不要留。这时候可以先把要删的收集起来遍历结束再统一删SetString toRemove new HashSet(); for (String item : list) { if (needRemove(item)) { toRemove.add(item); } } list.removeAll(toRemove);这里有个值得记下来的细节toRemove一定要用HashSet而不是ArrayList。因为removeAll内部会对原列表逐元素调用toRemove.contains(x)来判断是否删除HashSet的contains是 O(1)而ArrayList的contains是 O(n)用错了集合类型整体复杂度会从 O(n) 恶化成 O(n²)。这种方案的思路是在遍历期间不修改原集合只记录改动作留到遍历结束。它天然免疫 CME而且特别适合在多线程场景里用——把删除操作收集好交给一个安全的时间窗口去执行。3.3 倒序删索引别在 LinkedList 上翻车还有一个古老但好使的办法就是从末尾往前遍历按索引删除for (int i list.size() - 1; i 0; i--) { if (needDelete(list.get(i))) { list.remove(i); } }为什么倒序安全因为删除某个位置的元素后受影响的是它后面那些元素的下标而倒序遍历时后面的元素已经被处理过了前面的元素下标根本没变。正序删除会跳过元素倒序删除则不会。但这招只在 ArrayList 上好使。如果你拿这个写法去删 LinkedListget(i)每次都要从头往后走一遍整体复杂度直接变成 O(n²)数据量稍微大点就能感觉到卡顿。LinkedList 场景老老实实用迭代器或 removeIf。3.4 四种方案横向对比表方案推荐场景ArrayList 下的大致成本注意点Iterator.remove()遍历时要做额外逻辑、需要精确控制每个元素O(k * n)必须先 next()且只能删当前元素removeIf条件能用谓词表达无额外逻辑O(n)只适合单集合批量删除收集再删除条件复杂、跨集合比较O(n)用 HashSet 存待删项时待删集合别用 ArrayList 存倒序索引删除ArrayList 且遍历逻辑简单O(k * n)LinkedList 千万别这么干从这张表能看出绝大多数日常场景的答案是removeIf它无论在代码可读性还是性能上都占优。Iterator 的手动遍历更适合那些需要在遍历中做复杂业务判断、甚至需要提前 break 出循环的场景。4. 并发遍历删除弱一致性迭代器才是正确方向4.1 ConcurrentModificationException 不负责处理并发先纠正一个普遍误解看到 CME 就认为是并发问题是错的。绝大多数 CME 是单线程下一边迭代一边改结构导致的跟并发没有半毛钱关系。反过来也是如果真的是多线程并发修改同一个 ArrayListfail-fast 机制并不能拯救你。它只是检测到不一致就报错而不是保证不一致永远不会发生。在多线程场景下你需要的是专门为并发设计的集合而不是指望 CME 帮你兜底。4.2 ConcurrentHashMap 的弱一致性与 remove()ConcurrentHashMap的迭代器走的是另一条路叫做弱一致性weakly consistent。它不持有集合的全局锁遍历时允许其他线程并发修改它不会抛 CME因为它的迭代器根本不比较 modCount但它也不承诺一定能看到遍历开始之后发生的所有修改——可能看到部分也可能看不到。这类迭代器是支持remove()的删除操作会把当前遍历到的 entry 从 map 里移除并且不会抛出 CME。这种你删你的我遍历我的的模式在缓存清理、定时任务扫表这类场景里非常实用。4.3 CopyOnWrite 系列快照迭代不能删CopyOnWriteArrayList和CopyOnWriteArraySet是另一种思路任何时候的修改操作都会复制一份新的底层数组迭代器则永远在创建时那个不可变的快照上遍历。好处是读操作完全无锁、绝无 CME坏处是每次写都要复制全量数组写成本极高。这类集合的迭代器干脆不支持remove()你调了会直接抛UnsupportedOperationException。想删元素得对集合本身操作而不是对迭代器操作。需要特别注意的是已经创建出来的迭代器看不到后续任何修改如果你先创建迭代器再list.remove(...)迭代器遍历的还是旧快照。4.4 多线程边遍历边删除的几种稳妥做法如果业务确实需要在多线程下边遍历边删除我的建议优先级是这样的换用ConcurrentHashMap这类支持并发修改的集合配合它的remove()或compute原子操作升级成收集再删除模式遍历时只记录 key遍历结束后统一批量删除实在要用普通集合就加锁把整个遍历删除包成一个临界区锁对象必须和所有写线程用的是同一个。最后一种做法虽然简单但要注意锁范围一定要覆盖整个遍历过程只锁住删除那一步、遍历不锁照样是并发问题。5. 比异常更隐蔽的实战坑排查记录与避坑清单5.1 按索引正序删跳过元素问题CME 至少会明确告诉你我出问题了而按索引正序删除则是无声无息地出错。经典例子ListInteger nums new ArrayList(Arrays.asList(1, 2, 3, 4, 5)); for (int i 0; i nums.size(); i) { if (nums.get(i) % 2 1) { // 想删掉所有奇数 nums.remove(i); } } // 期望结果[2, 4] // 实际结果[2, 4, 5] —— 5 被漏掉了原因很简单删掉下标 1 的元素后原来下标 2 的元素补位到下标 1但循环变量i已经自增到 2这个补位的元素就被跳过了。同理正序删除时如果连续两个元素都命中条件第二个大概率会被漏掉。这种 bug 在单元测试里都很难发现因为测试数据往往不够连续命中。5.2 List 的 remove(int) 陷阱这个坑我见过不止一次。一个ListInteger想按值删除某个数字ListInteger nums new ArrayList(Arrays.asList(10, 20, 30)); nums.remove(20); // 你以为删的是值 20实际是想删下标 20List有两个重载remove(int index)和remove(Object o)。20是字面量 int编译器会优先选择更匹配的remove(int)于是这里不是删除值 20而是删除下标 20直接抛下标越界。就算不越界比如nums.remove(1)你以为是删值 1实际删的是第二个元素。想按值删除必须手动装箱nums.remove(Integer.valueOf(20))。而用Iterator.remove()或removeIf时因为操作对象直接是元素本身压根不存在这个歧义。这也是我推荐后两种写法的一个隐藏理由。5.3 嵌套循环共用集合外层迭代器被内层删除搞挂还有一种更隐蔽的情况外层 for-each 遍历 list内层也遍历同一个 list内层做了删除。此时内层迭代器删除后会同步自己的expectedModCount但外层迭代器的expectedModCount还留在旧值。等外层next()再次执行时发现版本对不上立刻 CME。我第一次遇到时困惑了很久因为报错堆栈指向的是外层循环而删除操作明明发生在内层。后来想明白CME 检查的是这个迭代器创建之后集合是否被任何途径结构性修改过与修改者是谁无关。嵌套循环里处理同一个集合要么全部走收集再删除要么在删除后立刻 break 掉外层循环别再继续迭代。5.4 不可变集合的 UnsupportedOperationExceptionJava 9 之后大家越来越习惯用List.of()、Set.of()创建不可变集合。这些集合的迭代器同样不支持remove()直接调用会抛UnsupportedOperationException。更隐蔽的是removeIf也会抛同样的异常因为不可变集合的removeIf根本没有可删的对象。遇到这种情况别纠结先拷贝一份可变集合再操作ListString immutable List.of(a, b, c); ListString workList new ArrayList(immutable); workList.removeIf(item - b.equals(item));5.5 HashMap 遍历中的改值合规改结构违规最后一个细节是关于HashMap的。遍历 keySet 或 entrySet 时如果调用put(key, 新值)去更新一个已存在的 key这是允许的因为替换已有 key 的值不会让modCount自增不算结构性修改。但如果你往里put一个新 key或者remove一个 keymodCount就会变遍历就会抛 CME。所以遍历 map 时要更新 value直接用entry.setValue()或者对已存在的 key 做 put 都没问题要增删 key老老实实收集完再统一操作。这个边界规则理解了 modCount 的语义之后就不需要背了。最后说点个人体会。虽然日常新代码里我已经更多地用removeIf了但我觉得 Iterator 这套约束非常值得彻底搞懂。它不是老古董而是一面镜子——它逼你先想清楚你正在处理的到底是哪个元素。很多诡异的并发问题、跳过元素问题本质上都是因为没有看清集合的状态在哪一步、被谁改变了。如果你能养成从modCount视角去看集合操作的习惯那些看似玄学的异常都会慢慢变成可推断的常识。下次再遇到相关报错先别急着加 try-catch 掩盖把迭代器的状态在脑子里画一遍答案往往就在next()和remove()的边界上。

相关新闻

大龄读研值不值得?从一位31岁校招生入职说起:计算机校招生的职业选择与准备指南

大龄读研值不值得?从一位31岁校招生入职说起:计算机校招生的职业选择与准备指南

文档教程知识库 【免费下载链接】InterviewGuide 🔥🔥「InterviewGuide」是阿秀从校园->职场多年计算机自学过程的记录以及学弟学妹们计算机校招&秋招经验总结文章的汇总,包括但不限于C/C 、Golang、JavaScript、Vue、操作系统、数据结…

2026/10/13 2:53:39 阅读更多 →
TinyBase Svelte 实战:用 `<ValuesInHtmlTable />` 把 Store 的 Values 渲染成 HTML 表格

TinyBase Svelte 实战:用 `<ValuesInHtmlTable />` 把 Store 的 Values 渲染成 HTML 表格

数据库后端前端 【免费下载链接】tinybase A reactive data store & sync engine. 项目地址: https://gitcode.com/gh_mirrors/ti/tinybase 点击查看 免费下载 本文是 TinyBase 官方 Svelte UI 组件系列演示的第一篇,聚焦 ui-svelte-dom 模块中的 &…

2026/10/13 2:53:39 阅读更多 →
论文AIGC痕迹太重怎么降?十款工具实测与五步降痕流程全解析

论文AIGC痕迹太重怎么降?十款工具实测与五步降痕流程全解析

又到了一年一度写论文的“苦难季”。不管你是本科生、研究生还是准备评职称的在职党,应该都发现了:今年和往年最大的不同,是除了查重,又多了一道“查AI”的关卡。很多学校、期刊编辑部开始明文要求提交AIGC检测报告,论…

2026/10/13 2:52:38 阅读更多 →

最新新闻

手写通用状态机,终结订单状态流转的if-else噩梦

手写通用状态机,终结订单状态流转的if-else噩梦

接手过几个订单系统的同学应该都有同感:状态流转这块代码,刚开始写if-else还挺顺手的,等业务跑起来、需求迭代几个月之后,就会变成一个到处打补丁的“面条锅”。今天我用订单场景做例子,从零手写一个通用状态机&#x…

2026/10/13 4:22:04 阅读更多 →
制造业数智化升级路径:从设备联网到数据驱动的实战指南

制造业数智化升级路径:从设备联网到数据驱动的实战指南

先说明一点:我这些年接触过不少做智能制造、产线数字化的项目,很多企业砸了几百万上了系统,最后变成一堆漂亮的报表和永远没人看的看板。问题通常不是出在技术上,而是出在“路径”上——大家把数智化升级当成一个IT项目在推&#…

2026/10/13 4:22:04 阅读更多 →
DEA数据包络分析:美赛效率评估模型选型与Python实战指南

DEA数据包络分析:美赛效率评估模型选型与Python实战指南

2026年美赛(MCM/ICM)备赛周期刚开始,数据包络分析(DEA)就已经在各路经验帖里刷屏了。效率评估类问题几乎是竞赛里的常客:宏观一点能考区域发展质量、产业升级效果,微观一点能考医院运营、银行网…

2026/10/13 4:22:04 阅读更多 →
前端组件库调试痛点看 yalc:用快照与显式推送替代 npm link

前端组件库调试痛点看 yalc:用快照与显式推送替代 npm link

如果你维护过跨业务项目复用的前端组件库,大概率经历过这种尴尬:在某业务项目里发现了组件的样式问题,改完组件库代码要重新构建、再手动把产物拷贝进目标项目的node_modules里反复验证,来回几次就烦了。我一开始也是这么干的&…

2026/10/13 4:22:04 阅读更多 →
MinGW免安装版:Windows下可复现的C/C++编译环境交付方案

MinGW免安装版:Windows下可复现的C/C++编译环境交付方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 4:22:04 阅读更多 →
鸿蒙Rcp网络请求封装:标准化Request工具类实战

鸿蒙Rcp网络请求封装:标准化Request工具类实战

做鸿蒙原生开发,只要应用需要和服务端交互,就绕不开网络请求这一层。HarmonyOS NEXT 自带的新一代网络库 Rcp,解决了老接口在连接复用、上传下载、Cookie 管理上的不少历史遗留问题,但直接用 Rcp 写业务,代码会非常碎。…

2026/10/13 4:21:04 阅读更多 →

日新闻

物联网系统集成避坑指南:从选型到验收的关键要点

物联网系统集成避坑指南:从选型到验收的关键要点

做了这么多年物联网系统集成的项目,我最大的感受是,大部分坑不是出在技术前沿,而是出在一些看起来很平常的地方。设备选了便宜的、协议没问清楚、现场没摸透、验收标准写得含糊——这些细枝末节,最后都会变成延期、超支和扯皮的大…

2026/10/13 0:00:57 阅读更多 →
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

如果你玩ESP32有一阵子了,很难不碰到舵机控制这道坎。做智能小车要转方向,做机械臂要抬关节,做云台要偏航俯仰,背后都是同一个东西在干活:舵机。而我见过太多人卡在“代码能编译、舵机就是不听话”这个阶段&#xff0c…

2026/10/13 0:00:57 阅读更多 →
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

1. 为什么CAN总线至今仍是工业现场的“硬通货”?——从汽车底盘到智能温室的真实需求你有没有拆过一辆老款燃油车的中控台?或者调试过某高校实验室里那台跑着Modbus协议却频频丢帧的PLC控制柜?又或者,在某个农业物联网项目里&…

2026/10/13 0:00:57 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/12 0:16:38 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 21:05:09 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 14:48:49 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 18:11:17 阅读更多 →