在使用ArrayList、HashMap等集合进行遍历时,如果在遍历过程中直接调用集合的add或remove方法修改结构,程序就会抛出java.util.ConcurrentModificationException。这个名字容易让人误解,以为只有多线程环境才会出现,实际上单线程中一样会触发。要彻底解决这个问题,需要先弄清楚异常背后的fail-fast机制,再根据具体场景选择合适的处理方案。

异常产生的底层原理:modCount与fail-fast机制
翻开ArrayList的源码可以看到,每个AbstractList的子类都持有一个名为modCount的字段,它记录了集合结构被修改的次数。任何会引起结构变化的操作,比如add、remove、clear,都会让modCount自增。当通过iterator()方法获取迭代器时,迭代器会把当前的modCount值保存到自己的expectedModCount字段中。
迭代器每次调用next()方法时,都会执行一个内部检查方法checkForComodification(),它的逻辑很简单:比较modCount和expectedModCount是否相等,不相等就立即抛出ConcurrentModificationException。这种设计被称为fail-fast(快速失败),目的是尽早暴露错误,避免在结构已经改变的数据上继续遍历产生不可预测的结果。
需要注意的一点是,Java语言规范并没有强制要求迭代器必须抛出这个异常,它只是尽力而为的防御性检查。这也是为什么在某些边界情况下(比如遍历倒数第二个元素后删除最后一个元素),使用for-each循环删除元素却碰巧没有报错的原因,但这种巧合不可依赖,不同JDK版本表现可能不同。
典型的错误代码示例分析
先看一段最经典的错误代码,几乎每个初学者都写过类似的逻辑:
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "d"));
for (String item : list) {
if ("b".equals(item)) {
list.remove(item); // 遍历时直接删除,抛出异常
}
}
这段代码的for-each循环本质上就是迭代器遍历,删除元素后modCount增加了,但迭代器的expectedModCount还是旧值,下一次调用next()时检查失败,异常立刻抛出。如果把条件改成"c".equals(item),删除倒数第二个元素后循环恰好结束,next()不再被调用,异常反而不会出现,这正是很多人困惑的根源。
另一个常见误区是以为改用下标遍历就安全了:
for (int i = 0; i < list.size(); i++) {
if ("b".equals(list.get(i))) {
list.remove(i); // 不抛异常,但会跳过元素
}
}
普通for循环确实不会触发这个异常,因为它是通过下标访问,不经过迭代器检查。但删除元素后,后面的元素会整体前移一位,导致下一个被检查的元素被跳过。比如连续两个相邻的"b"只能删掉一个,这种逻辑错误比抛异常更隐蔽,排查起来反而更麻烦。
单线程场景下的正确处理方式
最正统的做法是使用迭代器自带的删除方法。Iterator的remove()方法在删除元素后会同步更新expectedModCount,因此不会触发检查失败:
Iterator<String> it = list.iterator();
while (it.hasNext()) {
if ("b".equals(it.next())) {
it.remove(); // 正确:同步更新了expectedModCount
}
}
需要注意的是,remove()每调用一次前必须先调用过next(),连续调用两次remove()会抛出IllegalStateException。HashMap的遍历删除同理,通过entrySet()获取迭代器后调用it.remove()即可。
JDK 8之后有更简洁的写法,直接使用集合的removeIf()方法,它内部封装了迭代器逻辑,代码可读性更好:
list.removeIf(item -> "b".equals(item));
如果不想边遍历边删除,也可以先在遍历时把待删除元素收集到一个临时列表,遍历结束后调用removeAll()一次性删除。这种写法在元素较多时性能略差,但逻辑最清晰,也最不容易出错。
此外还有一种倒序遍历的技巧,从最后一个下标往前遍历,删除当前元素不会影响前面还未访问的元素:
for (int i = list.size() - 1; i >= 0; i--) {
if ("b".equals(list.get(i))) {
list.remove(i); // 倒序删除不会跳过元素
}
}
这种写法只适合ArrayList这类基于数组、支持下标随机访问的集合,LinkedList用下标访问效率很低,不建议采用。
多线程场景下的解决方案
如果多个线程同时读写同一个集合,即使每个线程都用迭代器的remove()方法也可能出问题,因为一个线程的修改会让另一个线程的迭代器检查失败。这时应优先考虑java.util.concurrent包提供的并发容器。
CopyOnWriteArrayList是最常用的替代方案,它的写操作会复制一份新数组,读操作完全不加锁,迭代器遍历的是创建时刻的快照,因此永远不会抛ConcurrentModificationException:
List<String> list = new CopyOnWriteArrayList<>();
list.add("a");
list.add("b");
for (String item : list) {
if ("b".equals(item)) {
list.remove(item); // 安全,但可能删的是快照之外的新数据
}
}
它的代价是每次写操作都要复制数组,内存开销和写入成本都较高,适合读多写少的场景,比如监听器列表、配置缓存这类数据。如果写操作频繁,性能会急剧下降,此时应考虑用Collections.synchronizedList配合手动加锁,或者改用ConcurrentLinkedQueue等更适合高并发写的结构。
对于Map,JDK 7提供了ConcurrentHashMap替代Hashtable。ConcurrentHashMap的迭代器是弱一致性(weakly consistent)的,不抛异常,但遍历结果可能不包含遍历期间新写入的数据,使用时要理解这种语义差异,不要把它当成强一致快照来用。
方案对比与选择建议
把上述方案整理成表格方便对比:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 迭代器remove() | 单线程边遍历边删 | 官方推荐,性能好 | 代码稍显繁琐 |
| removeIf() | JDK 8及以上 | 简洁易读 | 低版本JDK不可用 |
| 倒序下标遍历 | ArrayList单线程 | 无需迭代器 | 只适合随机访问列表 |
| CopyOnWriteArrayList | 多线程读多写少 | 读无锁,不抛异常 | 写操作复制数组开销大 |
| ConcurrentHashMap | 多线程Map操作 | 高并发性能好 | 迭代器弱一致性 |
选择时可以遵循这样的思路:先确认是否真的需要在遍历中修改,很多情况下重构为“收集后统一处理”能让逻辑更清晰;单线程场景优先用removeIf()或迭代器remove();多线程场景根据读写比例选择合适的并发容器。理解了modCount与expectedModCount的检查机制,再遇到这个异常就能迅速定位问题本质,而不是盲目地try-catch吞掉异常,掩盖真正的逻辑缺陷。
ConcurrentModificationException并发修改异常Java集合修改时间:2026-09-06 16:32:39