Java 遍历时删元素抛 ConcurrentModificationException:fail-fast 原理与三种正确删法

Java 遍历时删元素抛 ConcurrentModificationException:fail-fast 原理与三种正确删法

线上有段代码,把一批订单里已经取消的挑出来删掉,平时跑得好好的,某天量一大就抛 ConcurrentModificationException,而且异常里没有任何和「并发」相关的线索------明明整段代码就一个线程在跑。这个异常名字里带 Concurrent,却经常在单线程里出现,坑了不少人。这篇把它的来龙去脉讲清楚,再给三种能落地的正确删法。

先复现:一段看着人畜无害的删除代码

java 复制代码
import java.util.*;

public class Demo {
    public static void main(String[] args) {
        List<String> orders = new ArrayList<>(List.of("A", "B-cancel", "C", "D-cancel"));

        for (String o : orders) {
            if (o.endsWith("-cancel")) {
                orders.remove(o); // 直接在增强 for 里删
            }
        }
        System.out.println(orders);
    }
}

跑一下,直接抛异常:

复制代码
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:967)

注意异常栈的最后一帧是 Itr.next------问题不出在 remove,而出在下一次取元素的时候。这说明增强 for 底层其实是迭代器在干活。

原理:modCount 和 expectedModCount 对不上了

增强 for 循环 for (String o : orders) 会被编译器展开成迭代器写法:

java 复制代码
Iterator<String> it = orders.iterator();
while (it.hasNext()) {
    String o = it.next();
    // ...
}

ArrayList 内部维护一个字段 modCount,每次结构性修改(add/remove 导致 size 变化)都会让它 +1。而迭代器在创建时,会把当时的 modCount 抄一份存成 expectedModCount。每次调 it.next() 前,都会做一次检查:

java 复制代码
// ArrayList 内部源码,简化版
final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

当你用 orders.remove(o) 删元素时,改的是 list 的 modCount,但迭代器手里那份 expectedModCount 没跟着变。下一次 next() 一检查,两个数对不上,直接抛异常。这套机制叫 fail-fast:一旦发现集合在迭代过程中被意外结构性修改,立刻失败,而不是继续跑出一个谁也说不清的错误结果。

所以它和多线程没有必然关系------单线程里自己一边遍历一边删,同样触发。多线程只是另一种「迭代期间被人改了」的场景而已。

一个更隐蔽的坑:删倒数第二个元素时不抛异常

fail-fast 是「尽力而为」,不是「保证一定抛」。看这段:

java 复制代码
List<String> list = new ArrayList<>(List.of("A", "B"));
for (String o : list) {
    if (o.equals("A")) {
        list.remove(o); // 删的是倒数第二个
    }
}
System.out.println(list); // [B],居然正常跑完,没抛异常

为什么?ArrayList 的迭代器 hasNext() 判断依据是 cursor != size。删掉 "A" 后 size 从 2 变成 1,此时 cursor 也正好是 1,hasNext() 返回 false,循环直接结束,压根没机会走到下一次 next() 的检查。

这就是最危险的地方:你的测试用例可能恰好删的是倒数第二个,跑得好好的;上线后数据一变,删了别的位置,才炸。别指望「没抛异常」就等于「删对了」。

正确删法一:用迭代器自己的 remove()

Iterator 提供了 remove() 方法,它删元素的同时会把 expectedModCount 同步成新的 modCount,两边始终一致:

java 复制代码
Iterator<String> it = orders.iterator();
while (it.hasNext()) {
    String o = it.next();
    if (o.endsWith("-cancel")) {
        it.remove(); // 用迭代器的 remove,不是 list 的
    }
}

要点:it.remove() 删的是「上一次 next() 返回的那个元素」,所以必须先 next() 再 remove(),连续调两次 remove() 会抛 IllegalStateException。这是最经典、兼容性最好的写法,JDK 1.2 就有了。

正确删法二:removeIf(Java 8 起,首选)

如果只是「按条件删」,别再手写迭代器了,removeIf 一行搞定,而且底层帮你处理好了 modCount:

