我在 for-each 循环里遍历一个 List 时顺手删掉了几个元素,明明是单线程却抛了个 ConcurrentModificationException,我对着增强 for 循环底层用迭代器的 fail-fast 机制这个坑排查了大半天的复盘
这是一个让我对 Java "遍历和修改不能同时进行"彻底记牢的坑。它最让我困惑的是那个异常的名字:ConcurrentModificationException(并发修改异常)——可我的代码压根就是单线程的,根本没有任何"并发",凭什么报一个"并发修改"的异常?
需求很常见:我要遍历一个列表,把其中满足某个条件的元素删掉(比如删掉所有已取消的订单)。我很自然地用了增强 for 循环(for-each),边遍历边删:
// 遍历列表, 删掉满足条件的元素(有问题的版本)
List<Order> orders = new ArrayList<>(...);
for (Order order : orders) { // 增强for循环(for-each)
if (order.isCancelled()) {
orders.remove(order); // ★ 边遍历边删除! 💥
}
}
// 运行时抛: java.util.ConcurrentModificationException
// —— 单线程! 没有任何并发! 却报了"并发修改异常"
我盯着这个 ConcurrentModificationException,一脸懵。我的代码从头到尾就一个线程,没有 new Thread、没有线程池、没有任何并发,凭什么报"并发修改"异常?而且这逻辑看起来天经地义啊——遍历列表,遇到符合条件的就删掉,这有什么问题?可它就是稳定地、必现地在删除时抛异常。这个"单线程却报并发异常"的矛盾,让我抓狂了好一阵。
第一件事:看清真相——for-each 底层是迭代器,遍历时改集合触发 fail-fast
我去深入理解了 Java 增强 for 循环的底层、以及集合的 fail-fast 机制,才彻底明白这个"单线程并发异常"之谜——增强 for 循环底层其实是用迭代器(Iterator)来遍历的;而集合内部有个"修改计数器(modCount)",每次结构性修改(add/remove)它就 +1;迭代器记住了开始时的计数,每次 next() 都会检查"计数变了没"——一旦发现集合在遍历过程中被直接修改了(计数对不上),就立刻抛 ConcurrentModificationException,这叫 fail-fast(快速失败)。
for-each + fail-fast 的真相
# 1. 增强for循环(for-each)的底层: 它其实是【迭代器Iterator】的语法糖!
# for (Order o : orders) {...} 编译后约等于:
# Iterator it = orders.iterator();
# while (it.hasNext()) { Order o = it.next(); ... }
# 2. 集合(ArrayList等)内部有个【modCount(修改计数器)】:
# - 每次对集合做【结构性修改】(add/remove等改变大小的操作), modCount++。
# 3. 迭代器的 fail-fast 机制:
# - 创建迭代器时, 它记下当时的 modCount(叫 expectedModCount);
# - 每次调用 it.next(), 它都会检查: 当前modCount == expectedModCount 吗?
# - 如果【不相等】→ 说明集合在遍历过程中被【直接修改】过 → 立刻抛 CME!
# 4. 我的代码发生了什么:
# - for-each 在底层用迭代器遍历 orders;
# - 我在循环里 orders.remove(order) —— 【直接修改了集合】, modCount++;
# - 下一次迭代器 next() 时, 发现 modCount != expectedModCount → 抛 CME!
# 5. 为什么叫"ConcurrentModification"却在单线程也报:
# - 这个名字有误导性。它检测的不是"多线程并发", 而是
# "在迭代过程中, 集合被迭代器之外的方式修改了"(comodification)。
# - 单线程里, "一边用迭代器遍历、一边用集合自己的remove改它", 就触发了。
# - (当然多线程并发改也会触发, 所以叫这名字, 但单线程同样会中招)
# 6. fail-fast 的意义: 它是一种【保护】——遍历时改集合会导致遍历行为不可预测
# (漏元素/重复/越界), Go式地"立刻报错"比"默默给错误结果"好。
# 核心: 增强for底层是迭代器, 集合有modCount, 迭代器靠它fail-fast——遍历中直接改集合(add/remove)
# 会让modCount对不上, 立刻抛CME(单线程也会, 名字有误导); 要删要用迭代器自己的remove/removeIf。
真相大白,我恍然大悟。原来增强 for 循环底层其实是迭代器(Iterator)的语法糖;而集合内部有个 modCount(修改计数器),每次结构性修改(add/remove)它就 +1;迭代器创建时记下当时的计数(expectedModCount),每次 next() 都检查"计数变了没"——一旦不相等,就立刻抛 ConcurrentModificationException,这叫 fail-fast。我的代码:for-each 在底层用迭代器遍历,而我在循环里 orders.remove(order) 直接修改了集合、modCount++;下一次迭代器 next() 发现计数对不上,就抛 CME。而为什么叫"ConcurrentModification"却在单线程也报:这个名字有误导性——它检测的不是"多线程并发",而是"在迭代过程中,集合被迭代器之外的方式修改了"(co-modification);单线程里"一边用迭代器遍历、一边用集合自己的 remove 改它"就触发了。而 fail-fast 的意义是一种保护:遍历时改集合会导致遍历行为不可预测(漏元素/重复/越界),"立刻报错"比"默默给错误结果"好得多。
第二件事:正解——用 iterator.remove()、removeIf(),或遍历副本
搞懂了原理,正解就清晰了:要在遍历中删元素,就用迭代器自己的 remove()(它会同步 modCount);更简洁的用 removeIf();或遍历一个副本来删原集合;并发场景用并发集合。
// ====== 正解一(最简洁, Java8+): removeIf ======
orders.removeIf(order -> order.isCancelled()); // ★ 一行搞定, 内部安全地删除
// → removeIf 内部用迭代器正确处理删除, 简洁又安全, 首选!
// ====== 正解二: 显式用 Iterator 的 remove() ======
Iterator it = orders.iterator();
while (it.hasNext()) {
Order order = it.next();
if (order.isCancelled()) {
it.remove(); // ★ 用迭代器自己的remove! 它会同步modCount, 不会CME
}
}
// 关键: it.remove() 删的是"上一次next()返回的元素", 且它会更新expectedModCount,
// 所以不会触发fail-fast。(注意: 不能用 orders.remove(), 只能用 it.remove())
// ====== 正解三: 遍历副本, 删原集合(或反之) ======
for (Order order : new ArrayList<>(orders)) { // 遍历一个【副本】
if (order.isCancelled()) {
orders.remove(order); // 删【原集合】(遍历的是副本, 不冲突)
}
}
// → 遍历的和修改的是两个不同的集合, 互不干扰; 但有复制开销。
// ====== 正解四: 先收集要删的, 遍历后批量删 ======
List toRemove = new ArrayList<>();
for (Order order : orders) {
if (order.isCancelled()) toRemove.add(order); // 遍历时只收集, 不删
}
orders.removeAll(toRemove); // 遍历结束后再批量删
// ====== 正解五(并发场景): 用并发安全的集合 ======
// 多线程并发遍历+修改, 用 CopyOnWriteArrayList(写时复制, 遍历用快照)
// 或 ConcurrentHashMap(并发安全, 遍历时允许修改, 弱一致性)。
// 核心: 遍历中删元素用 removeIf(首选)或 Iterator.remove()(它同步modCount); 或遍历副本删原集合、
// 或收集后批量删; 别在for-each里直接 集合.remove(); 并发场景用CopyOnWriteArrayList/ConcurrentHashMap。
修复的核心,是"用迭代器自己的 remove 或 removeIf,别在 for-each 里直接改集合"。正解一(最简洁,Java8+):removeIf——orders.removeIf(o -> o.isCancelled()) 一行搞定,内部用迭代器安全删除,首选。正解二:显式用 Iterator.remove()——用 it.remove()(删上次 next 返回的元素),它会同步 expectedModCount、不触发 fail-fast;注意只能用 it.remove()、不能用 orders.remove()。正解三:遍历副本删原集合——for (Order o : new ArrayList<>(orders)) 遍历副本、删原集合,两个集合互不干扰(有复制开销)。正解四:先收集要删的,遍历后批量删——遍历时只 add 到 toRemove,结束后 removeAll。正解五(并发场景):用并发安全集合——CopyOnWriteArrayList(写时复制、遍历用快照)、ConcurrentHashMap(并发安全、弱一致性)。归根结底:遍历中删元素用 removeIf(首选)或 Iterator.remove();或遍历副本/收集后批量删;别在 for-each 里直接 集合.remove();并发场景用 CopyOnWriteArrayList/ConcurrentHashMap。
第三件事:集合操作的其他常见坑
排查后我把 Java 集合操作相关的其他常见坑也系统梳理了一遍。
Java 集合操作的其他常见坑
# 1. 遍历时直接改集合(本文): CME。→ removeIf/Iterator.remove。
# 2. Arrays.asList 返回的List不可变(大小): 对它add/remove抛UnsupportedOperationException。
# → 要可变就 new ArrayList<>(Arrays.asList(...))。
# 3. List.of/Map.of(Java9+)返回不可变集合: 改它抛异常。→ 需要可变就拷贝。
# 4. subList是视图: list.subList()返回的是原list的视图, 改它影响原list, 原list结构改了subList还会CME。
# 5. 用对象做HashSet/HashMap的key没重写equals/hashCode: 行为异常(见专文)。
# 6. 在Map遍历中改Map: 同样CME。→ 用iterator.remove或遍历entrySet收集后删。
# 7. 自动装箱在集合里: List频繁装拆箱有开销, ==比较有缓存坑(见专文)。
# 8. 并发修改集合却用非并发集合: 多线程下CME或数据损坏。→ 用并发集合/加锁。
# 共同根源: Java集合有"快照式遍历"的假设——遍历期间集合结构不应被(迭代器之外地)改变;
# 而很多操作(直接remove、不可变集合、视图)违背了使用者对集合"行为"的朴素直觉。
# 核心: 遍历别直接改集合(用removeIf/迭代器remove); 注意asList/List.of的不可变、subList视图、
# 并发要用并发集合; 用集合前搞清它"可不可变、是不是视图、并不并发安全"。
排查让我把集合操作的其他坑也梳理清了。一、遍历时直接改集合(本文)。二、Arrays.asList 返回的 List 不可变(大小)(add/remove 抛 UnsupportedOperationException)。三、List.of/Map.of 返回不可变集合。四、subList 是视图(改它影响原 list)。五、对象做 key 没重写 equals/hashCode。六、Map 遍历中改 Map(同样 CME)。七、集合里的自动装箱(开销+缓存坑)。八、并发修改用非并发集合。它们的共同根源是:Java 集合有"快照式遍历"的假设——遍历期间集合结构不应被(迭代器之外地)改变;而很多操作违背了使用者对集合"行为"的朴素直觉。核心是:遍历别直接改集合;注意 asList/List.of 的不可变、subList 视图、并发要用并发集合;用集合前搞清它"可不可变、是不是视图、并不并发安全"。下面这张图,是这次 CME 的成因与解法:
第四件事:遍历中删除元素的几种方案对照表
这次踩坑后,我把"遍历中删元素"的几种方案整理成一张表。
| 方案 | 会CME吗 | 特点 |
|---|---|---|
| for-each + 集合.remove() | ✗ 抛CME | 错误用法 |
| removeIf(条件) | ✓ 安全 | 最简洁, Java8+, 首选 |
| Iterator + it.remove() | ✓ 安全 | 经典写法, 可控 |
| 遍历副本删原集合 | ✓ 安全 | 有复制开销 |
| 收集后批量removeAll | ✓ 安全 | 两趟, 清晰 |
| 倒序for(i--)按索引删 | ✓ 安全 | 仅限List, 倒序避免索引错位 |
| CopyOnWriteArrayList | ✓ 安全 | 并发场景, 写时复制 |
这张表把"遍历中删除"的方案钉清了。核心是:唯一错误的是"for-each + 集合自己的 remove";其他方案(removeIf、Iterator.remove、遍历副本、批量删、倒序索引删、并发集合)都安全,各有适用——其中 removeIf 最简洁、是首选。它给我的最大启发是:同一个需求("遍历中删元素"),Java 提供了好几种正确的实现方式,它们在简洁度、可控性、性能、适用场景上各有取舍;而随着语言演进(Java8 的 removeIf、Stream),很多过去要"小心翼翼写迭代器"的操作,有了更简洁、更不易出错的声明式写法。这让我意识到:遇到一个"经典坑"时,除了知道"怎么绕过它的传统办法"(迭代器 remove),也要去了解"语言有没有提供更现代、更优雅、更不易错的新办法"(removeIf/Stream);很多经典坑,在新版本语言里其实已经有了"让你根本不会踩进去"的更好工具。这其实是一个学习的态度:不仅要学会"避开坑",还要跟上语言/工具的演进,用上那些"从设计上就让坑更难发生"的新特性——它们往往是社区在反复踩坑后,提炼出的更好的解法。知道经典坑的多种解法、并优先采用语言演进带来的更优雅安全的新写法(removeIf)——是这个 CME 坑教给我的实用经验。
第五件事:fail-fast 的设计哲学给我的启示
这次也让我理解了 fail-fast(快速失败)这个设计哲学,以及它为何是好的。
| 对比 | fail-fast(快速失败) | "默默继续"(容忍错误) |
|---|---|---|
| 遍历中改集合 | 立刻抛CME | 可能漏元素/重复/越界 |
| 问题暴露 | 立即、明确、在出错点 | 延迟、隐蔽、数据已错 |
| 排查难度 | 容易(异常栈直指现场) | 极难(现象离根因远) |
| 对开发者 | "逼"你立刻修正 | 让你以为没事 |
这张表道出了 fail-fast 的价值。核心是:fail-fast 选择在"检测到危险操作的那一刻"立刻、明确地报错(抛 CME),而不是"默默地继续、给出一个可能错误的结果";前者让问题立即、在现场暴露(容易排查),后者会让错误延迟、隐蔽地发作(数据已经悄悄错了,极难追查)。它给我的深刻启发是:"尽早、响亮地失败(fail fast & loud)",在很多时候比"容忍错误、勉强继续"更好;因为一个"立即在错误源头爆发的异常",虽然当下看着"讨厌",却把问题精确地、及时地摆在了你面前;而一个"被默默容忍的错误",会带着错误的状态继续运行,等到很久以后、很远的地方,以一种面目全非的形式爆发,让你付出大得多的排查代价。这其实是一个重要的设计与编码原则:在设计自己的代码/系统时,也应该多多采用"fail-fast"的思想——对那些"不该发生的状态、违反约定的输入、危险的操作",与其"容忍它、试图勉强处理",不如尽早地、明确地校验并报错(断言、参数校验、状态检查);让 bug 在离它产生最近的地方就暴露,而不是让它潜伏、扩散。欣赏并主动运用 fail-fast 的哲学、让错误尽早在源头响亮地暴露——是这个 CME 坑,在"怎么避开它"之上,教给我的"怎么写出易于排查的健壮代码"的更深一层智慧。
第六件事:遍历集合时,我现在的判断习惯
现在每当我遍历一个集合,我都会按这张图先想清楚:
这张图的精髓,是"遍历中会增删就绝不在 for-each 里直接改集合,用 removeIf/迭代器/副本"。只读用 for-each 安全;会删就绝不在 for-each 里直接 集合.remove,用 removeIf(最简洁)、Iterator.remove(更可控)或遍历副本/批量删;会加元素更要小心。并发遍历+修改用 CopyOnWriteArrayList/ConcurrentHashMap。这套习惯,让我遍历集合时,从"随手 for-each 边遍历边删"变成了"先想遍历中会不会改集合、该用什么安全方式改"——核心始终是:遍历中改集合会 fail-fast 抛 CME,用 removeIf/迭代器 remove,别在 for-each 里直接改集合。
我立下的几条规矩
这场"单线程也抛 ConcurrentModificationException"的事故,换来了我写 Java 时,刻进骨子里的几条铁律:
- for-each 底层是迭代器。它有 fail-fast,遍历中被外部改集合就抛 CME。
- CME 单线程也会抛。名字误导,它检测的是"遍历中集合被外部修改"。
- 绝不在 for-each 里直接 集合.remove/add。这是 CME 的经典触发。
- 删元素首选 removeIf。一行,简洁安全。
- 或用 Iterator.remove()。它同步 modCount,不触发 fail-fast。
- 并发遍历+改用并发集合。CopyOnWriteArrayList/ConcurrentHashMap。
- 欣赏 fail-fast,自己也多用它。让错误在源头尽早响亮地暴露。
附:一段亲眼看清 for-each 底层是迭代器的实验
口说无凭。下面这段代码,把"for-each 就是迭代器"以及各种删除方式的对错,并排演示清楚:
import java.util.*;
public class CMEDemo {
public static void main(String[] args) {
System.out.println("=== 1. for-each 里直接 remove: 抛 CME ===");
try {
List list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
for (Integer x : list) {
if (x % 2 == 0) list.remove(x); // ✗ 直接改集合
}
} catch (ConcurrentModificationException e) {
System.out.println(" 抛了 ConcurrentModificationException! (单线程也抛)");
}
System.out.println("\n=== 2. for-each 等价于显式迭代器(同样抛CME) ===");
try {
List list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
Iterator it = list.iterator(); // for-each 底层就是这个
while (it.hasNext()) {
Integer x = it.next(); // next() 会检查 modCount
if (x % 2 == 0) list.remove(x); // ✗ 还是直接改集合
}
} catch (ConcurrentModificationException e) {
System.out.println(" 同样抛CME! 证明for-each底层就是迭代器");
}
System.out.println("\n=== 3. 正确: Iterator.remove() ===");
List list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
Iterator it = list.iterator();
while (it.hasNext()) {
if (it.next() % 2 == 0) it.remove(); // ✓ 用迭代器自己的remove
}
System.out.println(" 删偶数后: " + list); // [1, 3, 5] ✓
System.out.println("\n=== 4. 正确: removeIf(最简洁) ===");
List list2 = new ArrayList<>(List.of(1, 2, 3, 4, 5));
list2.removeIf(x -> x % 2 == 0);
System.out.println(" removeIf后: " + list2); // [1, 3, 5] ✓
}
}
// 核心: 跑一遍, 第1和第2段都抛CME且现象一致——亲眼证明for-each底层就是迭代器、
// 直接改集合会触发fail-fast; 而 it.remove()/removeIf 安全。
这段实验代码,是我这次踩坑后写下的"for-each 揭盖器"。它最巧妙的设计,是第 1 段(for-each)和第 2 段(显式迭代器)的并排对比:它们都直接在循环里 list.remove(),而结果是抛出完全一样的 CME——这就亲手证明了"for-each 底层就是迭代器"这个关键事实:既然两种写法触发的是同一个异常、同样的机制,那它们底层就是一回事。然后第 3、4 段展示了 it.remove() 和 removeIf 的正确结果。这正是我想用这段代码,留给每个学 Java 的人的核心方法:要验证"某个语法糖底层到底是不是某个东西"(for-each 是不是迭代器),一个好办法是把"语法糖写法"和"你认为它等价的底层写法"并排放在一起,看它们在边界情况(如这里的边遍历边删)下表现是否一致——如果它们触发同样的行为、同样的异常,就有力地证明了它们底层是同一回事。因为"语法糖等价于什么"这种说法,光听别人说、光记住,印象总是浅的;而当你亲手构造一个能让它们'露出底层马脚'的场景(边界情况),并亲眼看到它们表现一致时,"哦,原来 for-each 真的就是迭代器"这个认识,就会变得无比扎实。用"在边界情况下对比语法糖与其底层等价写法"的实验,来确证和理解语法糖的本质——这份习惯,是我看穿一切"语法糖背后是什么"最有效的法门。
补充:"边遍历边修改"是一个跨场景的危险模式
这次踩坑后,我意识到"边遍历边修改"不只是 Java 集合的问题,它是一个普遍存在、跨语言跨场景的危险模式,值得单独记一笔。
类似的坑在很多地方都有:Python 里遍历字典/列表时增删元素会抛 RuntimeError 或行为异常;C# 里遍历集合时改它同样抛 InvalidOperationException;JavaScript 里遍历 DOM 节点列表(动态 NodeList)时增删节点会让索引错乱;数据库里一边游标遍历一边改表也可能有问题。它们的本质是同一个:"遍历"这个动作,通常隐含着一个假设——被遍历的集合在遍历期间是稳定的;而"在遍历过程中修改它的结构",破坏了这个假设,导致遍历器(迭代器/游标/索引)迷失:可能漏掉元素、重复访问、越界,或像 Java 这样直接报错。
这让我把"边遍历边修改"提炼成了一条跨语言的通用警觉:每当我要"在遍历一个集合的同时,修改这个集合本身"时,无论用什么语言,我都会立刻警觉,并采用那几种安全模式之一:用遍历器自带的安全修改方法(如 Java 的 Iterator.remove、removeIf)、遍历一个副本来改原集合、先收集要改的再批量改、或倒序按索引改。这其实体现了一个学习的高效之道:把一个个具体的坑(Java 的 CME、Python 的 RuntimeError),提炼成它们共有的、更抽象的"危险模式"(边遍历边改);这样,你记住的就不是 N 个孤立的语言细节,而是一条能贯穿所有语言、举一反三的通用原则——以后无论用哪门语言、遇到任何"遍历+修改"的场景,都会自动亮起警灯。把具体的坑提炼成跨语言的通用危险模式、形成一条能举一反三的警觉——是这个 CME 坑,带给我的超越 Java 本身的收获。
写在最后
回头看,这场由"遍历中删元素"引发的、ConcurrentModificationException 的事故,真正教给我的,远不止"用 removeIf"这一个技巧。它让我对"语法糖之下藏着什么",以及"边遍历边修改"这个普遍的危险模式,有了一次深刻的体会。我栽跟头,根源是我把增强 for 循环(for-each)当成了一个"简单纯粹的遍历语法",却不知道它底层其实是迭代器;于是我用"遍历就是挨个看一遍、看的时候顺手删一个有什么关系"的朴素直觉去用它,完全没意识到——底层那个迭代器,正小心翼翼地维护着它和集合之间的一份'遍历期间你别动我'的约定(通过 modCount);而我直接 list.remove() 的操作,正是背着迭代器、破坏了这份约定,触发了它的 fail-fast 警报。我看到的是"简洁的 for-each 语法",没看到的是"语法糖背后,迭代器和集合之间那份脆弱的契约"。这让我领悟到一个深刻的认知:语言提供的很多"语法糖/便利语法"(for-each、属性访问、运算符重载、自动装箱……),虽然让代码写起来简洁,但它们底层往往对应着更复杂的机制和约定;当你用它做"常规的事"时,糖很甜;可一旦你做的事触碰了它底层那些机制的边界或约定(如遍历中改集合),它就会以"看似简单的语法、却报出底层的复杂异常"的方式让你困惑。这其实再次印证了"抽象泄漏":语法糖是一层抽象,它在常规使用时隐藏了底层细节(很甜);但在边界情况下,底层的复杂性会"泄漏"出来(for-each 泄漏出了迭代器的 CME);所以,对你常用的语法糖,值得花点时间了解"它脱掉糖衣后,底层到底是什么、有什么约定"——这样当糖"不甜"时,你才能理解为什么、并知道怎么办。看穿语法糖之下的真实机制与约定、理解"边遍历边改"这类操作为何危险——这,是我用一次 CME 的事故,换来的、关于 Java、也关于如何真正理解语言特性的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想在 for-each 里删元素时,手一顿、改用 removeIf,那我对着那个"单线程却报并发异常"的矛盾抓狂的这大半天,就值了。
—— 别看了 · 2026