我在 for-each 遍历一个 List 的过程中顺手删了几个元素,本地跑得好好的、线上却偶发抛 ConcurrentModificationException 崩溃:一次 Java 遍历时修改集合、迭代器 fail-fast 机制的深度复盘

我写了个清理逻辑遍历订单列表、把过期的删掉,for-each 里遇到过期就 orders.remove(order)。本地几条数据跑得好好的,上线后日志偶尔蹦出 java.util.ConcurrentModificationException、清理任务直接崩了。可这是单线程循环,哪来的并发?查清才发现:这异常名有误导,单线程一边遍历一边改集合也抛;for-each 底层是迭代器,迭代器靠 modCount==expectedModCount 做 fail-fast 自检,我用集合自己的 remove 改结构让 modCount 变了但迭代器的 expectedModCount 没变,下次 next() 检查对不上就抛 CME;而且删倒数第二个时碰巧不抛,所以本地测试漏过了。这篇复盘从故障现场讲到迭代器 fail-fast 与 modCount 机制,再到用 Iterator.remove()、removeIf()、遍历副本、倒序索引的完整正解,以及别无协调地同时读写一个结构、fail-fast 是把问题暴露在最早处的智慧、把报错当善意提醒去理解而非绕过的认知。

我在 for-each 遍历一个 List 的过程中顺手删了几个元素,本地跑得好好的、线上却偶发抛 ConcurrentModificationException 崩溃:一次 Java 遍历时修改集合的深度复盘

那个异常是线上偶发崩溃、翻日志才抓到的:我写了个清理逻辑,要遍历一批订单、把其中已过期的删掉。代码写得很自然——for-each 遍历订单列表,遇到过期的就 orders.remove(order)。本地用几条测试数据跑,好好的,没任何问题;可一上线,日志里就偶尔蹦出一个刺眼的异常:"java.util.ConcurrentModificationException"——清理任务直接崩了。我盯着这个异常名愣住了:ConcurrentModification?并发修改?可我这就是个单线程的循环啊,哪来的"并发"?我查了好一会儿,才看明白,后背发凉:这个异常的名字有误导性——它叫"ConcurrentModification",但不是只在多线程并发时才抛,单线程里"一边遍历、一边结构性地修改集合"同样会抛。原因是:for-each 遍历集合,底层用的是迭代器(Iterator);迭代器内部记着一个 "预期修改次数"(expectedModCount),而集合本身记着一个 "实际修改次数"(modCount);当我用 orders.remove() 直接改集合时,集合的 modCount 变了,但迭代器手里那个 expectedModCount 没跟着变;迭代器下一次取元素(next)时,发现这俩对不上了,就判定"集合在我遍历期间被人偷偷改了结构",于是抛 ConcurrentModificationException(这是一种"快速失败 fail-fast"的自我保护)问题的根,是我在 for-each(用迭代器)遍历的同时,绕过迭代器、直接调用集合的 remove 改了它的结构,触发了迭代器的 fail-fast 检查。这篇就把这次"遍历时修改集合"的坑,从头到尾复盘一遍。

故障现场:一边 for-each 一边 remove

问题在于 for-each(底层是迭代器)遍历的同时,直接用集合的 remove 改了结构:

// ✗ 出问题的代码: for-each 遍历的同时, 直接 remove
public void cleanExpired(List<Order> orders) {
    for (Order order : orders) {        // for-each底层用的是 orders.iterator()
        if (order.isExpired()) {
            orders.remove(order);        // ✗ 直接改集合结构! 集合modCount变了,
            //                              但迭代器的expectedModCount没变 → 下次next()抛CME
        }
    }
}

// 现象: 抛 java.util.ConcurrentModificationException
// - 单线程! 不是多线程并发, 名字有误导;
// - 而且偶发: 删的位置/数量不同, 不一定每次都抛(下面解释为何"偶发")。

