一句话先说结论:这个异常的名字有误导性。它不要求多线程,单线程里一边遍历一边改集合就会抛。根子是 fail-fast 迭代器拿
modCount和expectedModCount对不上。修法就两条:用iterator.remove(),或者 Java 8 的removeIf()。
背景
ConcurrentModificationException 是 Java 集合框架里最常见的运行时异常之一。名字里带「并发」,但它跟多线程没有必然关系。
ArrayList、HashMap、HashSet 这些标准集合都用 fail-fast 迭代器。迭代器一旦发现集合被「结构性修改」,立刻抛异常,而不是等你拿到一个不确定的遍历结果。
「结构性修改」指增删元素、改变容量这类操作,替换已有元素不算。
现象
一个单线程程序,遍历时删掉匹配元素,直接报错。
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));
for (String s : list) {
if (s.equals("b")) {
list.remove(s);
}
}
报错原文:
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)
at com.example.Main.process(Main.java:15)
HashMap 也一样,遍历 entrySet 时删 key:
java.util.ConcurrentModificationException
at java.base/java.util.HashMap$HashIterator.nextNode(HashMap.java:1597)
注意堆栈落在 next(),不是你的业务代码那行。很多人第一反应去 process() 找问题,实际根子在更早的遍历写法上。
根因分析
每个集合内部维护一个 modCount 计数器,每次结构性修改就加 1。
迭代器创建时,把当时的 modCount 记作 expectedModCount。之后每次 next() 都校验一次,两个值不一致就抛异常。
ArrayList 的 Itr 大致是这么写的:
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
for (String s : list) 本质是隐式拿到迭代器,再循环调 next()。你在循环体里调 list.remove(),modCount 变了,迭代器的 expectedModCount 没变,下一次 next() 就炸了。
官方文档还补了一句,容易被忽略:fail-fast 是「尽力而为」,不是硬保证。改了集合有时不抛异常,也可能发生。别把「没抛异常」当成「安全」。
解决方案
首选:removeIf(Java 8+)
一行搞定,任何 Collection 都能用。
list.removeIf(s -> s.equals("b"));
它内部处理好并发校验,天生安全。
手动循环用 Iterator.remove
不用 for-each,显式拿迭代器,删的时候调迭代器自己的 remove()。
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
if (s.equals("b")) {
it.remove();
}
}
iterator.remove() 删完会把 expectedModCount 同步成新值,所以不炸。反过来 list.remove() 就不行。
遍历副本
先复制一份再遍历副本、删原集合,能绕开但没必要。
for (String s : new ArrayList<>(list)) {
if (s.equals("b")) {
list.remove(s);
}
}
不推荐,额外开销大,还容易把「删哪个集合」搞混。
多线程场景换并发容器
真到了多个线程同时读写,fail-fast 集合本来就不合适,换成线程安全容器。
List<String> list = new CopyOnWriteArrayList<>();
Map<String, Integer> map = new ConcurrentHashMap<>();
CopyOnWriteArrayList 的迭代器是快照式的,不抛这个异常。ConcurrentHashMap 的迭代器是弱一致的,也不会抛。
思考过程
社区最常见的误判:看到 ConcurrentModificationException,先加 synchronized。结果单线程程序照样抛。
这个误区来自名字。官方文档开头就写了,这个异常不一定表示有别的线程在改。翻译过来就是,单线程违反对象契约也会抛。
另一个容易漏的点:stream().forEach(x -> list.remove(x)) 也是错的。stream 是给转换用的,不是给改集合用的。正确做法是 filter().collect()。
还有 Map 的坑。遍历 keySet() 时调 map.remove(key) 会炸,遍历 entrySet() 时调 it.remove() 不炸,两者别混。
小结
ConcurrentModificationException是 fail-fast 机制,不认线程,只认「遍历时改结构」。- 根子是
modCount和expectedModCount对不上。 - 首选
removeIf(),手动循环用iterator.remove()。 - 真并发场景上
CopyOnWriteArrayList或ConcurrentHashMap。 - 别把它当可靠的并发检测手段,它可能漏报。
来源
- ConcurrentModificationException — Oracle Java 8 API
- Why is a ConcurrentModificationException thrown and how to debug it — Stack Overflow 讨论镜像
- Java - ConcurrentModificationException 异常原因和解决方法 — 51CTO
- Fix: Java ConcurrentModificationException — FixDevs
- Why ConcurrentModificationException Occurs with LinkedHashMap — JavaThinking