一段在 for-each 循环里直接 remove 元素的 Java 代码,跑起来就抛 ConcurrentModificationException,我和"快速失败"机制正面撞上了:一次集合遍历删除的深度复盘
那个异常来得莫名其妙:我有一段再普通不过的代码,用增强 for 循环(for-each)遍历一个 ArrayList,在循环里把满足条件的元素 remove 掉。逻辑清清楚楚,可一运行就抛 ConcurrentModificationException(并发修改异常)。我当时一脸懵:"我这明明是单线程啊,从头到尾就一个线程在跑,哪来的'并发修改'?"我盯着这个名字里带"Concurrent(并发)"的异常排查了半天,才终于明白它和"多线程"其实没什么关系,后背发凉:Java 的集合(如 ArrayList)的迭代器,有一个叫"快速失败(fail-fast)"的机制——迭代器在创建时会记录集合的"修改次数(modCount)",遍历的每一步都会检查"这个修改次数有没有被改过"。而增强 for 循环,本质上就是用迭代器(Iterator)在遍历。当我在循环里直接调用 list.remove() 时,我是绕过迭代器、直接改了集合,这让集合的 modCount 变了,但迭代器自己记录的那个期望值没变;于是迭代器在下一步检查时发现"咦,集合被我不知道的方式改过了",就立刻抛出 ConcurrentModificationException。这个"Concurrent"指的是"在迭代过程中发生了结构性修改",不一定是多线程——单线程里"边遍历边用集合自己的方法删",照样触发。这篇就把这次"遍历时删除、撞上 fail-fast"的坑,从头到尾复盘一遍。
故障现场:for-each 遍历时直接 remove
问题代码,是一个几乎人人都会下意识这么写的"遍历删除":
// ✗ 出问题的代码: 增强for循环里直接 list.remove()
List list = new ArrayList<>(Arrays.asList("a", "b", "c", "d"));
for (String s : list) { // 增强for, 底层用的是 Iterator
if (s.equals("b")) {
list.remove(s); // ✗ 直接改集合(绕过迭代器) → ConcurrentModificationException!
}
}
// 为什么:
// - 增强for循环 for(String s : list) 编译后, 本质是用 list.iterator() 拿迭代器在遍历;
// - ArrayList的迭代器有"fail-fast"机制: 它记录创建时集合的 modCount(修改次数);
// 每次 next() 都检查: 当前集合的modCount == 我记的expectedModCount 吗?
// - list.remove(s) 直接改了集合 → 集合的modCount变了, 但迭代器的expectedModCount没变;
// - → 迭代器下一次 next()/检查时发现两者不等 → 抛 ConcurrentModificationException。
// "Concurrent" 的真正含义:
// - 它指"在迭代期间, 集合发生了【结构性修改(增/删)】, 而这个修改不是通过迭代器自己做的";
// - 【不一定是多线程】! 单线程里"边for-each边用list.remove"就会触发(本文就是)。
// - (多线程下一个线程遍历、另一个改, 也会触发——所以叫Concurrent, 但单线程同样会。)
// 还有个"更坑"的情况(删倒数第二个元素时可能不抛、但漏删):
// - 由于fail-fast检查的实现细节, 删除"倒数第二个元素"有时不抛异常但行为错误;
// - → 所以靠"运气"它没抛, 也是错的, 别依赖。
// 关键: 增强for用迭代器遍历; 遍历期间直接用集合的remove/add(结构性修改)会让modCount不一致,
// 触发fail-fast的ConcurrentModificationException——和多线程无关, 单线程也会。
第一次搞懂这个异常时,我又恍然又有点哭笑不得:"一个单线程的循环,被一个叫'并发修改'的异常给拦下了,这名字也太误导人了。"这个坑最迷惑人的地方,正是它的名字——ConcurrentModificationException 里的"Concurrent"让几乎所有人第一反应都是"多线程并发",于是在单线程代码里见到它就完全摸不着头脑;可它真正的含义是"迭代期间发生了结构性修改",和线程数无关。另一个坑是它的不稳定:因为 fail-fast 检查的实现细节(删倒数第二个元素时可能恰好不触发检查),它有时抛、有时不抛但悄悄删错——"没抛异常"不代表"对了"。下面就来拆解,该怎么在遍历时安全地删除。
第一件事:搞懂 fail-fast 机制,以及为什么遍历时不能直接改集合
我认真研究了 Java 集合的 fail-fast 机制,才彻底理解这个坑。
fail-fast 机制: 为什么遍历时直接改集合会抛异常?
【核心: 迭代器记录集合的修改次数(modCount); 遍历时若集合被"迭代器之外的方式"改了, modCount不一致, 立刻抛异常】
1. modCount 是什么:
- ArrayList等集合内部有个计数器 modCount, 记录集合被"结构性修改"(增/删)的次数;
- 每次 add/remove(改变了集合大小/结构)都会让 modCount + 1。
2. 迭代器的 fail-fast 检查:
- 创建迭代器时, 它把当前的 modCount 记下来, 存为 expectedModCount;
- 每次调用迭代器的 next()(增强for每轮都会调)时, 检查:
当前集合的 modCount == 我记的 expectedModCount 吗?
- 不相等 → 说明"集合被我(迭代器)不知道的方式改过了" → 抛 ConcurrentModificationException。
3. 为什么 list.remove() 会触发:
- list.remove() 直接改集合 → 集合 modCount +1;
- 但迭代器的 expectedModCount 没跟着变(它不知道你绕过它改了);
- → 下次 next() 检查时, 两者不等 → 抛异常。
- 而 iterator.remove()(迭代器自己的remove)会【同时更新】expectedModCount → 不冲突。
4. 为什么叫 fail-fast(快速失败):
- "边遍历边被意外修改"是一种危险/易错的情况(可能漏元素、重复、行为不可预测);
- 与其让它"悄悄地产生错误结果", 不如【立刻、明确地抛异常】, 让你尽早发现问题;
- → fail-fast 是一种"宁可报错也不带病运行"的设计(和Go的map并发fatal error异曲同工)。
5. "Concurrent"为何有误导:
- 这个机制最初是为"多线程并发修改"设计的检测, 故名Concurrent;
- 但它检测的本质是"迭代期间的结构性修改", 单线程"自己边遍历边删"也满足 → 同样触发。
一句话: 集合用modCount记结构性修改次数, 迭代器fail-fast会校验它; 遍历时用集合自身的remove/add
直接改(绕过迭代器)会让modCount不一致、立刻抛ConcurrentModificationException(和多线程无关)。
这套机制,是整个坑的根。modCount 是什么:ArrayList 内部有个计数器记录集合被"结构性修改"(增/删)的次数,每次 add/remove 让它 +1。迭代器的 fail-fast 检查:创建迭代器时记下当前 modCount 为 expectedModCount,每次 next() 检查"当前 modCount == expectedModCount 吗",不等就抛 ConcurrentModificationException。为什么 list.remove() 触发:它直接改集合让 modCount+1,但迭代器的 expectedModCount 没跟着变(它不知道你绕过它改了),下次 next() 检查不等就抛;而 iterator.remove() 会同时更新 expectedModCount 所以不冲突。为什么叫 fail-fast:"边遍历边被意外修改"危险易错(可能漏/重/行为不可预测),与其悄悄产生错误结果不如立刻明确抛异常让你尽早发现(和 Go 的 map fatal error 异曲同工)。"Concurrent"为何误导:它最初为多线程并发修改设计、故名,但本质检测的是"迭代期间的结构性修改",单线程自己边遍历边删也触发。一句话:集合用 modCount 记结构性修改次数,迭代器 fail-fast 会校验它;遍历时用集合自身的 remove/add 直接改(绕过迭代器)会让 modCount 不一致、立刻抛 ConcurrentModificationException(和多线程无关)。
第二件事:正解——用 Iterator.remove、removeIf、或倒序索引遍历
搞懂了原理,正解就清晰了:遍历时要删,用迭代器自己的 remove()(它会同步 modCount)、或用 removeIf(最简洁)、或用倒序的索引 for 循环、或用 Stream 过滤生成新集合。
// ====== 正解一(最简洁, Java 8+): 用 removeIf ======
List list = new ArrayList<>(Arrays.asList("a", "b", "c", "d"));
list.removeIf(s -> s.equals("b")); // ✓ 一行搞定, 内部正确处理了迭代和删除
// → removeIf是为"按条件删除"专门设计的, 简洁且安全, 首选。
// ====== 正解二: 用 Iterator 自己的 remove() ======
Iterator it = list.iterator();
while (it.hasNext()) {
String s = it.next();
if (s.equals("b")) {
it.remove(); // ✓ 迭代器自己的remove, 会【同步更新expectedModCount】
}
}
// → it.remove()删除的是"上一次next()返回的元素", 且会更新迭代器的expectedModCount → 不冲突。
// 这是"遍历中删除"的经典正确写法(removeIf之前的标准做法)。
// ====== 正解三: 倒序的索引 for 循环 ======
for (int i = list.size() - 1; i >= 0; i--) {
if (list.get(i).equals("b")) {
list.remove(i); // ✓ 用索引删; 倒序是为了删除后不影响"还没遍历到"的元素的下标
}
}
// → 用索引(不是迭代器)就不受fail-fast约束; 倒序遍历避免"删了前面的, 后面的下标前移导致漏删"。
// (正序索引删除会漏元素: 删了i, i+1的元素前移到i, 但下一轮i++跳过了它。)
// ====== 正解四: 用 Stream 过滤, 生成新集合(不修改原集合) ======
List result = list.stream()
.filter(s -> !s.equals("b")) // 保留不等于"b"的
.collect(Collectors.toList());
// → 函数式风格: 不修改原集合, 而是产生一个过滤后的新集合; 无副作用、更安全。
// ====== 反例对照 ======
// ✗ for (String s : list) { if (cond) list.remove(s); } // for-each里直接删 → CME(本文)
// ✓ list.removeIf(cond); // 最佳
// ====== 选型 ======
// - 按条件删除: removeIf(最简洁, 首选);
// - 删除时还要别的迭代器操作: Iterator + it.remove();
// - 习惯用索引: 倒序索引for;
// - 想要不可变/函数式: Stream.filter生成新集合。
// 核心: 遍历时删除别用for-each直接remove; 用removeIf(首选)、Iterator.remove(经典)、
// 倒序索引for、或Stream.filter生成新集合; 别绕过迭代器去结构性修改正在被遍历的集合。
修复的核心,是"用不会破坏迭代一致性的方式删除"。正解一(最简洁,Java 8+):removeIf——list.removeIf(s -> ...) 一行搞定,内部正确处理了迭代和删除,首选。正解二:Iterator.remove()——迭代器自己的 remove 会同步更新 expectedModCount 所以不冲突,删除的是上一次 next() 返回的元素,这是经典正确写法。正解三:倒序索引 for——用索引不受 fail-fast 约束,倒序是为了删除后不影响还没遍历的元素下标(正序索引删会漏元素)。正解四:Stream.filter——生成过滤后的新集合、不修改原集合,无副作用。归根结底:遍历时删除别用 for-each 直接 remove;用 removeIf(首选)、Iterator.remove(经典)、倒序索引 for、或 Stream.filter 生成新集合;别绕过迭代器去结构性修改正在被遍历的集合。
第三件事:Java 集合遍历与修改的其他常见坑
排查后我把 Java 集合遍历/修改相关的其他常见坑也系统梳理了一遍。
Java 集合遍历 / 修改的其他常见坑
# 1. for-each里直接remove/add(本文): 触发ConcurrentModificationException。→ removeIf/Iterator.remove。
# 2. 正序索引删除漏元素: 删了i, 后面元素前移, i++跳过了它。→ 倒序删, 或删后i--。
# 3. 多线程遍历+修改: 一个线程遍历、另一个改 → CME(这才是真"并发")。→ 加锁/并发容器。
# 4. Arrays.asList返回的List不可增删: 它是定长视图, add/remove抛UnsupportedOperationException。
# → 要可变就 new ArrayList<>(Arrays.asList(...))。
# 5. 用Collections.unmodifiableList后改: 改不可变视图抛UnsupportedOperationException。
# 6. subList的坑: subList是原list的视图, 改subList影响原list, 改原list又让subList失效。
# 7. 遍历Map时改Map: 同样会CME。→ 用entrySet的Iterator.remove, 或removeIf(values/keySet)。
# 8. 并发场景用普通集合: ArrayList/HashMap非线程安全。→ 用CopyOnWriteArrayList/ConcurrentHashMap。
# 共同根源: 集合在"被遍历"时, 有一个"遍历过程的一致性"假设; 在遍历中结构性修改它(尤其绕过迭代器),
# 会破坏这个一致性; Java用fail-fast检测并报错, 帮你尽早发现这类危险操作。
# 核心: 遍历中删除用removeIf/Iterator.remove/倒序索引/Stream; 理解fail-fast和modCount;
# 注意Arrays.asList/subList/不可变视图的限制; 并发场景用并发安全集合。
排查让我把集合遍历的其他坑也梳理清了。一、for-each 里直接 remove/add(本文)。二、正序索引删除漏元素(倒序删)。三、多线程遍历+修改(真"并发"的 CME)。四、Arrays.asList 不可增删(定长视图)。五、unmodifiableList 改抛异常。六、subList 的视图坑。七、遍历 Map 时改 Map(用 entrySet 的 Iterator.remove)。八、并发场景用普通集合。它们的共同根源是:集合在"被遍历"时有一个"遍历过程的一致性"假设;在遍历中结构性修改它(尤其绕过迭代器)会破坏这个一致性;Java 用 fail-fast 检测并报错,帮你尽早发现这类危险操作。核心是:遍历中删除用 removeIf/Iterator.remove/倒序索引/Stream;理解 fail-fast 和 modCount;注意 Arrays.asList/subList/不可变视图的限制;并发场景用并发安全集合。下面这张图,是这次 CME 坑的成因与解法:
第四件事:遍历删除方案对比表
这次踩坑后,我把"遍历中删除元素"的几种方案整理成一张表,按场景选。
| 方案 | 安全吗 | 适用 |
|---|---|---|
| for-each + list.remove | ✗ 抛CME | 别用(本文的错误) |
| removeIf(条件) | ✓ 安全 | 按条件删, 最简洁首选 |
| Iterator + it.remove() | ✓ 安全 | 遍历中删, 经典写法 |
| 倒序索引 for + remove(i) | ✓ 安全 | 习惯用索引/要下标 |
| 正序索引 for + remove(i) | △ 易漏删 | 要删后i--, 否则漏 |
| Stream.filter 生成新集合 | ✓ 安全 | 函数式/不改原集合 |
这张表把遍历删除方案钉清了。核心是:"遍历中删除"这个看似简单的操作,正确的写法有好几种(removeIf/Iterator.remove/倒序索引/Stream),它们的共同点是"要么用迭代器自己的删除(它会维护一致性)、要么用索引(不走迭代器)、要么干脆不改原集合(生成新的)";唯独"for-each 里直接 remove"是错的(绕过迭代器改集合)。它给我的最大启发是:"一边遍历一边修改正在遍历的东西"是一个普遍危险的操作模式——不只是 Java 集合,在很多场景下"边迭代边改"都会出问题(改正在遍历的列表、修改正在迭代的字典、删正在循环的文件……);因为"遍历"通常隐含了"遍历期间这个东西是稳定的"这个假设,而"修改"打破了它。这其实是一条通用的编程戒律:"不要在遍历一个集合的同时,修改它的结构"——要么用专门支持"遍历中安全删除"的机制(迭代器的 remove、removeIf),要么"先收集要删的、遍历完再统一删",要么"遍历副本/生成新集合";这条戒律在 Java、Python(RuntimeError: dictionary changed size during iteration)、各语言里都成立——"遍历与修改要分离"。掌握遍历删除的安全写法、牢记"遍历时别改结构"这条通用戒律——是这个坑带给我的认知。
第五件事:fail-fast 体现的一种设计哲学
这次让我再次见识了"fail-fast"这种设计。我把它和"fail-safe"的对比、以及它的价值整理成表。
| 维度 | fail-fast(快速失败) | fail-safe(安全失败) |
|---|---|---|
| 遇到异常情况 | 立刻抛异常报错 | 容忍, 尽量继续 |
| Java集合例子 | ArrayList/HashMap迭代器 | CopyOnWriteArrayList(遍历副本) |
| 优点 | 尽早暴露问题, 不带病运行 | 遍历期间能容忍修改 |
| 代价 | 会抛异常(需正确使用) | 遍历的是旧快照/有开销 |
| 设计取向 | 正确性优先, 暴露问题 | 可用性优先, 容忍 |
这张表道出了一种重要的设计哲学。核心是:fail-fast(快速失败)是一种"一旦检测到危险/异常的状态,就立刻、明确地报错,而不是默默地继续"的设计——Java 普通集合的 fail-fast 迭代器、Go 的 map 并发 fatal error 都是它;它的价值在于"让问题尽早、明确地暴露,而不是潜伏成更难查的诡异 bug"。它给我的深刻启发是:"尽早失败"是一种极其宝贵的工程美德——一个错误,越早被发现、离它的根因越近,就越容易定位和修复;反之,如果系统"容忍"了错误、带着它继续跑,错误会传播、累积、变形,等到它最终以某种诡异现象爆发时,已经离根因十万八千里、极难排查;所以"在错误发生的第一现场就让它响亮地暴露"(fail-fast),往往比"悄悄容忍、事后再查"(fail-silently)对系统的长期健康更有利。这给了我一种主动拥抱失败的态度:写代码时,要主动地、尽早地检测和暴露错误,而不是怕麻烦地容忍或掩盖——用断言、用参数校验、用类型检查、用 fail-fast 的设计,让不该发生的情况"一发生就响";"让 bug 早暴露、在离根因最近的地方暴露",是降低调试成本、提高系统可靠性的一条根本原则;不要用 catch-all、默认值、忽略错误去"维持表面的正常",那只是把问题推到了更难处理的将来。理解并拥抱 fail-fast 的价值、主动让错误尽早暴露在离根因最近处——是这个坑,从设计哲学层面给我的启发。
第六件事:遍历集合要改它时,我现在的判断习惯
现在每当我要在遍历一个集合时修改它,我都会按这张图先想一想:
这张图的精髓,是"结构性修改别用 for-each 直接改,用 removeIf/Iterator/Stream"。只改元素内容 for-each 无妨;要增删(结构性修改)就别 for-each 直接改:按条件删用 removeIf、复杂用 Iterator.remove、要新集合用 Stream.filter、要加元素则先收集再统一加或遍历副本。这套习惯,让我从"遍历里随手 remove"变成了"遍历改集合先想用哪种安全方式"——核心始终是:遍历时做结构性修改别绕过迭代器,用 removeIf/Iterator.remove/Stream 等安全方式。
我立下的几条规矩
这场"遍历删除撞上 fail-fast"的事故,换来了我写 Java 集合时,刻进骨子里的几条铁律:
- for-each 里别直接 list.remove/add。会触发 ConcurrentModificationException。
- ConcurrentModificationException 和多线程无关。它指迭代期间的结构性修改。
- 按条件删用 removeIf。最简洁,首选。
- 遍历中删用 Iterator.remove()。它会同步 expectedModCount,不冲突。
- 用索引删要倒序。正序删后下标前移会漏元素。
- 不想改原集合用 Stream.filter 生成新的。无副作用更安全。
- 理解并善用 fail-fast。让错误尽早暴露在离根因最近的地方。
写在最后
回头看,这场由"遍历时直接删元素"引发的、撞上 fail-fast 的事故,真正教给我的,远不止"遍历删除要用 removeIf 或 Iterator.remove"这一个技巧。它让我对"读懂一个机制'为什么这么设计',往往比记住'它会报什么错'更有价值",有了一次刻骨的体会。我一开始的反应,是把这个 ConcurrentModificationException 当成一个"讨厌的、需要绕开的障碍"——我只想"怎么让它别抛",一度甚至想"是不是 catch 住它就行了"。可当我真正搞懂了它背后的 fail-fast 机制和设计意图后,我的态度彻底变了:它根本不是在"找我麻烦",而是在"保护我"——它在用一声响亮的报错告诉我:"你正在做一件危险的事(边遍历边乱改),这很可能导致漏元素、行为错乱,我现在就拦住你、让你尽早改对,而不是等你上线后被诡异的结果坑惨"。当我从"它为什么拦我"理解到"它在替我把关",这个异常就从"障碍"变成了"朋友"。这让我领悟到一个关于学习的深刻认知:对待框架/语言抛出的报错、限制、"不让你这么做"的约束,第一反应不该是"怎么绕过它",而该是"它为什么要这样?它在防止什么?"——绝大多数这类约束(类型检查、fail-fast、不可变、编译错误)都不是设计者在刁难你,而是他们把"前人踩过的坑"凝结成的"护栏",在你将要犯错时拦住你;"读懂约束背后的善意和原理",你就能从"和工具对抗(绕过报错)"转向"和工具协作(理解并顺着它的设计写对)"。这给了我一种面对约束时的成熟心态:把每一个报错、每一条限制,都当成一次"了解这个系统的设计智慧"的机会——去问"它为什么这么设计、它在保护什么",而不是急着 catch 掉、绕过去、压制它;"理解约束、与设计者的意图同频",不仅能让你写出更正确的代码,更能让你从每一次踩坑里,学到设计者沉淀的经验。把报错和约束当成理解设计智慧的机会、读懂它的善意而非急于绕过——这,是我用一次 ConcurrentModificationException 的事故,换来的、关于 Java、也关于如何从工具的约束中学习成长的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次再见到这个异常时,不再急着想"怎么绕过",而是会心一笑"哦,它在提醒我别边遍历边乱改",转而用上 removeIf,那我对着这个误导性的异常名困惑的这半天,就值了。
—— 别看了 · 2026