// 为什么? for-each 的本质 + 迭代器的 fail-fast 机制:
// 1. for (Order o : orders) 会被编译成用 Iterator 遍历:
//    Iterator it = orders.iterator();  while (it.hasNext()) { Order o = it.next(); ... }
// 2. 创建迭代器时, 它记下集合当前的修改次数: expectedModCount = modCount;
// 3. orders.remove(order) 直接改集合, 使集合的 modCount++ (结构性修改计数+1);
//    但这个remove没有更新迭代器的 expectedModCount;
// 4. 循环下一轮 it.next() 时, 迭代器检查 if (modCount != expectedModCount) throw CME;
//    → 发现对不上 → 抛 ConcurrentModificationException。

// 为什么"偶发/有时不抛"? —— 经典陷阱:
// - 若删的恰好是【倒数第二个】元素, 删后size-1, 迭代器的cursor正好等于size, hasNext()返回false,
//   循环直接结束, 没机会执行next()做检查 → 不抛异常(但也漏了最后一个元素的判断)!
// - 所以这坑可能"测试时碰巧不抛", 上线数据一变就抛 → 更隐蔽。

// 关键: for-each底层是迭代器, 迭代器用modCount/expectedModCount做fail-fast检查;
//       遍历期间直接用集合的remove/add改结构, 会让两者不一致, 触发ConcurrentModificationException。

第一次定位到"是 for-each 的迭代器和我的 remove 打架"时,我又懊恼又恍然:"我一直以为 ConcurrentModificationException 是多线程才有的事,完全没想到单线程一边遍历一边删也会抛;更没想到它还偶发——本地测试居然碰巧没抛。"这个坑最坑的地方在于:偶发——删除倒数第二个元素时碰巧不抛(迭代器 hasNext 提前返回 false),让本地小数据测试很可能漏过,上线数据一变就崩;而且它的名字 "Concurrent" 极具误导,让人往多线程上想、查错方向下面就来拆解,这个机制到底怎么回事、该怎么正确地"边遍历边删"。

第一件事:搞懂迭代器的 fail-fast 与 modCount 机制

我顺着这次事故,把 Java 集合的迭代器 fail-fast 机制彻底理清了。

为什么遍历时改集合会抛 ConcurrentModificationException?

【核心: for-each底层是迭代器; 迭代器靠modCount==expectedModCount做fail-fast自检; 绕过迭代器直接改集合结构会让两者不一致, 抛CME】

1. for-each 的真相: 它是迭代器的语法糖
   - for (T x : coll) {...} 编译后 ≈ 用 coll.iterator() 遍历;
   - 所以"用for-each遍历"="正在用一个迭代器遍历这个集合"。

2. modCount: 集合的"结构性修改次数"
   - ArrayList等内部有个字段 modCount, 记录"结构被改了多少次"(add/remove等);
   - 注意: 是"结构性修改"(增删元素), set(i,x)改值一般不算。

3. 迭代器的 fail-fast 自检:
   - 创建迭代器时, 它拷贝一份当时的modCount, 存为 expectedModCount;
   - 每次 it.next() / it.remove() 前, 检查 modCount == expectedModCount 吗?
   - 不相等 → 说明"遍历期间, 集合结构被【迭代器以外的途径】改了" → 立刻抛CME;
   - 这叫 fail-fast(快速失败): 一旦发现状态被意外修改, 立刻报错, 而非continue产生不可预知的结果。

4. 本文为何抛: 绕过迭代器改了结构
   - orders.remove(order) 是【集合自己的remove】, 它让modCount++, 但不通知迭代器;
   - → 迭代器的expectedModCount没变, 下次next()检查时 modCount != expectedModCount → CME。

5. 为什么 Iterator.remove() 就不抛?
   - 迭代器自己的 it.remove(): 它删完元素后, 会【同步更新】expectedModCount = modCount;
   - → 两者始终一致, 不会触发fail-fast → 所以"用迭代器自己的remove"是安全的正解。

6. fail-fast 不是"保证", 是"尽力检测":
   - 它是为了帮你尽早发现"遍历时被意外修改"的bug, 不保证一定检测到(如前述删倒数第二个就没抛);
   - 多线程并发修改更是如此 → 别依赖CME来做并发控制, 它只是个"尽力的警报"。