java 复制代码
orders.removeIf(o -> o.endsWith("-cancel"));

它内部是遍历一遍、标记要删的位置、最后统一搬移数组,只在结束时更新一次 modCount,既不会抛 CME,性能也比「循环里一个个 remove」好------后者每删一个都要把后面的元素整体前移一位,是 O(n²)。日常需求 90% 都该用这个。

正确删法三:CopyOnWriteArrayList(真·多线程场景)

前两种解决的是「单线程边遍历边删」。如果是真的多线程 ------一个线程在遍历、另一个线程在增删,那 removeIf 也救不了你,得换容器。CopyOnWriteArrayList 的迭代器基于「创建迭代器那一刻的数组快照」,遍历期间别的线程怎么改都不影响这份快照,自然不会 fail-fast:

java 复制代码
import java.util.concurrent.CopyOnWriteArrayList;

List<String> orders = new CopyOnWriteArrayList<>(List.of("A", "B-cancel", "C"));

// 线程 1 遍历
for (String o : orders) {
    System.out.println(o); // 遍历的是快照,不受线程 2 影响
}
// 线程 2 同时删,互不干扰
orders.removeIf(o -> o.endsWith("-cancel"));

代价是:每次写操作(add/remove)都会复制整个底层数组,所以它只适合「读远多于写」的场景,比如监听器列表、配置项列表。要是写很频繁,复制开销会把你拖垮,那种场景该考虑加锁或用别的并发容器。

顺带说一句:HashMap 遍历时删也一样

这套 fail-fast 不是 List 专属,HashMap、HashSet 也一样。遍历 map 时想删 key,同样得用迭代器或 removeIf:

java 复制代码
Map<String, Integer> map = new HashMap<>(Map.of("a", 1, "b", 0, "c", 2));

// 删掉 value 为 0 的项
map.entrySet().removeIf(e -> e.getValue() == 0); // entrySet 的视图支持 removeIf

小结

  • ConcurrentModificationException 由 fail-fast 机制抛出,核心是迭代器的 expectedModCount 和集合的 modCount 对不上,和多线程没有必然关系,单线程边遍历边删就会触发。
  • 它是「尽力而为」不是「保证抛」:删倒数第二个元素时不抛,别把「没报错」当成「删对了」。
  • 三种正确删法各有场景:单线程手动控制用 Iterator.remove();按条件批量删用 removeIf(首选,还快);真多线程并发读写用 CopyOnWriteArrayList(读多写少)。
  • 一句话记忆点:遍历时想删东西,别碰集合本身,要么交给迭代器,要么交给 removeIf。
相关推荐
武子康4 小时前
ESP32 唤醒成功,离线命令为什么没有执行?
人工智能·后端·agent
挖掘狂人4 小时前
SQL 注入:一行代码如何撬开整个数据库
后端·sql·数据分析
今年下半年4 小时前
【短信发送】Spring Boot整合多种短信接口的实现(附源码)
java·spring boot·后端·sms·qcloudsms
不爱吃鱼的蔓热莓4 小时前
Autosar之统计SWC中port、runnable、信号名等信息(三)
后端·restful
北城笑笑5 小时前
Server 18 ,Nginx 双服务器 HTTP转HTTPS 代理实战( HTTP 地址换 HTTPS 域名 )
运维·服务器·前端·后端·nginx·http·https
紫洋葱hh5 小时前
Go 入门基础:类型、变量与运算符
开发语言·后端·ai·golang
打工仔折腾 AI5 小时前
Qt网络编程实战:UDP、TCP到HTTP的落地经验与选型
网络·人工智能·后端·python·qt·udp·ai agent 实战
打工仔折腾 AI5 小时前
SQL 编辑器不是高级记事本:KStudio 与 KSQL Developer 的建表与增删改查手法
数据库·人工智能·后端·python·sql·编辑器·ai agent 实战
拖孩6 小时前
# AI 帮我写的小程序,上线不到一个月流量主突然爆了
前端·后端·微信小程序
考虑考虑6 小时前
JFROG手动上传包
运维·后端·自动化运维