一段在 for-each 循环里直接 remove 元素的 Java 代码,跑起来就抛 ConcurrentModificationException,我和快速失败机制正面撞上了:一次集合遍历删除的深度复盘

一段单线程的 for-each 遍历 ArrayList、在循环里 remove 满足条件的元素,一运行就抛 ConcurrentModificationException——可明明是单线程,哪来的并发?根因是增强 for 底层用迭代器遍历,迭代器有 fail-fast 机制:记录集合的 modCount,每步检查它有没有被改;而 list.remove 绕过迭代器直接改集合让 modCount 不一致,迭代器一检查就抛异常,这个 Concurrent 指迭代期间的结构性修改、和多线程无关。本文讲透 fail-fast 与 modCount 机制,给出 removeIf/Iterator.remove/倒序索引/Stream.filter 的正解,梳理集合遍历常见坑,最后落到'读懂约束背后的设计智慧、把报错当朋友而非障碍、拥抱 fail-fast 让错误尽早暴露'的认知。

一段在 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 集合时,刻进骨子里的几条铁律:

  1. for-each 里别直接 list.remove/add。会触发 ConcurrentModificationException。
  2. ConcurrentModificationException 和多线程无关。它指迭代期间的结构性修改。
  3. 按条件删用 removeIf。最简洁,首选。
  4. 遍历中删用 Iterator.remove()。它会同步 expectedModCount,不冲突。
  5. 用索引删要倒序。正序删后下标前移会漏元素。
  6. 不想改原集合用 Stream.filter 生成新的。无副作用更安全。
  7. 理解并善用 fail-fast。让错误尽早暴露在离根因最近的地方。

写在最后

回头看,这场由"遍历时直接删元素"引发的、撞上 fail-fast 的事故,真正教给我的,远不止"遍历删除要用 removeIf 或 Iterator.remove"这一个技巧。它让我对"读懂一个机制'为什么这么设计',往往比记住'它会报什么错'更有价值",有了一次刻骨的体会。我一开始的反应,是把这个 ConcurrentModificationException 当成一个"讨厌的、需要绕开的障碍"——我只想"怎么让它别抛",一度甚至想"是不是 catch 住它就行了"。可当我真正搞懂了它背后的 fail-fast 机制和设计意图后,我的态度彻底变了:它根本不是在"找我麻烦",而是在"保护我"——它在用一声响亮的报错告诉我:"你正在做一件危险的事(边遍历边乱改),这很可能导致漏元素、行为错乱,我现在就拦住你、让你尽早改对,而不是等你上线后被诡异的结果坑惨"当我从"它为什么拦我"理解到"它在替我把关",这个异常就从"障碍"变成了"朋友"这让我领悟到一个关于学习的深刻认知:对待框架/语言抛出的报错、限制、"不让你这么做"的约束,第一反应不该是"怎么绕过它",而该是"它为什么要这样?它在防止什么?"——绝大多数这类约束(类型检查、fail-fast、不可变、编译错误)都不是设计者在刁难你,而是他们把"前人踩过的坑"凝结成的"护栏",在你将要犯错时拦住你;"读懂约束背后的善意和原理",你就能从"和工具对抗(绕过报错)"转向"和工具协作(理解并顺着它的设计写对)"这给了我一种面对约束时的成熟心态:把每一个报错、每一条限制,都当成一次"了解这个系统的设计智慧"的机会——去问"它为什么这么设计、它在保护什么",而不是急着 catch 掉、绕过去、压制它;"理解约束、与设计者的意图同频",不仅能让你写出更正确的代码,更能让你从每一次踩坑里,学到设计者沉淀的经验把报错和约束当成理解设计智慧的机会、读懂它的善意而非急于绕过——这,是我用一次 ConcurrentModificationException 的事故,换来的、关于 Java、也关于如何从工具的约束中学习成长的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次再见到这个异常时,不再急着想"怎么绕过",而是会心一笑"哦,它在提醒我别边遍历边乱改",转而用上 removeIf,那我对着这个误导性的异常名困惑的这半天,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

一个被多个 goroutine 同时读写的普通 map,把整个 Go 服务以 fatal error 直接干崩、连 recover 都拦不住:一次 map 并发不安全的深度复盘

2026-6-2 17:42:56

技术教程

一条 NOT IN 子查询的 SQL,因为子查询里混进了一个 NULL,把本该返回几千行的结果集变成了空,我栽进了 SQL 三值逻辑的坑:一次 NULL 处理的深度复盘

2026-6-2 17:53:28

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索