一句话: for-each底层是迭代器, 迭代器靠 modCount==expectedModCount 做fail-fast自检;
   遍历时直接用集合的remove/add改结构会让两者不一致而抛CME; 用迭代器自己的remove()(会同步计数)才安全。

这套认知,是整个坑的根。for-each 的真相:它是迭代器的语法糖——for (T x : coll) 编译后就是用 coll.iterator() 遍历modCount:集合的"结构性修改次数"(add/remove 时 ++)。迭代器的 fail-fast 自检:创建时拷贝一份 modCount 存为 expectedModCount,每次 next() 前检查两者是否相等,不等就立刻抛 CME(说明遍历期间集合被迭代器以外的途径改了结构)。本文为何抛:orders.remove() 是集合自己的 remove,让 modCount++ 但不通知迭代器,下次 next() 检查就对不上。为什么 Iterator.remove() 不抛:它删完会同步更新 expectedModCount = modCount,两者始终一致。fail-fast 不是保证、是尽力检测(删倒数第二个就没抛;别依赖 CME 做并发控制)。一句话:for-each 底层是迭代器,迭代器靠 modCount==expectedModCount 做 fail-fast 自检;遍历时直接用集合的 remove/add 改结构会让两者不一致而抛 CME;用迭代器自己的 remove()(会同步计数)才安全。

第二件事:正解——用 Iterator.remove()、removeIf()、或遍历副本

搞懂了原理,正解就清晰了:要在遍历时删元素,就用迭代器自己的 remove()(它会同步计数);更简洁的是用 Java 8 的 removeIf();或者遍历一个副本、改原集合;或用倒序索引的普通 for 循环

// ====== 正解一: 用 Iterator 自己的 remove()(经典正解) ======
public void cleanExpired(List<Order> orders) {
    Iterator<Order> it = orders.iterator();
    while (it.hasNext()) {
        Order order = it.next();
        if (order.isExpired()) {
            it.remove();          // ★ 用迭代器自己的remove! 它会同步更新expectedModCount → 不抛CME
        }
    }
}
// → it.remove() 删的是"刚由next()返回的那个元素", 且内部同步计数, 安全。

// ====== 正解二: Java 8+ 用 removeIf(最简洁, 推荐) ======
public void cleanExpired2(List<Order> orders) {
    orders.removeIf(Order::isExpired);   // ★ 一行搞定, 内部正确处理了遍历删除
}
// → removeIf是Collection的默认方法, 内部用迭代器安全地删除满足条件的元素, 简洁又不出错。

// ====== 正解三: 遍历"副本", 修改"原集合" ======
public void cleanExpired3(List<Order> orders) {
    for (Order order : new ArrayList<>(orders)) {   // ★ 遍历原集合的拷贝
        if (order.isExpired()) {
            orders.remove(order);                    // 改原集合(此刻没在遍历它) → 不冲突
        }
    }
}
// → 遍历的是副本的迭代器, 改的是原集合, 两者不是同一个 → 不触发fail-fast。代价: 多一次拷贝。
// ====== 正解四: 用倒序索引的普通for循环(避免漏删/越界) ======
public void cleanExpired4(List<Order> orders) {
    for (int i = orders.size() - 1; i >= 0; i--) {   // ★ 倒序遍历
        if (orders.get(i).isExpired()) {
            orders.remove(i);    // 普通索引remove, 没用迭代器 → 不涉及CME
        }
    }
}
// → 为什么倒序? 正序删除时, 删了i后, 后面元素前移, i+1变成了i, 会"跳过"一个 → 漏删;
//   倒序删除则不影响"前面还没遍历到的"索引 → 不漏删、不越界。
// (注意: ArrayList按索引remove是O(n), 大量删除性能不如removeIf。)

// ====== 经验法则 ======
// 1. 遍历中删除, 首选 removeIf(条件) —— 最简洁、最不易错;
// 2. 需要更复杂的遍历逻辑, 用 Iterator + it.remove();
// 3. 别在 for-each 里直接 collection.remove()/add() —— 会抛CME(或偶发漏删);
// 4. 并发场景: 用CopyOnWriteArrayList或加锁, 别指望CME帮你做并发控制。

