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 专属,HashMapHashSet 也一样。遍历 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

小结

  • ConcurrentModificationExceptionfail-fast 机制抛出,核心是迭代器的 expectedModCount 和集合的 modCount 对不上,和多线程没有必然关系,单线程边遍历边删就会触发。
  • 它是「尽力而为」不是「保证抛」:删倒数第二个元素时不抛,别把「没报错」当成「删对了」
  • 三种正确删法各有场景:单线程手动控制用 Iterator.remove();按条件批量删用 removeIf(首选,还快);真多线程并发读写用 CopyOnWriteArrayList(读多写少)。
  • 一句话记忆点:遍历时想删东西,别碰集合本身,要么交给迭代器,要么交给 removeIf。
相关推荐
IT_陈寒2 小时前
React Hooks闭包陷阱差点让我加班到凌晨
前端·人工智能·后端
码事漫谈2 小时前
本体论,彻底懂
后端
创新技术阁3 小时前
FastapiAdmin 系统日志体系与核心配置参数详解
前端·后端·fastapi
掘金者阿豪3 小时前
SQL Server 迁到金仓,真正麻烦的不是数据,而是这些 T-SQL
后端
Kyrie_kk3 小时前
Java--IO--Path文件访问
java·后端
wxwx_bscxy3223 小时前
基于springboot宠物领养系统的设计与实现
数据库·spring boot·后端·spring·宠物
分支预测失败4 小时前
RISC-V AIA 中断架构实战:从 PLIC 到 APLIC 与 IMSIC 的迁移
linux·后端
源代码•宸4 小时前
前置准备:定时微服务背景和现状
开发语言·经验分享·后端·微服务·云原生·架构·golang
我的div丢了肿么办4 小时前
go语言中的map,map定义不同的数据类型,循环map,map的无序性
后端·go