我在 for-each 遍历 List 的时候顺手删了几个元素,线上偶发抛 ConcurrentModificationException,明明是单线程、根本没有并发,我对着这个异常排查了大半天的复盘
那是我写的一段再普通不过的代码:遍历一个订单 List,把其中已失效的订单从 List 里删掉。我用了最顺手的 for-each 循环,边遍历边 list.remove(),本地跑测试数据一点问题没有,就上线了。结果线上偶发地抛出一个异常:java.util.ConcurrentModificationException。我第一反应是:"Concurrent?并发?可这就是个单线程的方法啊,哪来的并发?"我反复确认了没有任何多线程操作这个 List,百思不得其解。排查了大半天,我才明白这个异常的名字有多么容易误导人,以及它背后那个叫"fail-fast"的机制。这篇就把这场"没有并发的并发修改异常"的事故,从头复盘一遍。
故障现场:单线程,却报"并发修改"
先看现场。问题就藏在那段"边遍历边删"的代码里:
List<Order> orders = new ArrayList<>(getOrders());
// ✗ 边 for-each 遍历, 边 remove —— 偶发抛 ConcurrentModificationException
for (Order order : orders) {
if (order.isExpired()) {
orders.remove(order); // ← 在遍历过程中修改了集合结构!
}
}
// 抛出:
// java.util.ConcurrentModificationException
// at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:...)
// at java.util.ArrayList$Itr.next(ArrayList.java:...)
// 为什么"偶发"? 为什么单线程也报?
// - for-each 本质是用迭代器(Iterator)遍历: 等价于
// Iterator it = orders.iterator();
// while (it.hasNext()) { Order order = it.next(); ... }
// - ArrayList 内部有个 modCount(修改次数计数器), 每次 add/remove 都 +1。
// - 迭代器创建时记下一个 expectedModCount = 当时的 modCount。
// - 每次 it.next(), 都会检查 modCount == expectedModCount 吗?
// 不等就抛 ConcurrentModificationException(fail-fast 快速失败)。
// - 我直接 orders.remove() 改了集合 → modCount 变了, 但迭代器的
// expectedModCount 没变 → 下次 next() 检查不一致 → 抛异常。
// 为什么"偶发"(有时不报)?
// - 特殊情况: 如果删的是"倒数第二个"元素, 删完后迭代器恰好 hasNext()
// 返回 false, 循环正常结束, 不会再调 next() 检查 → 不报!
// - 所以"删最后第二个"碰巧不报, 删别的位置就报 → 表现为"偶发"。
// ★ 关键: ConcurrentModificationException 里的 "Concurrent",
// 不一定指"多线程并发"! 单线程"边遍历边改集合结构"同样会触发。
// 它真正的含义是"在迭代期间, 集合被(意外地)修改了"。
看清真相后,我才明白这个异常的名字有多坑人。ConcurrentModificationException 里的 "Concurrent",根本不一定指"多线程并发"!它真正的含义是"在迭代期间,集合被(意外地)修改了结构"——单线程"边遍历边改"同样会触发。原理是:for-each 本质是用迭代器遍历;ArrayList 内部有个 modCount 计数器,每次增删都 +1;迭代器创建时记下 expectedModCount,每次 next() 都检查两者是否相等,不等就抛异常(这叫 fail-fast 快速失败)。我直接 orders.remove() 改了集合,modCount 变了、迭代器的 expectedModCount 没变,下次 next() 检查不一致就抛。而"偶发"的原因很微妙:如果恰好删的是倒数第二个元素,删完后迭代器 hasNext() 返回 false、循环正常结束、不再调 next() 检查,就不报——所以删不同位置表现就不同。
第一件事:搞懂 fail-fast 与 modCount 机制
要解决它,得先搞懂这个 ConcurrentModificationException 背后的 fail-fast(快速失败)机制。
fail-fast 机制与 modCount
# 什么是 fail-fast(快速失败)?
# - Java 集合(ArrayList/HashMap 等)的迭代器, 在发现"迭代期间集合
# 被意外修改"时, 会【立刻抛 ConcurrentModificationException】,
# 而不是继续跑出"不可预测的错误结果"。
# - 这是一种"保护机制": 与其让你拿到错乱的数据(漏删/重复/越界),
# 不如立刻报错, 让你尽早发现问题。fail-fast = "尽早暴露错误"。
# 它是怎么实现的? —— modCount
# - 集合内部维护 modCount: 结构性修改(add/remove/clear)的次数。
# - 创建迭代器时: expectedModCount = modCount(拍个快照)。
# - 每次 it.next() / it.remove(): 检查 modCount == expectedModCount?
# 相等 → 正常; 不等 → 抛 ConcurrentModificationException。
# - 含义: "我开始迭代后, 集合的结构被人动过了(且不是通过我迭代器动的)"。
# 为什么"通过迭代器自己的 remove" 就没事?
# - it.remove() 在删除后, 会【同步更新】expectedModCount = modCount。
# - 所以它删完, 两者依然相等, 不会触发 fail-fast。
# - 而 list.remove() 只改 modCount, 不管迭代器的 expectedModCount。
# 注意: fail-fast 不保证一定能检测到!(尤其多线程)
# - modCount 的检查没加锁, 多线程下可能漏检。
# - 所以它是"尽力而为的 bug 提示", 不是"并发安全的保证"。
# - 真正的并发安全要用 并发集合(如 CopyOnWriteArrayList)或加锁。
# 核心: fail-fast 是集合迭代器的保护机制, 靠 modCount/expectedModCount 检测
# "迭代期间集合被结构性修改"并立刻抛异常; 迭代器自己的remove会同步计数故安全。
原来,这个异常是一个善意的"保护机制"。fail-fast(快速失败):集合的迭代器在发现"迭代期间集合被意外修改"时,会立刻抛异常,而不是继续跑出不可预测的错误结果(漏删、重复、越界)——与其让你拿到错乱的数据,不如立刻报错让你尽早发现。它的实现靠 modCount:集合内部维护"结构性修改次数"modCount;创建迭代器时拍快照 expectedModCount = modCount;每次 next() 检查两者是否相等,不等就抛。而为什么"通过迭代器自己的 remove() 就没事"?因为it.remove() 删除后会同步更新 expectedModCount = modCount,两者依然相等;而 list.remove() 只改 modCount、不管迭代器。还有一个重要提醒:fail-fast 不保证一定能检测到(modCount 检查没加锁,多线程可能漏检)——它是"尽力而为的 bug 提示",不是"并发安全的保证";真正的并发安全要用并发集合或加锁。
第二件事:正解——用对"边遍历边删"的几种姿势
搞懂了原理,正解就清晰了:用迭代器自己的 remove、或 removeIf、或倒序索引遍历,别在 for-each 里直接改集合。
// ====== 正解一(推荐, Java 8+): removeIf, 最简洁 ======
orders.removeIf(order -> order.isExpired());
// → 一行搞定, 内部用迭代器安全删除, 可读性最好。首选!
// ====== 正解二: 显式用迭代器的 remove() ======
Iterator it = orders.iterator();
while (it.hasNext()) {
Order order = it.next();
if (order.isExpired()) {
it.remove(); // ✓ 用迭代器自己的 remove, 它会同步 expectedModCount
}
}
// → 这是 removeIf 出现前的标准写法, 安全。注意是 it.remove() 不是 list.remove()!
// ====== 正解三: 倒序的索引 for 循环(需要按索引操作时)======
for (int i = orders.size() - 1; i >= 0; i--) {
if (orders.get(i).isExpired()) {
orders.remove(i); // ✓ 倒序删, 删除不影响未遍历元素的索引
}
}
// → 为什么倒序? 正序删 i 后, 后面元素前移, i+1 变成了原来的 i+2, 会漏删!
// 倒序删, 删的是后面的, 不影响前面待遍历的索引。
// ====== 正解四: 收集要删的, 遍历后统一删(或收集要留的, 重建)======
List toRemove = new ArrayList<>();
for (Order order : orders) { // 只读遍历, 不改
if (order.isExpired()) toRemove.add(order);
}
orders.removeAll(toRemove); // 遍历结束后再统一删
// 或者反过来: 用 stream 过滤出"要保留的", 生成新 list:
List kept = orders.stream()
.filter(o -> !o.isExpired())
.collect(Collectors.toList());
// ====== ✗ 这些写法仍然会踩坑 ======
// for (Order o : orders) { orders.remove(o); } // ✗ for-each 里直接删
// for (Order o : orders) { orders.add(...); } // ✗ 遍历里 add 也会抛!
// orders.forEach(o -> { if(...) orders.remove(o); });// ✗ forEach 里改也抛
// 核心: 边遍历边删用 removeIf(首选)/迭代器it.remove()/倒序索引删;
// 或先收集再统一删/stream过滤重建; 绝不在for-each里直接 list.add/remove。
修复的核心,是"用对'在遍历中修改集合'的姿势,而不是在 for-each 里直接改"。正解一(推荐,Java 8+):removeIf——orders.removeIf(o -> o.isExpired()),一行搞定、内部用迭代器安全删除、可读性最好,首选。正解二:显式用迭代器的 remove()——it.remove()(注意不是 list.remove()!)会同步 expectedModCount,安全,这是 removeIf 出现前的标准写法。正解三:倒序的索引 for 循环——为什么倒序?正序删 i 后,后面元素前移、会漏删;倒序删的是后面的,不影响前面待遍历的索引。正解四:收集要删的、遍历后统一删(removeAll),或用 stream 过滤出"要保留的"重建新 list。而这些写法仍会踩坑:for-each 里直接 remove/add、forEach lambda 里改集合。归根结底:边遍历边删用 removeIf(首选)/迭代器 it.remove()/倒序索引删;或先收集再统一删/stream 重建;绝不在 for-each 里直接 list.add/remove。
第三件事:fail-fast 与 fail-safe 的区别
排查时我还搞清了一对相对的概念:fail-fast 和 fail-safe。它们代表了两种截然不同的"遍历时被修改"的应对哲学。
fail-fast vs fail-safe
# ==== fail-fast(快速失败)====
# 代表: ArrayList, HashMap, HashSet 等普通集合的迭代器。
# 行为: 迭代时检测到集合被结构性修改 → 立刻抛 ConcurrentModificationException。
# 哲学: "宁可报错, 不出错" —— 尽早暴露"迭代中被改"的潜在bug。
# 代价: 不能在迭代中(用集合本身)修改; 多线程下也可能漏检(非线程安全)。
# ==== fail-safe(安全失败)====
# 代表: CopyOnWriteArrayList, ConcurrentHashMap 等并发集合的迭代器。
# 行为: 迭代时即使集合被修改, 也【不抛异常】, 照常遍历。
# 原理(以 CopyOnWriteArrayList 为例):
# - 迭代时基于"创建迭代器那一刻的数据快照"遍历。
# - 修改操作会"复制一份新数组"再改(写时复制 Copy-On-Write),
# 不影响正在遍历的旧快照。
# - 所以迭代器看的是旧快照, 修改改的是新数组, 互不干扰, 不抛异常。
# 代价: 迭代看到的可能不是"最新"数据(是快照, 有延迟);
# 写时复制有内存/性能开销(适合"读多写少")。
# 怎么选?
# - 单线程边遍历边删: 用 removeIf / 迭代器remove(还是 fail-fast 集合)。
# - 多线程并发读写: 用 fail-safe 的并发集合(CopyOnWriteArrayList /
# ConcurrentHashMap), 它们本就是为并发设计的。
# 核心: fail-fast(普通集合)迭代中被改就抛异常, 尽早暴露bug;
# fail-safe(并发集合)基于快照/写时复制遍历不抛异常但有延迟, 适合并发读多写少。
这对概念,代表了两种应对"遍历时被修改"的哲学。fail-fast(快速失败):普通集合(ArrayList/HashMap)的迭代器,检测到迭代中被修改就立刻抛异常,哲学是"宁可报错,不出错"——尽早暴露潜在 bug;代价是不能在迭代中改、多线程下也可能漏检。fail-safe(安全失败):并发集合(CopyOnWriteArrayList/ConcurrentHashMap)的迭代器,迭代中即使被修改也不抛异常、照常遍历;以 CopyOnWriteArrayList 为例,迭代基于"创建迭代器那一刻的快照",修改时复制新数组再改(写时复制),互不干扰;代价是迭代看到的可能不是最新数据(快照有延迟)、写时复制有开销(适合读多写少)。怎么选?单线程边遍历边删用 removeIf/迭代器 remove;多线程并发读写用 fail-safe 的并发集合。下面这张图,是这次 ConcurrentModificationException 的成因与解法:
第四件事:几种"遍历中删除"方式对比速查
这次踩坑后,我把"在遍历中删除集合元素"的几种方式整理成一张表,按场景对照着选。
| 方式 | 安全吗 | 适用 | 备注 |
|---|---|---|---|
| for-each 里 list.remove() | ✗ 抛CME | — | 最常见的错误写法 |
| removeIf(谓词) | ✓ 安全 | 按条件删,Java 8+ | 最简洁,首选 |
| Iterator + it.remove() | ✓ 安全 | 复杂删除逻辑 | 经典写法 |
| 倒序索引 for 删 | ✓ 安全 | 需按索引操作 | 正序会漏删 |
| 正序索引 for 删 | △ 易漏删 | — | 删后要 i-- 修正 |
| 收集后 removeAll | ✓ 安全 | 删除条件复杂 | 两次遍历 |
| stream filter 重建 | ✓ 安全 | 函数式风格 | 生成新集合不改原 |
这张表,把"遍历中删除"的各种姿势优劣都摆清了。结论很明确:按条件删首选 removeIf(一行、安全、可读);删除逻辑复杂用迭代器 it.remove();需要索引就倒序删;函数式风格用 stream filter 重建;而 for-each 里直接 remove 是绝对要避免的错误写法。它给我的启发是:一个常见的需求("边遍历边删"),语言/库往往已经提供了"专门的、安全的"做法(如 removeIf);而 bug,常常源于我们"用最朴素直觉的写法"(for-each 里直接删)去解决一个"其实有专门方案"的问题。这提醒我:遇到一个看似简单的操作,值得花一分钟想想"标准库有没有为这个场景提供专门的方法"——那些专门的方法,往往已经替你处理好了你没意识到的边界情况和陷阱(removeIf 内部就正确处理了迭代器删除)。善用标准库的"专用方法",是绕开大量底层陷阱最省力的方式。
第五件事:其他常见的 ConcurrentModificationException 场景
这次是 List,但这个异常的场景远不止于此。我把常见的几个一并梳理了。
| 场景 | 触发原因 | 修法 |
|---|---|---|
| for-each List 删/加 | 遍历中改结构(本文) | removeIf/迭代器remove |
| for-each Map 删 key | 遍历中改 Map 结构 | iterator.remove / entrySet迭代器 |
| Map.forEach 里 put 新key | 遍历中结构性修改 | 收集后批量 put |
| 多线程一个读一个写 | 线程A遍历时线程B改 | 并发集合/加锁 |
| subList 后改原 list | 原list改了, subList迭代失效 | 避免改原list |
| stream 中途改源集合 | 流操作时源被改 | 别在流里改源 |
这张表,把 ConcurrentModificationException 可能出现的"案发现场"都列了出来。它们的共同根源,其实只有一条:在"遍历一个集合"的过程中,这个集合的结构(大小)被改变了——无论是单线程自己边遍历边改,还是多线程一个遍历一个修改。无论是 List、Map、stream、subList,本质都是同一件事。它给我的最大启发是:"遍历"和"修改结构"这两件事,天生是有冲突的——因为遍历依赖于"集合在遍历期间是稳定的"这个假设,而修改结构恰恰破坏了这个假设。这其实是一个普遍的计算机科学问题:当你正在"枚举"一个数据结构时,同时改变它的"形状",几乎在所有语言、所有数据结构里都是危险的(Python 遍历 dict 时改 dict、C# 遍历时改 collection,都有类似的问题)。所以我现在养成了一个跨语言通用的警觉:每当我要"在遍历一个集合的同时修改它",我都会停下来,用语言提供的"安全方式"(专门的删除方法、收集后批量改、或基于副本操作),而绝不天真地"边遍历边随手改"。这个看似具体的 Java 异常,背后其实是一条放之四海皆准的编程铁律。
第六件事:要"遍历中修改集合"时,我现在的判断习惯
现在每当我要"边遍历边改集合",我都会先过一遍这张图,选对安全的姿势:
这张图的精髓,是"想改正在遍历的集合时,先按线程和操作类型,选对安全姿势"。第一问 "单线程还是多线程":多线程并发读写直接用并发集合;单线程再看改什么。单线程下:按条件删用 removeIf(首选)、复杂删除用迭代器 it.remove()、要按索引用倒序 for、遍历中要加元素就收集后批量加。而那条红线始终高悬:绝不在 for-each 里直接 list.add/remove。最后用含删除分支的测试兜底(因为这个 bug 常"偶发",普通测试数据可能恰好不触发)。这套习惯,让我处理"遍历中修改"时,从"顺手就改然后偶发崩溃"变成了"先选对安全姿势"——核心始终是:遍历和改结构天生冲突,要改正在遍历的集合,必须用专门的安全方式。
我立下的几条规矩
这场"没有并发的并发修改异常"的事故,换来了我写 Java 时,刻进骨子里的几条铁律:
- ConcurrentModificationException 不一定是多线程。单线程"边遍历边改集合结构"同样会触发。
- 别在 for-each 里直接 list.add/remove。它底层是迭代器,改结构就触发 fail-fast。
- 按条件删首选 removeIf。一行、安全、可读;复杂逻辑用迭代器 it.remove()。
- 要按索引删就倒序遍历。正序删会因元素前移而漏删。
- 多线程并发读写用并发集合。CopyOnWriteArrayList / ConcurrentHashMap,fail-safe。
- "遍历"和"改结构"天生冲突。这是跨语言通用的原则,不止 Java。
- 测要覆盖删除分支。这类 bug 常偶发,普通数据可能恰好不触发。
附:一段能亲眼对比"踩坑 vs 各种修复"的代码
口说无凭。下面把错误写法和几种正确写法放在一起,让你亲眼看见哪个抛异常、哪个正确:
import java.util.*;
import java.util.stream.*;
public class CMEDemo {
public static void main(String[] args) {
// ====== ✗ 错误写法: for-each 里直接 remove ======
List list1 = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
try {
for (Integer n : list1) {
if (n % 2 == 0) list1.remove(n); // 删偶数 → 抛CME
}
} catch (ConcurrentModificationException e) {
System.out.println("错误写法: 抛出 ConcurrentModificationException!");
}
// ====== ✓ 正解一: removeIf(首选) ======
List list2 = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
list2.removeIf(n -> n % 2 == 0);
System.out.println("removeIf 结果: " + list2); // [1, 3, 5]
// ====== ✓ 正解二: 迭代器 it.remove() ======
List list3 = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
Iterator it = list3.iterator();
while (it.hasNext()) {
if (it.next() % 2 == 0) it.remove(); // 用迭代器自己的remove
}
System.out.println("迭代器remove结果: " + list3); // [1, 3, 5]
// ====== ✓ 正解三: 倒序索引删 ======
List list4 = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
for (int i = list4.size() - 1; i >= 0; i--) {
if (list4.get(i) % 2 == 0) list4.remove(i);
}
System.out.println("倒序索引删结果: " + list4); // [1, 3, 5]
// ====== ✗ 反面教材: 正序索引删会漏删! ======
List list5 = new ArrayList<>(Arrays.asList(2, 4, 6, 8));
for (int i = 0; i < list5.size(); i++) { // 不加 i--
if (list5.get(i) % 2 == 0) list5.remove(i); // 删后元素前移
}
System.out.println("正序删(漏删): " + list5); // [4, 8] ← 漏删了!
// ====== ✓ 正解四: stream 过滤重建 ======
List list6 = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
List kept = list6.stream()
.filter(n -> n % 2 != 0)
.collect(Collectors.toList());
System.out.println("stream重建结果: " + kept); // [1, 3, 5]
}
}
/* 输出:
错误写法: 抛出 ConcurrentModificationException!
removeIf 结果: [1, 3, 5]
迭代器remove结果: [1, 3, 5]
倒序索引删结果: [1, 3, 5]
正序删(漏删): [4, 8] ← 正序索引删的反面教材, 漏删!
stream重建结果: [1, 3, 5]
*/
// 核心: 错误写法抛CME; removeIf/迭代器remove/倒序索引/stream重建都正确;
// 而正序索引删不加i--会漏删。亲手跑一遍, 各种姿势的对错一目了然。
这段可运行的对比代码,把"踩坑"和"各种修复"放在一起,让每种写法的对错一目了然。它不仅展示了 错误写法抛 ConcurrentModificationException、removeIf/迭代器/倒序/stream 四种正解都得到正确的 [1,3,5],还特意加了一个容易被忽略的反面教材:正序索引删如果不写 i--,会因为删除后元素前移而漏删(把 [2,4,6,8] 删成了 [4,8])。这个"漏删"的坑尤其阴险——它不抛异常、不崩溃,只是悄悄地少删了几个,比直接抛异常的 CME 更难发现。这,正是我想用这段代码,留给每个 Java 开发者的最后一课:处理"集合遍历修改"这类问题,不能只满足于"不抛异常",还要警惕那些"不报错但结果错"的隐性陷阱(如漏删)。而把所有写法并排跑一遍、对比输出,正是识别这两类问题(显性的异常、隐性的错误结果)最直接的办法。"能跑通不报错"只是及格线,"结果完全正确"才是真正的目标;而要确认后者,唯有靠覆盖各种情况的实际验证——尤其是那些会改变集合大小、容易引发边界问题的删除操作。代码不报错,不代表它做对了;让它把每种情况都跑给你看,才是真正的踏实。
写在最后
回头看,这场由 ConcurrentModificationException 引发的、"单线程却报并发"的事故,真正教给我的,远不止"边遍历边删要用 removeIf"这一个技巧。它给我上了关于"读异常、读错误信息"的深刻一课。我一开始排查的方向完全错了,就是因为我"望文生义"地理解了异常的名字——看到 "Concurrent",我的全部注意力都扑到了"找多线程问题"上,在一个根本没有并发的单线程方法里,徒劳地查了半天"并发"。而真相是,这个异常的名字,起得并不精确(甚至有点误导):它真正的含义是"迭代期间集合被意外修改",和"多线程"并没有必然联系。这让我领悟到一个排查问题时极其重要的道理:异常的名字、错误的提示,是"线索",但不是"定论";它们由人编写,可能不精确、可能有误导、可能只反映了问题的一个侧面;如果我们不求甚解地"按字面意思"去理解它,就很容易被带偏方向、南辕北辙。真正可靠的排查,是透过那个(可能不准的)名字,去理解"这个错误到底在什么机制下、因为什么被抛出来"——就像这次,只有当我真正搞懂了 modCount 和 fail-fast 机制,才明白"哦,原来单线程改集合也会触发它"。不被表象的名字迷惑,深入到机制层面去理解错误的真正成因——这,是从"面对报错手足无措"走向"精准定位问题"的关键一步。这,是我用一次"单线程报并发"的事故,换来的、关于 Java、也关于"如何正确解读错误信息"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次看到一个异常名时,先别急着望文生义、而是去搞懂它背后的机制,那我对着这个"名不副实"的异常熬的这大半天,就值了。
—— 别看了 · 2026