// 核心: 遍历集合时要增删元素, 用 迭代器自己的remove() 或 removeIf();
//   别在for-each(迭代器)遍历时直接调集合的remove/add改结构; 这样才不触发fail-fast的CME。

修复的核心,是"别绕过迭代器改结构,用迭代器自己的删除或 removeIf"正解一:用 Iterator 自己的 remove()——它删的是刚由 next() 返回的元素、内部同步计数,安全正解二:Java 8+ 用 removeIf(最简洁,推荐)——orders.removeIf(Order::isExpired) 一行搞定,内部正确处理了遍历删除正解三:遍历副本、改原集合(遍历的迭代器和被改的集合不是同一个,不触发 fail-fast;代价多一次拷贝)。正解四:倒序索引的普通 for 循环(倒序删除不影响前面未遍历到的索引,不漏删不越界)。经验法则:首选 removeIf;复杂逻辑用 Iterator + it.remove();别在 for-each 里直接 collection.remove()归根结底:遍历集合时要增删元素,用迭代器自己的 remove() 或 removeIf();别在 for-each(迭代器)遍历时直接调集合的 remove/add 改结构;这样才不触发 fail-fast 的 CME。

第三件事:Java 集合使用中其他常见的坑

排查后我把 Java 集合使用中其他容易踩的坑也系统梳理了一遍。

Java 集合使用的其他常见坑

# 1. 遍历时直接remove(本文): 抛CME或偶发漏删。→ Iterator.remove()/removeIf()。

# 2. Arrays.asList()返回固定大小List: 对它add/remove抛UnsupportedOperationException。→ new ArrayList<>(asList(...))。

# 3. List.subList()是视图: 改子列表会影响原列表, 且原列表结构改了子列表用就CME。→ 需要独立就拷贝。

# 4. 重写equals没重写hashCode: 放进HashMap/HashSet后找不到/重复。→ equals和hashCode一起重写。

# 5. HashMap多线程put: 可能死循环(老版本)/数据错乱。→ 用ConcurrentHashMap。

# 6. 用基本类型包装类做key/自动拆箱NPE: Integer为null时拆箱NPE。→ 留意null。

# 7. ConcurrentModificationException误以为是多线程: 单线程遍历改也抛。→ 理解fail-fast(本文)。

# 8. removeIf在不可变/固定List上: 同样抛UnsupportedOperationException。→ 确认List可变。

# 共同根源: Java集合框架功能丰富, 但很多行为(视图/固定大小/fail-fast/hashCode契约)藏在"约定和实现细节"里;
#   不了解这些"隐含契约", 按直觉用就会踩坑——尤其是"看着能用、实则有约束"的地方。

# 核心: 用Java集合, 要了解每种集合/操作的"隐含契约"(可变性/是否视图/fail-fast/hashCode要求);
#   遍历删用removeIf/Iterator.remove、equals配hashCode、并发用并发容器; 别只按直觉用, 读清它的约定。

排查让我把 Java 集合的其他坑也梳理清了。一、遍历时直接 remove(本文)。二、Arrays.asList() 返回固定大小 List(add 抛异常)。三、subList() 是视图四、重写 equals 没重写 hashCode五、HashMap 多线程 put六、自动拆箱 NPE七、CME 误以为是多线程八、removeIf 在不可变 List 上它们的共同根源是:Java 集合框架功能丰富,但很多行为(视图/固定大小/fail-fast/hashCode 契约)藏在"约定和实现细节"里;不了解这些隐含契约,按直觉用就会踩坑核心是:用 Java 集合,要了解每种集合/操作的"隐含契约"(可变性/是否视图/fail-fast/hashCode 要求);遍历删用 removeIf/Iterator.remove、equals 配 hashCode、并发用并发容器;别只按直觉用,读清它的约定下面这张图,是这次 CME 坑的成因与解法:

第四件事:几种"遍历时删除"方案对比表

这次踩坑后,我把几种"在遍历中删除元素"的方案对比成一张表。

