有个每天凌晨跑的批处理任务,逻辑很朴素:遍历一批订单,把其中已失效的剔除掉,剩下的继续处理。这段代码我写得行云流水,测试环境跑了无数遍都好好的,上线后也安稳了一阵。直到某天凌晨,任务突然挂了,异常栈顶端赫然写着:java.util.ConcurrentModificationException。我当时就懵了——这台机器上就一个线程在跑这个任务,哪来的"并发修改(Concurrent Modification)"?明明是单线程,异常名字里却带着"并发"二字,这误导性的命名让我一开始完全找错了方向,围着多线程查了半天,一无所获。
后来静下心来读异常栈,才看清真凶就在那段"遍历中删除"的代码里:我用增强 for 循环遍历这个 List,在循环体里一旦发现失效订单,就直接调用 list.remove(order) 把它删掉。问题恰恰出在这儿——你正在用迭代器遍历一个集合,却又绕过迭代器、直接去修改这个集合的结构(增删元素),Java 的集合就会立刻翻脸,抛出 ConcurrentModificationException。它根本不是在说"多线程并发",而是在说"你在遍历的同时改了它"。
这就是 Java 集合里一个极其经典、又极具迷惑性的坑:fail-fast(快速失败)机制下的"遍历时修改"异常。它名字里的"Concurrent"骗了无数人,让大家误以为是线程安全问题,实则单线程下同样高发。这篇文章,就从这次"凌晨批处理崩溃"的事故出发,把这个异常的来龙去脉、以及正确的"边遍历边删除"姿势,一次讲透。
先摆几个关于集合遍历的想当然
动手复盘前,先把我自己曾经深信、后来被这个异常教育的几个念头摆出来。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "ConcurrentModificationException 是多线程才会有的" | 单线程下"遍历时改集合"同样会触发, 名字极具误导性 |
| "增强 for 循环里直接 remove 没问题" | 增强 for 底层用迭代器, 你绕过它直接改集合就会抛异常 |
| "测试没报错,说明这么写是对的" | 是否抛异常和删的位置有关, 删倒数第二个元素恰好不报, 极具欺骗性 |
| "fail-fast 是个 bug,挺烦人" | 它是有意的保护设计, 宁可快速报错也不让你拿到错乱数据 |
| "想删元素,自己记下标再删就好" | 边遍历边按下标删, 会漏删、错删, 同样是经典坑 |
这些念头的共同病根,是对 Java 集合的迭代器机制和 fail-fast 设计缺乏理解,把"遍历"和"修改"想当然地当成可以随意混搭的操作。要看清这次事故,得先搞明白增强 for 循环背后到底发生了什么。
第一件事:增强 for 的真面目,和 modCount 这个暗哨
很多人不知道,Java 里那个简洁的增强 for 循环(for (Order o : list)),只是一颗语法糖。编译器会把它翻译成基于迭代器(Iterator)的循环:先调 list.iterator() 拿到一个迭代器,然后反复调 hasNext() 判断、next() 取值。也就是说,你以为自己在"直接遍历列表",实际上全程是那个迭代器在替你工作。
关键就在这里。ArrayList 这类集合内部维护着一个字段叫 modCount(modification count,修改计数),每当集合的结构发生改变(增、删元素),这个计数就加一。而迭代器在被创建的那一刻,会把当时的 modCount 记下来,存成自己的 expectedModCount。之后每次调用 next(),迭代器都会检查一下:集合现在的 modCount,还等于我当初记下的 expectedModCount 吗?一旦不相等,就说明"在我遍历的过程中,有人偷偷改了集合的结构"——迭代器立刻抛出 ConcurrentModificationException。
所以当你在增强 for 里调 list.remove(order),这个 remove 把 modCount 加了一,可迭代器自己的 expectedModCount 还停在旧值;下一次循环走到 next(),两者一对不上,异常就来了。下面这张图,把这个机制画出来:
看懂这张图,事故的根就清楚了:不是"并发"惹的祸,而是我用集合的 remove 改了 modCount,却没让正在干活的迭代器知道,迭代器一校验对不上,就快速失败报警了。"遍历"和"结构性修改"必须协调一致,绕过迭代器单方面改集合,就是在制造这种不一致。接下来,我们就看正确的删除姿势。
第二件事:正确的边遍历边删——用迭代器自己的 remove
问题想清楚了,解法的思路就明确了:既然异常来自"绕过迭代器改集合",那正确做法就是让迭代器自己来删。Iterator 接口提供了一个 remove() 方法,用它删除当前元素时,迭代器会在内部同步更新自己的 expectedModCount,让它和集合的 modCount 保持一致——于是校验不会失败,删除安全进行。
// 反例:增强 for 里直接 list.remove, 触发 ConcurrentModificationException
for (Order order : orders) {
if (order.isExpired()) {
orders.remove(order); // 绕过迭代器改集合, modCount 失配, 抛异常
}
}
// 正解:显式用迭代器, 调它自己的 remove(), 内部会同步 expectedModCount
Iterator<Order> it = orders.iterator();
while (it.hasNext()) {
Order order = it.next();
if (order.isExpired()) {
it.remove(); // 由迭代器删除当前元素, 安全, 不抛异常
}
}
这里的要点是:it.remove() 删除的是上一次 next() 返回的那个元素,所以调用它之前必须先调过 next()。它和直接 list.remove() 的本质区别在于,前者是"迭代器主导的删除",它知道自己在干什么、会维护好一致性;后者是"背着迭代器的删除",制造了不一致。记住一句话:在迭代过程中要删元素,只能用迭代器的 remove(),不能用集合的 remove()。
第三件事:Java 8 之后,首选更简洁的 removeIf
显式写迭代器虽然正确,但略显啰嗦。Java 8 给集合加了一个专门干这事的方法 removeIf,它接收一个判断条件(Predicate),把所有满足条件的元素安全地一次性删掉——内部就是用迭代器实现的,既正确又简洁,是现代 Java 里"按条件删除"的首选写法。
// 最佳:removeIf 一行搞定, 内部用迭代器保证安全
orders.removeIf(order -> order.isExpired());
// 它等价于前面那段显式迭代器代码, 但更短、更不易出错
// 想删"金额为 0 且已失效"的, 条件随便组合:
orders.removeIf(o -> o.getAmount() == 0 && o.isExpired());
对于"从集合里剔除满足某条件的元素"这个最常见的需求,removeIf 应该是你的第一反应——它把意图表达得清清楚楚("移除符合条件的"),又彻底回避了遍历时修改的陷阱。只有当删除逻辑很复杂、或者删除时还要做别的副作用操作时,才退回到显式迭代器的写法。
还有一种思路是"收集再处理":不在遍历原集合时删,而是先遍历一遍把要删的(或要保留的)挑进一个新集合,遍历结束后再统一操作。比如用 Stream 的 filter 直接产出一个"只含有效订单"的新列表——既绕开了 fail-fast,代码也更函数式、更清晰。
// 思路二:用 Stream filter 产出新集合, 完全不碰原集合的遍历删除
List<Order> valid = orders.stream()
.filter(o -> !o.isExpired()) // 只保留没失效的
.collect(Collectors.toList());
// 原 orders 不动, valid 是一个干净的新列表, 没有任何遍历修改冲突
第四件事:为什么测试时它常常"装作没事"
这个 bug 最阴险的地方,是它在测试里经常不报错,骗过你之后才在生产爆发。根源在于 ArrayList 增强 for 的一个实现细节:它判断循环是否结束,靠的是 hasNext(),而 hasNext() 的逻辑是"当前游标 cursor 是否等于列表 size"。当你删掉一个元素,size 减一;如果你恰好删的是倒数第二个元素,删完后游标正好等于新的 size,hasNext() 返回 false,循环直接结束——那次本该触发校验的 next() 根本没机会执行,异常也就不会抛出。
// 极具欺骗性:删倒数第二个元素, 恰好不抛异常, 但这是"碰巧", 不是"正确"
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
for (String s : list) {
if (s.equals("b")) { // "b" 是倒数第二个
list.remove(s); // 删完 size=2, 游标到 2, hasNext 返回 false
} // 循环结束, 居然不报错! 但换个位置就炸
}
// 测试数据里失效项恰好排在倒数第二, 就会"测试通过、生产崩溃"
这解释了我那次的遭遇:测试数据规模小、失效项的位置又恰好"幸运",于是一路绿灯;生产数据千变万化,失效项落在任意位置,异常自然就爆发了。这个坑给我的额外警示是:代码"没报错"不等于"写对了",有时只是数据恰好绕过了 bug。看到"遍历时改集合"这种写法,哪怕它眼下没报错,也要当成定时炸弹处理掉,绝不能因为"测试通过"就放过它。
第五件事:fail-fast 与 fail-safe,以及并发场景
理解了 fail-fast,再认识它的对立面 fail-safe,全局就完整了。ArrayList、HashMap 这些普通集合是 fail-fast 的:一发现遍历时被结构性修改,立刻抛异常,宁可"快速失败"也不让你继续在错乱状态下跑。而 java.util.concurrent 包下的并发集合,如 CopyOnWriteArrayList、ConcurrentHashMap,是 fail-safe 的:它们允许遍历时修改而不抛异常,代价是迭代器看到的可能是某个"快照"或不完全实时的视图。
// 真正多线程场景: 用并发集合, 遍历时被改也不抛异常(fail-safe)
List<Order> orders = new CopyOnWriteArrayList<>();
// 它在"写"时复制一份底层数组, 迭代器读的是旧快照, 因此互不干扰
for (Order o : orders) {
// 即便此刻别的线程在 add/remove, 这里也不会抛 ConcurrentModificationException
process(o); // 但注意: 你遍历到的是创建迭代器那一刻的快照
}
这里要厘清一个重要区别:fail-fast 的异常,在单线程下(像我这次)是"自己遍历时改了自己",在多线程下则可能是"别的线程改了我正在遍历的集合"。前者改写法即可,后者才是真正的线程安全问题,需要换并发集合或加锁。所以遇到这个异常,先分清是单线程的"自己改自己"(改用迭代器/removeIf),还是多线程的"别人改我"(上 CopyOnWriteArrayList/ConcurrentHashMap 或同步)。对症下药,别一律当多线程处理——这正是当年误导我半天的地方。
第六件事:Map 遍历删除,同样的坑同样的解
这个机制不只存在于 List,HashMap 等也一样。在遍历 Map 时直接 map.remove(key),同样会触发 ConcurrentModificationException。解法思路完全一致:要么用 entrySet() 的迭代器删,要么用 Java 8 的 removeIf(在 entrySet 上)或更直接的方式。
// 反例:遍历 Map 时直接 map.remove, 同样抛异常
for (Map.Entry<String, Order> e : map.entrySet()) {
if (e.getValue().isExpired()) {
map.remove(e.getKey()); // 抛 ConcurrentModificationException
}
}
// 正解一:用 entrySet 的迭代器删
Iterator<Map.Entry<String, Order>> it = map.entrySet().iterator();
while (it.hasNext()) {
if (it.next().getValue().isExpired()) it.remove(); // 安全
}
// 正解二:Java 8 的 entrySet().removeIf, 一行搞定
map.entrySet().removeIf(e -> e.getValue().isExpired());
到这儿,这个异常的方方面面就都掀开了。我把排查与修复的思路收成一张决策图:
把这套理解建立起来,这个曾经让我抓瞎的异常,就再也唬不住你了。最后,拧成几条可直接照做的铁律:
- 别被名字骗了,ConcurrentModificationException 单线程也高发,先分清"自己改自己"还是"别人改我"。
- 遍历中删元素,用迭代器的
remove(),绝不用集合自己的remove()。 - 按条件删除首选
removeIf,一行表意清晰又安全,这是现代 Java 的标准答案。 - "没报错"≠"写对了",删倒数第二个恰好不抛,遍历时改集合的写法一律视为隐患清除。
- 要产出过滤结果,用 Stream 的
filter产新集合,根本不碰原集合的遍历删除。 - 真多线程并发改集合,换并发集合或加锁,如 CopyOnWriteArrayList、ConcurrentHashMap。
- Map 遍历删除同理,用 entrySet 的迭代器或
entrySet().removeIf。
一张遍历删除速查表
把各种场景下"该怎么删"汇成一张表,写代码时随手对照。
| 场景 | 错误写法 | 正确做法 |
|---|---|---|
| List 按条件删(单线程) | 增强 for 里 list.remove | removeIf(首选) 或迭代器 remove |
| List 遍历中要加元素 | 增强 for 里 list.add | ListIterator 的 add, 或下标 for |
| 要得到过滤后的新集合 | 边遍历边删原集合 | Stream filter 产出新 List |
| Map 按条件删 | 遍历时 map.remove(key) | entrySet().removeIf 或迭代器 |
| 遍历中改元素的值(非结构) | —(改值不算结构修改, 安全) | 直接改, 不会抛异常 |
| 多线程并发遍历+修改 | 用 ArrayList/HashMap | CopyOnWriteArrayList/ConcurrentHashMap |
顺带一提:遍历中要"增"或"改",用 ListIterator
前面都在说删除,其实"遍历中增加元素"也是同样的坑——在增强 for 里 list.add() 一样会抛 ConcurrentModificationException。这时 Iterator 不够用了(它只有 remove,没有 add),需要功能更强的 ListIterator:它额外提供了 add() 和 set(),能在遍历过程中安全地插入和替换元素。
// 遍历中要插入元素:用 ListIterator 的 add(), 安全
ListIterator<String> lit = list.listIterator();
while (lit.hasNext()) {
String s = lit.next();
if (needInsertAfter(s)) {
lit.add("new-" + s); // 在当前位置后安全插入, 不抛异常
}
}
// 注意区分:仅"修改元素的值"不算结构性修改, 不会触发异常
for (Order o : orders) {
o.setStatus("PROCESSED"); // 改对象的属性, 没动集合结构, 完全安全
}
这里要厘清一个常被混淆的点:触发 fail-fast 的是"结构性修改"(增、删元素,改变 size),而不是"修改元素本身的内容"。你在遍历时调用元素的 setter 改它的属性,集合的结构没变、modCount 没动,完全安全。真正会引爆异常的,只有那些改变集合大小的操作。把"改结构"和"改内容"这两件事在脑子里分清,你就不会再对什么时候会抛异常感到模糊。
一个"自作聪明"的歧路:用下标循环来绕开异常
当年发现增强 for 会抛异常后,我的第一反应不是去用迭代器,而是"机智"地换成了普通的下标 for 循环——心想用 for (int i...) 不就没有迭代器、不就不会抛异常了吗?结果跳进了另一个更隐蔽的坑:边遍历边按下标删,会漏删元素。
// 反例:下标 for 里删除, 不抛异常了, 但会漏删!
for (int i = 0; i < list.size(); i++) {
if (list.get(i).isExpired()) {
list.remove(i); // 删了 i 处, 后面元素整体前移一位
} // 但 i 还要 ++, 于是"前移上来的那个"被跳过了!
}
// 两个连续的失效项, 第二个会被漏掉
// 勉强的修补:删完把 i 减回来(能用但易错、可读性差)
for (int i = 0; i < list.size(); i++) {
if (list.get(i).isExpired()) {
list.remove(i);
i--; // 删一个就退一格, 抵消前移
}
}
// 或者干脆倒着遍历, 从后往前删, 前移就不影响未遍历的部分
for (int i = list.size() - 1; i >= 0; i--) {
if (list.get(i).isExpired()) list.remove(i);
}
这个歧路很有教育意义:它告诉你,"绕开异常"和"写对逻辑"是两码事。下标 for 确实不抛 ConcurrentModificationException 了,因为它压根没用迭代器、没有 modCount 校验——但正因为少了这层校验,它把"删除导致元素前移"这个更隐蔽的逻辑错误,默默地放行了。fail-fast 的异常虽然烦人,某种意义上却是在保护你;你用下标 for 把这层保护绕过去,反而可能掉进一个不报错、却结果错误的更深的坑。
所以正确的态度,不是想方设法"让异常别抛",而是从根上用对的工具(removeIf、迭代器、Stream)去表达"删除"这个意图。这些工具不仅回避了异常,更重要的是它们内部已经把"删除后的位置调整"处理得正确无误,让你彻底不用操心下标前移这种琐碎又易错的细节。用对抽象,远比用各种小聪明去绕开报错要可靠。这也是这次事故给我的一个延伸领悟:当语言用异常拦住你时,先想想它在拦你什么,而不是急着找个不报错的旁门左道。
写在最后
这次"凌晨批处理崩溃"的事故,最值得回味的,是那个名字带来的认知陷阱。ConcurrentModificationException,一个堂而皇之写着"Concurrent(并发)"的异常,却在我单线程的代码里爆发,把我的排查方向整整带偏了半天。这件事让我深刻体会到:有时候,误导我们的不是问题本身有多难,而是它的名字、它的表象,诱使我们一头扎进了错误的假设里。遇到诡异问题时,与其急着顺着名字给的"暗示"猛冲,不如先静下心来,把异常栈、把代码逻辑老老实实读一遍——真相往往就静静躺在那里,只是被一个唬人的名字挡住了。
而剥开这个唬人的名字,内核其实是一个朴素而深刻的设计哲学:fail-fast。Java 的集合宁可在你"遍历时改结构"的那一刻就立刻抛异常、把程序拦下来,也不愿意让你在一个状态错乱的集合上继续跑、产出谁也说不清对错的结果。"快速地、响亮地失败",远胜于"悄无声息地出错"——这个理念,和这个系列里反复出现的主题一脉相承:好的系统,不是不会出错,而是会在出错的第一时间,用最显眼的方式告诉你。理解了这层用意,你就不会再把 fail-fast 当成讨厌的绊脚石,而会把它看作一个尽职尽责、替你守在错误最前线的哨兵。愿你我都能读懂这些机制背后的善意,把每一次被它拦下的时刻,都变成一次更深理解的契机。
如果你手上也有 Java 项目,不妨今天就花十几分钟做两件小事自查。第一,全局搜一下"在循环里调用集合的 remove/add"这种模式,尤其是增强 for 循环体里直接操作正在遍历的那个集合的地方——它们要么已经在偷偷埋雷,要么就是靠数据"碰巧"绕过了 bug,统统换成 removeIf 或迭代器写法。第二,如果你的集合会被多个线程同时读写,确认它是不是用了线程安全的并发集合,而不是裸的 ArrayList/HashMap——后者在并发遍历修改下,不仅会抛这个异常,还可能产生更难查的数据错乱。这两件事都不难,却能帮你提前拆掉那些专挑凌晨、专挑生产高峰发作的定时炸弹。
回头看,这个让我半夜被告警叫醒的异常,其实是一堂关于"理解机制"的好课。它表面是个具体的 API 用法问题——"遍历时不能直接删"——可往深一层,它考的是你懂不懂增强 for 背后的迭代器、懂不懂 modCount 这道暗哨、懂不懂 fail-fast 的设计意图。一旦把这些底层机制串起来,你会发现,正确的写法不是需要死记硬背的"规定",而是从原理出发自然推导出的"必然"。这正是这个系列我最想传递的东西:把每天都在用的工具,往下多挖一层,理解它为什么这样设计、这样运行——因为那一层理解,才是让你在深夜的告警面前,能从容地一眼看穿真相、而不是手忙脚乱乱试一通的底气所在。愿你我都不满足于"会用",而是一次次地问"为什么",在这些朴素的基础里,扎下越来越深的根。
更让我后怕的是,这个 bug 偏偏挑在凌晨无人值守时发作,又顶着一个把人往歧路上带的名字,二者叠加,几乎就是为"半夜手忙脚乱"量身定做的。这也提醒我:越是在自动化、无人盯守的任务里,越不能留下这种"靠数据碰运气"的写法。把每一处"遍历时改集合"都在白天清醒时就清理干净,远胜于等它在某个凌晨炸开、再被告警从睡梦中拽起来。代码的健壮,从来都是在风平浪静时一点点攒下的,而不是在事故现场临时拼出来的。
—— 别看了 · 2026