这是一个名字听起来吓人、却几乎人人都踩过一次的异常——ConcurrentModificationException(并发修改异常)。它最让我困惑的地方,是那个 "Concurrent"(并发)二字:我明明是单线程跑的代码,从头到尾就一个线程,哪来的"并发"?可它偏偏就抛了。事情是这样的:我写了一段清理逻辑,要遍历一个用户列表,把里面"不活跃"的用户从列表里删掉。逻辑直白得不能再直白——一边遍历、一边把满足条件的删掉。可一跑,就时不时地抛出 ConcurrentModificationException;更诡异的是,它还"时灵时不灵",有时跑得好好的(尤其是当要删的恰好是倒数第二个元素时),有时就炸。
排查之后,我才理解了这个异常的真正含义,也明白了那个"并发"二字的误导性——它真正说的不是"多线程并发",而是"你在用迭代器遍历一个集合的同时,又(在迭代器之外)结构性地修改了这个集合"。我那段代码,用 for-each 循环(它底层就是用迭代器在遍历)遍历列表,却在循环体里直接调用 list.remove() 去删元素——这就构成了"一边用迭代器遍历、一边从集合上直接增删"的冲突。而 Java 的集合,有一个叫 fail-fast(快速失败)的机制:迭代器在遍历过程中,会时刻检查"这个集合的结构有没有被我之外的操作改动过",一旦发现被改了(比如我直接 remove 了),就立刻抛出 ConcurrentModificationException,以"快速失败"的方式,阻止你继续这种可能导致不可预知结果的危险操作。这篇文章,就从这次"单线程遍历删元素却报并发修改异常"的事故讲起,把 Java 集合遍历这个最经典、最高频的坑,讲清楚。
故障现场:一边遍历,一边删,炸了
先把那段"自相矛盾"的代码还原一下:
List users = new ArrayList<>(...); // 一批用户
// 反面: for-each 遍历的同时, 直接在集合上 remove —— 抛 ConcurrentModificationException
for (User u : users) { // for-each 底层用的是迭代器 Iterator
if (!u.isActive()) {
users.remove(u); // ← 直接从 list 删! 绕过了迭代器
} // 迭代器一检查发现集合被改了 → 抛 CME
}
// 单线程跑, 却抛 ConcurrentModificationException —— 那个"并发"二字误导了你
// 它为什么"时灵时不灵"? 因为 fail-fast 的检测时机有个巧合:
// 当你删的恰好是"倒数第二个"元素时, 删完循环正好结束、没触发下一次检测,
// 于是侥幸没报错 —— 这更增加了它的迷惑性
看出这段代码"自相矛盾"在哪了吗?for (User u : users) 这个 for-each 循环,在 Java 里底层是用一个"迭代器(Iterator)"来遍历 users 的;而 users.remove(u),是直接在 users 这个集合上做删除——它绕过了那个正在工作的迭代器。于是就出现了一个矛盾的局面:迭代器以为自己在按部就班地遍历一个稳定的集合,可这个集合却在它眼皮底下被(迭代器之外的 remove)悄悄改动了结构。Java 的迭代器内部维护着一个"修改计数(modCount)",它记着"集合被结构性修改了多少次";迭代器每走一步,都会核对一下"集合当前的修改计数,和我开始遍历时记下的,一致吗?"——一旦你 list.remove() 改了集合,修改计数就变了,迭代器一核对发现对不上,就当机立断地抛出 ConcurrentModificationException。
而那个"时灵时不灵",是 fail-fast 检测时机的一个巧合造成的:当你删除的恰好是列表"倒数第二个"元素时,删完之后循环的内部计数恰好满足了"结束条件",于是迭代器没有再进行下一次"核对修改计数"的检查,就正常结束了——侥幸地没抛异常。这种"偶尔不报错"的巧合,反而更增加了这个坑的迷惑性,让人误以为"我这么写有时候是对的"。但本质上,"用 for-each 遍历集合、同时又直接在集合上增删"这种写法,就是错的、危险的——它违反了"遍历时不得结构性修改集合"这条铁律,fail-fast 机制只是把这个错误,以抛异常的方式及时地暴露给了你。
第一件事:理解 fail-fast——它是在"保护你",而非刁难你
要正确看待这个异常,先得理解 fail-fast(快速失败)机制的"良苦用心"。很多人第一反应是"Java 怎么这么严格,删个元素都不让"——可其实,fail-fast 不是在刁难你,而是在保护你:它在阻止你做一件"会导致结果不可预知、甚至更隐蔽的 bug"的危险事。
// 为什么"遍历时直接改集合"是危险的? 想象一下:
// ArrayList 遍历靠的是"索引 i 不断递增"。
// 当你删掉了当前元素, 后面的元素会"前移一位"补上空位 ——
// 于是索引 i 再 +1 时, 就"跳过"了一个元素! (它本该被检查, 却被漏掉了)
// 假设 [A, B(要删), C, D], 遍历到 i=1 删了 B:
// 删后变成 [A, C, D], 但 i 接着变成 2, 指向了 D —— C 被跳过了!
// 这种"静默地跳过元素"的 bug, 比直接抛异常要隐蔽、可怕得多。
// 所以 fail-fast 选择了"宁可吵闹地抛异常, 也不静默地出错":
// 它用 modCount 检测到这种危险操作, 立刻 CME, 把问题暴露在你面前。
关键认知是:"在用迭代器遍历集合的同时,又结构性地修改这个集合",是一种本质上会导致"行为不可预知"的危险操作(最典型的就是上面那种"删了元素导致后续元素被静默跳过"的 bug)。而 fail-fast 机制,是 Java 集合设计者刻意做出的一个选择:与其让这种危险操作"静默地产生一个隐蔽的、错误的结果"(比如漏掉了某些本该处理的元素,而你浑然不知),不如让它"立刻、吵闹地抛一个异常",把这个错误第一时间、明明白白地暴露给你。所以,ConcurrentModificationException 不是 Java 在为难你,而是它在用一种"快速失败"的、负责任的方式提醒你:"嘿,你正在做一件危险的事(边遍历边改集合),这么干结果是不可预知的,我先帮你叫停,你换个安全的方式来。"理解了 fail-fast 是"保护性的提前报警"而非"无理的限制",你就不会再把它当成讨厌的拦路虎,而会感激它帮你拦下了一个本可能更隐蔽的 bug。那么,正确的"边遍历边删"该怎么写呢?这就是下一节的解法。
第二件事:正解——用 Iterator.remove() 或 removeIf()
那"边遍历边删"到底该怎么写?有几种正确姿势,核心都是:要么通过迭代器自己提供的 remove 方法来删(让迭代器知道这次删除、并同步好自己的状态),要么用集合提供的批量删除方法。
// 正解1: 用 Iterator 的 remove() 方法删 —— 迭代器自己删, 它知道、不会 CME
Iterator it = users.iterator();
while (it.hasNext()) {
User u = it.next();
if (!u.isActive()) {
it.remove(); // 通过迭代器删! 它会同步更新自己的状态, 不报错
}
}
// 正解2(推荐, Java 8+): 用 removeIf() —— 一行搞定, 最简洁
users.removeIf(u -> !u.isActive()); // 删掉所有"不活跃"的, 内部正确处理
// 正解3: 遍历一个副本, 删原集合(遍历的和删的不是同一个, 互不干扰)
for (User u : new ArrayList<>(users)) { // 遍历 users 的副本
if (!u.isActive()) users.remove(u); // 删原 users, 不冲突
}
// 正解4: 用倒序索引 for 循环删(避免删元素导致后续索引错位)
for (int i = users.size() - 1; i >= 0; i--) {
if (!users.get(i).isActive()) users.remove(i);
}
这几种正解,各有适用,但首推前两种:正解1(Iterator.remove())是最根本的方式——既然问题出在"你绕过迭代器去删集合",那解法就是"通过迭代器自己来删";迭代器的 remove() 方法会在删除的同时,正确地同步更新它自己内部的状态(包括那个修改计数),所以不会触发 fail-fast。正解2(removeIf())是 Java 8 之后的最佳实践——它把"遍历 + 按条件删除"这个常见需求封装成了一个简洁的方法,一行代码搞定,内部已经正确处理了所有边界,既简洁又安全,强烈推荐。正解3(遍历副本)的思路是"分离"——遍历的是一个副本、删的是原集合,两者不是同一个对象,自然不会冲突(代价是多复制一份)。正解4(倒序索引)是用普通 for 循环 + 倒序索引,从后往前删——倒序的妙处是:删掉一个元素后,前移的是它"前面"的元素,而那些已经是你"遍历过"的了,不会影响你接下来要访问的"更前面"的索引。我把"该怎么边遍历边删"画成一张图:
这张图把"边遍历边删"的对错姿势一网打尽:唯一错的,是"for-each 里直接对集合 remove";其余几种(Iterator.remove、removeIf、遍历副本、倒序索引)都是对的,其中 removeIf 最简洁、最推荐。记住这张图,这个高频坑就再也咬不到你了——遇到"边遍历边删"的需求,优先 removeIf,搞定。
第三件事:不只是删——遍历时"增"和"改结构"也一样会 CME
这个坑不只发生在"删"元素时。要警惕的是:任何在遍历过程中对集合做的"结构性修改"(改变集合大小的操作:add 增、remove 删等),都会触发 fail-fast,抛 CME。不只是 remove,在 for-each 里 add 元素同样会炸。
// 遍历时 add 也会 CME (结构性修改)
for (User u : users) {
if (someCondition(u)) {
users.add(newUser); // ← 遍历时 add, 同样抛 CME!
}
}
// 但注意: 遍历时只"改元素的内容"(不改集合结构), 是安全的, 不会 CME
for (User u : users) {
u.setActive(false); // 只是改 u 这个对象的属性, 没动 users 的结构, 安全
}
// 区别的关键:
// "结构性修改"(add/remove, 改变集合大小) → 触发 fail-fast, CME
// "非结构性修改"(只改已有元素的内容) → 安全, 不触发
// 要在遍历时"增"元素, 怎么办? 攒到一个临时列表, 遍历完再 addAll:
List toAdd = new ArrayList<>();
for (User u : users) {
if (someCondition(u)) toAdd.add(newUser); // 先攒着, 别动 users
}
users.addAll(toAdd); // 遍历结束后, 一次性加进去
这里要厘清一个关键区别:"结构性修改"和"非结构性修改"。 "结构性修改"指的是改变集合大小的操作——增加元素(add)、删除元素(remove)等,这些会改变集合的结构,在遍历时做就会触发 fail-fast、抛 CME。"非结构性修改"则是只改动集合里"已有元素"的内容(比如遍历时调用 u.setActive(false) 改了 u 这个对象的属性)——它没有改变集合本身的结构(还是那么多个元素),所以是安全的,不会触发 CME。搞清这个区别很重要:遍历时"改元素内容"放心改;但遍历时想"增删元素"(改结构),就必须用前面讲的那些安全方式。而如果你需要在"遍历过程中往集合里增元素",一个通用的安全做法是:遍历时先把"要增加的元素"攒到一个临时列表里(不动正在遍历的原集合),等遍历完全结束后,再用 addAll 一次性地把它们加进去——这样就彻底避开了"遍历时改结构"的冲突。
第四件事:那"并发"二字也不全是误导——真·多线程也会 CME
前面说那个 "Concurrent" 二字"误导",其实只说了一半。它确实在"单线程遍历删"的场景里是个误导(那里没有并发);但反过来,在真正的多线程场景下,这个异常的名字就名副其实了:一个线程在遍历集合,另一个线程同时去修改它的结构,也会触发同样的 fail-fast、抛 CME。所以它的名字其实涵盖了两种情况。
// 真·并发场景: 一个线程遍历, 另一个线程同时改 —— 也会 CME
List users = new ArrayList<>(...); // 普通 ArrayList, 非线程安全
// 线程A: 遍历
for (User u : users) { process(u); }
// 线程B(同时): 修改
users.add(newUser); // 线程A 遍历到一半, 集合被线程B改了 → 线程A 抛 CME
// 多线程下要"边遍历边改", 用并发集合(fail-safe, 不抛 CME):
List safe = new CopyOnWriteArrayList<>(); // 写时复制, 遍历的是快照
// 遍历它时, 别的线程改它, 不会 CME(遍历的是修改前的快照, 只是可能看不到最新改动)
Map safeMap = new ConcurrentHashMap<>(); // 并发 map, 遍历时也可安全修改
所以,这个异常其实覆盖了两种"遍历时集合被改"的情形:单线程下"自己边遍历边改"(最常见的误用),以及多线程下"我遍历、别人改"(真正的并发冲突)。而对于多线程场景,如果你确实需要"一边遍历、一边可能被其它线程修改",那就该用专门的并发集合(fail-safe 集合),比如 CopyOnWriteArrayList、ConcurrentHashMap 等。它们和普通集合(fail-fast)的处理策略不同:fail-fast 集合(ArrayList/HashMap)在遍历时一旦发现被改就抛异常(快速失败);而 fail-safe 集合(CopyOnWriteArrayList 等)则采取"容忍"的策略——比如遍历时基于一个"快照"来遍历,你遍历的过程中别人改了集合,你不会报错(但也可能看不到那些最新的改动)。把 fail-fast 和 fail-safe 这两种策略对比一下:
| fail-fast(快速失败) | fail-safe(安全失败) | |
|---|---|---|
| 代表集合 | ArrayList, HashMap 等 | CopyOnWriteArrayList, ConcurrentHashMap |
| 遍历时被改 | 立刻抛 CME | 不抛异常, 容忍 |
| 实现机制 | modCount 检测 | 遍历快照/副本 |
| 能否看到遍历中的修改 | 不适用(直接报错) | 可能看不到(遍历的是旧快照) |
| 适合 | 单线程; 快速暴露误用 | 多线程并发读写 |
第五件事:把"边遍历边改"的安全姿势归个类
讲到这儿,关于"遍历集合时修改"的各种情况和对策,可以归个类了。我把它们整理成一张"该怎么改"的速查表,遇到相关需求时对照着选:
| 需求 | 错误做法 | 正确做法 |
|---|---|---|
| 遍历时按条件删元素 | for-each 里 list.remove | removeIf(谓词) (首选) |
| 遍历时删 + 复杂逻辑 | 同上 | Iterator.remove() |
| 遍历时往集合增元素 | for-each 里 list.add | 攒临时列表, 遍历完 addAll |
| 遍历时只改元素内容 | (这个本来就安全) | 放心在 for-each 里改 |
| 多线程边遍历边改 | 用 ArrayList/HashMap | 用并发集合(CopyOnWrite等) |
| 遍历和修改要彻底隔离 | 遍历删同一个集合 | 遍历副本, 改原集合 |
这张表几乎覆盖了所有"遍历时想改集合"的场景。它们背后的核心原则,可以归纳成一句话:永远不要在"用迭代器遍历一个集合"的同时,"绕过迭代器、直接对这个集合做结构性修改"。要么用迭代器/集合提供的安全方法(removeIf、Iterator.remove)、要么把遍历和修改在对象上分离(遍历副本)或在时间上分离(攒着遍历完再改)、要么在多线程下换用专门容忍并发修改的并发集合。把这条原则和这张表记在心里,ConcurrentModificationException 这个名字唬人、其实很好对付的异常,就再也不会让你在生产里手忙脚乱了。它从一个"偶尔炸一下、还不知道为什么"的拦路虎,变成了一个"我知道它为什么炸、也知道怎么优雅地绕开它"的老熟人。
一张"遍历集合想改它怎么办"的决策图
把这次踩坑沉淀成一张图。每当你想在遍历集合时修改它,照着它选一个安全姿势:
这张图把"遍历时改集合"的所有正确姿势都串了起来:只改内容放心改;单线程删用 removeIf;单线程增攒着遍历完再加;多线程用并发集合。核心判断就两问:"改的是内容还是结构?""单线程还是多线程?"把这个判断变成习惯,CME 这个坑就彻底与你绝缘了。
我立下的几条集合遍历规矩
这次"遍历删元素报 CME"的事故后,我给自己立了几条规矩:
- 遍历时删元素用 removeIf:按条件删除集合元素,首选 removeIf(谓词),一行搞定且安全。
- 复杂删除逻辑用 Iterator.remove:删除逻辑复杂、removeIf 表达不了时,用迭代器的 remove() 方法。
- 绝不在 for-each 里直接增删集合:for-each 里直接 list.add/remove 同一个集合,是明确的错误写法。
- 遍历时增元素攒着后加:需要在遍历时往集合增元素,先攒到临时列表,遍历完 addAll。
- 分清结构性修改与内容修改:遍历时改元素内容安全,改集合结构(增删)才会 CME。
- 多线程边遍历边改用并发集合:多线程场景需要边遍历边改,用 CopyOnWriteArrayList、ConcurrentHashMap 等。
- 把 CME 当成提醒而非麻烦:遇到 CME 别烦躁,它是在帮你拦下一个更隐蔽的 bug,换个安全姿势即可。
这几条里,第七条是我最想分享的一个心态转变。我刚遇到 ConcurrentModificationException 时,第一反应是烦躁——"删个元素而已,至于抛个这么吓人的异常吗?"觉得它是在给我添麻烦。可当我真正理解了它背后的 fail-fast 机制、理解了它拦下的是一个"静默跳过元素"的更隐蔽的 bug,我对它的态度,从"嫌弃"变成了"感激"。因为我意识到:一个会"吵闹地、及时地抛异常"的设计,远比一个会"静默地、悄悄地出错"的设计,对程序员友好得多。前者把问题第一时间砸到你脸上,逼你当场解决;后者让错误的结果默默流向下游,等你某天发现时早已追查无门。所以,fail-fast 这种"宁可吵闹失败、也不静默出错"的设计哲学,是一种深刻的工程善意——它用一时的"刺耳",换来了长久的"安心"。
写在最后:好的设计,宁可"吵闹地失败",也不"静默地出错"
这次被 ConcurrentModificationException 教育的经历,让我对一个重要的设计哲学,有了切身的体会:面对"程序员可能犯的错误",一个系统(语言、框架、库)有两种态度——一种是"静默地容忍",出了问题不声不响,让错误的结果悄悄地往下流;另一种是"吵闹地拒绝",一旦检测到危险或错误的用法,就立刻、响亮地抛出异常、把问题暴露出来。而后者(fail-fast),往往是对程序员更负责、更友好的设计。因为软件开发中,最可怕的从来不是那些"会报错、会崩溃"的 bug(它们至少诚实、容易被发现),而是那些"不报错、却悄悄给出错误结果"的 bug——它们藏得深、流得远,等你发现时,损失早已造成、追查更是艰难。
想通这一点,我对"如何写出好代码、设计好系统"有了一条新的准则:主动地为你的代码,设计"快速失败"的机制——在错误或危险的用法刚一出现时,就用断言、异常、校验,把它响亮地拦下来、暴露出来,而不要让它静默地溜过去、在远处酿成更大的祸。这其实贯穿了我这一系列复盘里反复出现的主题:校验外部输入(别让脏数据静默流入)、检查 finish_reason(别让截断的输出静默被用)、用穷尽性检查(别让漏处理静默发生)……它们和 fail-fast 是同一种智慧——把潜在的错误,尽可能地"提前、显式、吵闹"地暴露出来,而不是让它"延后、隐蔽、静默"地发作。一个处处为"快速失败"而设计的系统,出 bug 时虽然"动静大",但每一个 bug 都被钉在了离它产生最近的地方,清清楚楚、好查好修;而一个处处"静默容忍"的系统,跑起来风平浪静,可一旦出问题,就是一场需要顺着长长的链路、艰难溯源的噩梦。
所以,如果你也在写代码、设计接口和系统,我想把这次踩坑最想说的话送给你:请拥抱"快速失败"的哲学——既要感激那些(像 fail-fast 这样)帮你及时拦下错误的机制,也要在自己的代码里,主动地为可能的错误设下"提前报警"的关卡。宁可让你的程序在错误的用法面前"吵闹地罢工",也别让它"忍气吞声地"产出一个看似正常、实则错误的结果。因为对一个系统的可靠性而言,"能不能及早地、响亮地暴露错误",往往比"能不能避免错误"更重要——错误难免会有,但让错误无处遁形、第一时间显形,才是真正的工程功力所在。那个名字唬人的 ConcurrentModificationException,最终教给我的,远不止"怎么安全地删集合元素",而是这份对"快速失败"哲学的深深认同——它让我从一个被异常吓到、嫌它麻烦的新手,变成了一个理解并主动运用"宁可吵闹失败、不可静默出错"这条原则的工程师。愿你我都能把这份哲学,织进自己写的每一行代码里,让错误无所遁形,让系统稳如磐石。
—— 别看了 · 2026