方案 是否抛 CME 简洁度 适用
for-each + collection.remove() ✗ 抛(或偶发漏删) 错误写法, 别用
removeIf(条件) 不抛 最简洁(一行) 按条件删, 首选
Iterator + it.remove() 不抛 中等 遍历逻辑较复杂时
遍历副本改原集合 不抛 中等 删除中还要做别的
倒序索引 for 循环 不抛 中等 需索引/避免漏删

这张表把几种方案钉清了。核心是:问题的本质是"遍历(用迭代器)"和"修改集合结构"这两件事不能由"两个互不知情的主体"同时干——要么让"同一个主体"(迭代器自己 it.remove)来协调地做,要么让"遍历的对象"和"被改的对象"分开(遍历副本改原集合)它给我的最大启发是:当"读/遍历"和"写/修改"要同时作用在同一个数据结构上时,必须有一个"协调机制"来保证一致性——要么用提供了协调能力的接口(Iterator.remove/removeIf 内部协调好了)、要么在时空上把读和写错开(遍历副本/倒序);"无协调地一边读一边改同一个结构",几乎总是 bug 的温床(CME、漏删、数据错乱)这给了我一种处理"边遍历边改"的清醒:每当我想"一边遍历一个数据结构、一边修改它"时,都要先警觉——"这个结构, 允许我在遍历时改它吗?该用什么协调好的方式改?"——而不是想当然地直接改;"对'同时读写同一结构'保持警惕、用协调好的方式(专用接口或读写分离)去做",是避免这类一致性 bug 的关键意识认清遍历与修改需协调、别无协调地同时读写一个结构——是这个坑带给我的认知。

第五件事:这次事故暴露的"fail-fast"设计哲学

这次让我反思更深一层:那个 ConcurrentModificationException,其实是 Java 在"主动地、尽早地"帮我报告 bug。我把"fail-fast(快速失败)"和"fail-silently(静默容忍)"两种设计哲学对比成表。

维度 fail-fast(如抛CME) fail-silently(静默继续)
发现问题 立刻、在出错点 推迟、可能很晚或不发现
表现 抛异常崩溃(刺眼) 结果悄悄错(隐蔽)
定位难度 容易(现场就报) 难(现场无感, 远处才暴露)
对开发者 逼你当场修对 放任你带着bug上线
本质 把问题暴露在最早处 把问题掩盖/推迟

这张表道出了两种哲学的优劣。核心是:ConcurrentModificationException 看着讨厌(让程序崩了),但它其实是 fail-fast 哲学的体现——它在"检测到状态被意外修改"的第一时间、就在出错的现场把问题大声地报出来,而不是默默地继续、产生一个"谁也不知道对不对"的诡异结果;它用"当场崩溃的痛",换来了"问题极易定位、不会带病上线变成更隐蔽的数据错乱"它给我的深刻启发是:一个好的系统/设计,应当倾向于"fail-fast"——让错误在离它产生最近的地方、最早的时间暴露出来,而不是被容忍、被掩盖、被推迟到很远的下游才以更难懂的形式爆发;"早崩、响亮地崩",看似不友好,实则是对开发者最大的友好——因为最贵的 bug, 是那些"当时没报、上线后才以诡异方式发作、且离病根十万八千里"的 bug这给了我一种设计和编码时的取向:在我自己的代码里,也要主动践行 fail-fast——对"不该发生的状态"(非法参数、破坏的不变量、意外的 null)尽早地、明确地报错(断言、抛异常、参数校验),而不是用一个默认值、一个 try-catch 把它悄悄吞掉;"让代码在假设被违反的第一时间就响亮地失败",是写出易调试、可信赖的代码的一种重要原则认清 fail-fast 是把问题暴露在最早处的智慧、主动让代码尽早响亮地失败——是这个 CME 坑带给我的工程态度。

第六件事:要在遍历中改集合时,我现在的自检习惯

现在每当我要在遍历一个集合的过程中增删它的元素,我都会先按这张图问自己:

