Java 遍历时删元素抛 ConcurrentModificationException:fail-fast 原理与三种正确删法
发布时间:2026/9/1 3:02:30 作者:尧图编辑部 阅读量:1,286

Java 遍历时删元素抛 ConcurrentModificationException:fail-fast 原理与三种正确删法线上有段代码,把一批订单里已经取消的挑出来删掉,平时跑得好好的,某天量一大就抛ConcurrentModificationException,而且异常里没有任何和「并发」相关的线索——明明整段代码就一个线程在跑。这个异常名字里带 Concurrent,却经常在单线程里出现,坑了不少人。这篇把它的来龙去脉讲清楚,再给三种能落地的正确删法。先复现:一段看着人畜无害的删除代码importjava.util.*;publicclassDemo{publicstaticvoidmain(String[]args){ListStringordersnewArrayList(List.of(A,B-cancel,C,D-cancel));for(Stringo: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)会被编译器展开成迭代器写法:IteratorStringitorders.iterator();while(it.hasNext()){Stringoit.next();// ...}ArrayList内部维护一个字段modCount,每次结构性修改(add/remove 导致 size 变化)都会让它 1。而迭代器在创建时,会把当时的modCount抄一份存成expectedModCount。每次调it.next()前,都会做一次检查:// ArrayList 内部源码,简化版finalvoidcheckForComodification(){if(modCount!expectedModCount)thrownewConcurrentModificationException();}当你用orders.remove(o)删元素时,改的是 list 的modCount,但迭代器手里那份expectedModCount没跟着变。下一次next()一检查,两个数对不上,直接抛异常。这套机制叫fail-fast:一旦发现集合在迭代过程中被意外结构性修改,立刻失败,而不是继续跑出一个谁也说不清的错误结果。所以它和多线程没有必然关系——单线程里自己一边遍历一边删,同样触发。多线程只是另一种「迭代期间被人改了」的场景而已。一个更隐蔽的坑:删倒数第二个元素时不抛异常fail-fast 是「尽力而为」,不是「保证一定抛」。看这段:ListStringlistnewArrayList(List.of(A,B));for(Stringo: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,两边始终一致:IteratorStringitorders.iterator();while(it.hasNext()){Stringoit.next();if(o.endsWith(-cancel)){it.remove();// 用迭代器的 remove,不是 list 的}}要点:it.remove()删的是「上一次next()返回的那个元素」,所以必须先next()再remove(),连续调两次remove()会抛IllegalStateException。这是最经典、兼容性最好的写法,JDK 1.2 就有了。正确删法二:removeIf(Java 8 起,首选)如果只是「按条件删」,别再手写迭代器了,removeIf一行搞定,而且底层帮你处理好了 modCount:orders.removeIf(o-o.endsWith(-cancel));它内部是遍历一遍、标记要删的位置、最后统一搬移数组,只在结束时更新一次modCount,既不会抛 CME,性能也比「循环里一个个 remove」好——后者每删一个都要把后面的元素整体前移一位,是 O(n²)。日常需求 90% 都该用这个。正确删法三:CopyOnWriteArrayList(真·多线程场景)前两种解决的是「单线程边遍历边删」。如果是真的多线程——一个线程在遍历、另一个线程在增删,那removeIf也救不了你,得换容器。CopyOnWriteArrayList的迭代器基于「创建迭代器那一刻的数组快照」,遍历期间别的线程怎么改都不影响这份快照,自然不会 fail-fast:importjava.util.concurrent.CopyOnWriteArrayList;ListStringordersnewCopyOnWriteArrayList(List.of(A,B-cancel,C));// 线程 1 遍历for(Stringo:orders){System.out.println(o);// 遍历的是快照,不受线程 2 影响}// 线程 2 同时删,互不干扰orders.removeIf(o-o.endsWith(-cancel));代价是:每次写操作(add/remove)都会复制整个底层数组,所以它只适合「读远多于写」的场景,比如监听器列表、配置项列表。要是写很频繁,复制开销会把你拖垮,那种场景该考虑加锁或用别的并发容器。顺带说一句:HashMap 遍历时删也一样这套 fail-fast 不是 List 专属,HashMap、HashSet也一样。遍历 map 时想删 key,同样得用迭代器或removeIf:MapString,IntegermapnewHashMap(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。