这张图的精髓,是"遍历中改集合,首选 removeIf,绝不在 for-each 里直接 remove"简单按条件删用 removeIf、复杂逻辑用 Iterator.remove、删除时还要干别的遍历副本改原集合,绝不在 for-each 里直接 collection.remove这套习惯,让我从"遍历里随手 remove"变成了"遍历改集合先想用哪种安全方式"——核心始终是:遍历集合时增删元素,用 removeIf 或 Iterator.remove,别在 for-each(迭代器)遍历时直接调集合的 remove/add 触发 fail-fast 的 CME。

我立下的几条规矩

这场"单线程遍历删元素却偶发 CME 崩溃"的事故,换来了我写 Java 集合操作时,刻进骨子里的几条铁律:

  1. ConcurrentModificationException 不只是多线程,单线程遍历改集合也抛。名字有误导。
  2. for-each 底层是迭代器,迭代器靠 modCount 做 fail-fast 自检。这是机制根源。
  3. 别在 for-each 里直接 collection.remove()/add()。会抛 CME 或偶发漏删。
  4. 按条件删除首选 removeIf(条件)。一行、简洁、不出错。
  5. 复杂遍历删除用 Iterator + it.remove()。它会同步计数,安全。
  6. 删倒数第二个偶发不抛,本地小数据测试可能漏过。别因测试没崩就以为没问题。
  7. fail-fast 是好事——让 bug 在最早处响亮暴露。自己写代码也践行尽早报错。

写在最后

回头看,这场由"一边遍历一边删"引发的、偶发的 ConcurrentModificationException 崩溃,真正教给我的,远不止"用 removeIf 代替 for-each 里的 remove"这一个技巧。它让我对"一个看似在'给我添乱'的报错, 背后可能藏着一个'在保护我'的良苦用心",有了一次刻骨的体会。我一开始,是把那个 ConcurrentModificationException 当成"麻烦、阻碍、要绕过的东西"来看的——它让我的程序崩了,我第一反应是"怎么把这个烦人的异常弄掉"。可当我真正搞懂它之后才明白:不是在添乱, 而是在'救我'——它在用"当场崩溃"的方式,逼我正视一个我没意识到的、危险的操作(遍历时破坏结构);如果它抛这个异常、而是"体贴地"让我继续,我的程序会静默地漏删元素、产生错误的数据,而我浑然不觉,直到很久以后某个更难查的地方,才发现"数据怎么不对"这让我领悟到一个关于"如何看待报错与约束"的深刻认知:很多时候,框架/语言/工具抛出的"讨厌的报错"、施加的"麻烦的约束",不是在跟你作对, 而是在'替你挡住一个你看不见的坑'——类型检查的报错、CME 的崩溃、编译器的警告、lint 的唠叨,本质都是"把一个潜在的、隐蔽的运行时灾难, 提前变成一个显眼的、当下的提醒";"报错不是敌人, 是最早、最忠诚地指出你错误的朋友"这给了我一种面对报错与约束时的心态转变:遇到一个"讨厌的报错/约束",第一反应不该是"怎么绕过/压制它",而该是"它在试图告诉我什么?它在保护我免于什么更糟的后果?"——先理解它的善意和原理, 再用正确的方式满足它, 而不是粗暴地把警报线掐掉;"把报错和约束当成善意的提醒去理解、而非障碍去绕过",是一个开发者从'对抗工具'走向'读懂并善用工具'的成熟标志认清讨厌的报错背后是保护你的善意、理解它再正确满足它而非绕过——这,是我用一次 CME 崩溃的事故,换来的、关于 Java 集合、也关于如何看待报错与约束的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次在遍历里想删元素时,顺手换成 removeIf、并对那个曾经讨厌的 CME 心生一点感激,那我对着那偶发的崩溃日志排查的这段时间,就值了。

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

我在 for range 循环里起了一批 goroutine 并发处理任务,结果它们全都处理了同一个、也就是最后一个任务:一次 Go 循环变量被复用、闭包捕获到的全是最后一个值的深度复盘

2026-6-2 20:08:10

技术教程

列表页前几页飞快、翻到几千页后接口直接超时,我用 LIMIT 一百万逗号二十去查那一页才发现数据库默默扫描并丢弃了前一百万行:一次深度分页 LIMIT offset 性能塌陷的深度复盘

2026-6-2 20:17:32

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