写Java写了一段时间的人,基本都遇到过这么个事:在for循环里遍历一个List,想顺手把不符合条件的元素删掉,结果一运行直接抛ConcurrentModificationException,查了半天不知道哪里并发修改了。还有人更狠,在循环里直接list.add(),运气好没报错,但数据错得一塌糊涂;运气差直接死循环,CPU拉满,整个服务卡死。
数组和集合长度能不能改,这个问题看起来基础,实际上坑特别多。很多初学者以为“循环里不能改”是个死规矩,但问深一点——为什么不能改?Iterator为什么可以删?CopyOnWriteArrayList为什么可以在循环里随便增删?普通for循环和foreach到底差在哪?能回答清楚的人真不多。这篇文章就把这件事彻底聊透,从底层机制到实测表现,再到日常开发里最稳妥的处理方案,一次性讲清楚。
1. 从一个很日常的报错说起
1.1 为什么突然想聊这个话题
先还原一个特别典型的场景。假设有个订单列表,需要把状态为“已取消”的订单全部移除。刚写代码的朋友很容易这么干:
List<Order> orderList = getOrders(); for (Order order : orderList) { if (order.isCancelled()) { orderList.remove(order); } }这段代码一跑,十有八九抛异常。更奇怪的是,有时候不抛异常,但删除的结果不对——删了一个元素之后,后面有个元素没被遍历到,漏掉了。这两种情况分别对应两种不同的循环机制,具体细节后面讲。先记住一个结论:只要在遍历过程中修改了集合的结构(add、remove、clear这种改变元素个数或顺序的操作),要么抛异常,要么结果不符合预期,总之不能随便写。
1.2 先给结论:数组不能改,集合看情况
数组的情况最简单:数组长度从创建那一刻就固定了,没法改,也没提供任何方法来改。你唯一能做的是改某个下标位置上的元素值,但数组本身的长度永远不变。所以“循环中修改数组长度”这件事,严格意义上不存在,编译都不允许。所有关于“修改长度”的讨论,核心都集中在集合上。
集合的情况要分场景:
- 普通
for循环遍历时,增删元素是允许的,但可能导致漏遍历或越界; foreach(增强for循环)遍历时,删除元素会抛ConcurrentModificationException;Iterator遍历时,调用iterator.remove()是安全的;ListIterator遍历时,add()和set()是安全的,remove()也是安全的;- 使用
CopyOnWriteArrayList时,任何遍历方式下增删都不会抛异常。
这么多情况,背后的原因各不相同。如果把原理捋顺了,就再也不用死记这些结论了。
2. 先把关键概念捋清楚:数组长度不可变,集合长度可变
2.1 数组的本质:固定长度连续内存
数组在Java里是最底层的容器,它在内存里是一块连续的区域。创建new int[10]的时候,JVM一次性分配能装下10个int的连续空间,并用一个length属性记录这个容量。这块区域的大小在分配之后不可改变,因为它的前后内存可能已经被其他数据占用,不可能凭空扩大或缩小。
所以数组的设计哲学是:固定容量,靠下标访问。你要是想“变长”,只能新建一个更大的数组,然后把旧数据拷贝过去。比如ArrayList的扩容,底层干的就是这件事。但那是集合的行为,不是数组本身。
2.2 集合的动态扩容是怎么回事
以ArrayList为例,它内部其实维护着一个Object[] elementData数组和一个int size。你看到的list.size()不是数组长度,而是当前实际存储的元素个数。当元素个数超过数组容量时,ArrayList会新创建一个更大的数组(通常是原来的1.5倍),再把旧数组的元素拷贝进去,最后替换引用。
换句话说,集合的长度是逻辑上的“元素个数”,它和底层数组的物理长度是两回事。这也解释了为什么“集合长度”可以动态变化——因为它压根不是数组那个固定的length,而是一个不断更新的size计数。
2.3 为什么循环中“修改长度”会出问题
循环的本质是“按顺序访问容器里的每个元素”。如果每次循环还要去判断“当前有多少个元素”,而这个数量还在不断变化,那“遍历”这件事就失去了确定性。你想访问第5个元素,结果删掉了第3个,原来的第5个变成了第4个,后面的元素全往前挪了一位。程序不知道这些变化,还是按老计划走,自然就错乱了。
从数据结构角度来说,这有点像在一列排队的人里,边点名边让人出列。队伍一直在变短,点到后面要么点错人,要么队伍空了还在点。任何语言都很难容忍这种操作,Java只是通过异常机制把你拦住了。
3. 各种循环姿势下的实测表现
3.1 普通for循环 + 数组:越界和漏数据
数组不能增删,但普通for循环访问数组时,如果你试图“模拟删除”,比如把后面的元素往前覆盖,然后自己维护一个逻辑长度,那是完全可行的。但要注意,数组的length属性不会变,你只是不再访问末尾那几个旧元素而已。
如果非要在循环里改数组“长度”,标准做法是重建数组:
int[] arr = {1, 2, 3, 4, 5}; // 删除值为 3 的元素 int[] newArr = new int[arr.length - 1]; int index = 0; for (int i = 0; i < arr.length; i++) { if (arr[i] != 3) { newArr[index++] = arr[i]; } }这样确实“变短了”,但本质是新建数组,不是原地修改。
如果脑子一抽,直接在普通for循环里尝试“逻辑删除”却不小心把索引写错,最常见的问题就是ArrayIndexOutOfBoundsException。比如你想把每个元素都往后挪再覆盖,但没控制好结束条件。数组不像集合,它没有迭代器保护,所以它不会抛ConcurrentModificationException,只会傻傻地按你的下标越界或跳过数据。
3.2 for-each + ArrayList:经典ConcurrentModificationException
foreach是语法糖,编译后实际上用的是Iterator。对于ArrayList,foreach遍历时会拿一个Iterator实例,每次next()都会检查“期望的修改次数”和“实际的修改次数”是否一致。如果不一致,直接抛ConcurrentModificationException。
下面这段代码,几乎每个人初学Java时都跑过一次:
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D")); for (String item : list) { if (item.equals("B")) { list.remove(item); // 这里抛异常 } }有人会问:为什么删除"A",也就是第一个元素的时候,有时候反而不报错?这个现象我后面单独讲。先记住:foreach里直接修改集合结构是不被允许的。
3.3 Iterator.remove():唯一安全的删除姿势之一
为什么Iterator.remove()安全?因为remove()会同步更新迭代器内部记录的modCount,让迭代器知道“这个修改是我自己做的”,所以不会误判为并发修改。
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D")); Iterator<String> it = list.iterator(); while (it.hasNext()) { String item = it.next(); if (item.equals("B")) { it.remove(); } }注意:Iterator接口没有add()方法,只有remove()。所以用普通Iterator遍历时,只能删不能加。
3.4 ListIterator增删:双向遍历的利器
ListIterator是Iterator的子接口,专门给List用,支持双向遍历,并且额外提供了add()方法。在ListIterator遍历过程中,调用add()和remove()都会同步更新modCount,所以也是安全的。
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C")); ListIterator<String> it = list.listIterator(); while (it.hasNext()) { String item = it.next(); if (item.equals("B")) { it.add("B2"); // 安全 it.remove(); // 注意:add 和 remove 别同时用,这里只是演示 } }有一点要注意:add()之后,紧接着调用remove()会抛IllegalStateException,因为调用add()后迭代器的状态变了,必须重新next()或previous()之后才能再remove()。这是另一个常见的坑,后文会单独列出来。
3.5 循环里add元素:扩容和死循环风险
很多人不敢在foreach里删除,但觉得“add应该没事吧”。实际试一下就知道了,同样会抛ConcurrentModificationException,原理是一样的——add也修改了modCount。
那普通for循环里add呢?试试这个:
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5)); for (int i = 0; i < list.size(); i++) { list.add(i, 0); // 每次在开头插入0,size不断变大 }这段代码不是报错,而是死循环。因为i在增长,size()也在增长,永远到不了i < list.size()为假的那一步。这种问题排查起来更隐蔽,因为没有异常,只有CPU飙升。
还有另一种情况:循环里add但控制不好边界,可能把想要的数据重复加很多次,或者导致IndexOutOfBoundsException。比如:
for (int i = 0; i < 10; i++) { list.add(list.size() - 100, i); // size不够大,越界 }4. 异常背后的机制:快速失败与modCount
4.1 modCount是什么
modCount是AbstractList里的一个字段,中文一般叫“修改次数”。每当集合的结构发生变化(增、删、清空等),modCount就加1。注意,set()替换某个位置的元素不算结构修改,因为它不改变元素个数,也不改变顺序。
Iterator在创建时会记录当时的modCount,保存为expectedModCount。每次调用next()时,都会检查:
if (modCount != expectedModCount) { throw new ConcurrentModificationException(); }如果别的代码(或者自己在foreach里)改了集合结构,modCount变了,而迭代器自己没来得及更新expectedModCount,就会触发异常。Iterator.remove()之所以安全,是因为它在内部先调用了ArrayList.this.remove(lastRet),然后立刻把最新modCount同步给expectedModCount。
4.2 快速失败机制的意义
Java集合框架中很多类都采用了**fail-fast(快速失败)**机制。它的核心思想是:与其在遍历到一半时产生不可预知的数据错乱,不如尽早抛出异常,让程序员马上知道代码有问题。
这个机制牺牲了一定的灵活性,换来了更高的安全性。就好比一个团队开会,如果会议纪要的版本号和实际讨论内容不一致,快速失败就是在你说到第3条的时候立刻打断你,告诉你“纪要已经过时了”,而不是让你把整场会议都说完,回头发现全是错的。
理解了modCount,很多现象就说得通了:
- 为什么删除倒数第二个元素有时候不报错?因为
next()检查的时候刚好还没触发,最后一次hasNext()返回false,循环退出,压根没做检查。这是运气成分,不能依赖。 - 为什么
CopyOnWriteArrayList可以边遍历边改?因为它压根不用modCount做一致性检查。
4.3 CopyOnWriteArrayList:弱一致性迭代器
CopyOnWriteArrayList是java.util.concurrent包下的线程安全集合,它的迭代器有一个特点:迭代器在创建时引用的是当时那个数组的快照,之后无论集合怎么增删,迭代器遍历的都是旧快照。所以不会抛ConcurrentModificationException,也不会感知到后续修改。
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>(Arrays.asList("A", "B", "C")); for (String item : list) { if (item.equals("B")) { list.remove(item); // 不报错,但遍历结果仍包含 B } }运行之后,遍历打印出来是A B C,但最终list里只剩A C。因为foreach用的是迭代器,迭代器看的是快照。这种设计叫“弱一致性”,适合读多写少、可以接受短暂延迟的并发场景。
不过CopyOnWriteArrayList写操作很重,每次修改都会复制整个底层数组,如果集合很大又频繁增删,性能和内存消耗都很不划算。日常开发中别为了“能在循环里改”而滥用它。
5. 正确修改集合长度的几种方案
5.1 使用Iterator.remove():最经典的方式
最推荐且最简单的就是前面演示过的Iterator.remove()。它适合在遍历过程中“删除满足条件的元素”,不需要知道下标,也不需要反向遍历。
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C")); Iterator<String> it = list.iterator(); while (it.hasNext()) { if (it.next().equals("B")) { it.remove(); } }这套代码可以放心用在ArrayList、LinkedList、HashSet等大部分非并发集合上。HashSet也可以用迭代器删除,但它的遍历顺序不保证和插入顺序一致。
5.2 先收集后处理:两阶段删除
有时候你不仅想删,还想根据元素做更多操作,或者需要判断“这个元素删了之后,后面某个元素要不要一起删”,而这种关联关系在遍历时不好处理。这时可以先把要删除的元素收集到一个临时集合里,遍历结束之后再统一删除:
List<String> toRemove = new ArrayList<>(); for (String item : list) { if (item.startsWith("A")) { toRemove.add(item); } } list.removeAll(toRemove);这种方式的好处是不修改正在遍历的集合,绝对安全。缺点是会多一份临时集合,如果数据量特别大,内存占用会多一点。还有一种类似思路:把不需要删除的元素收集到新list里,最后直接替换原list,相当于“筛选重建”。
5.3 使用removeIf/Predicate:Java 8+ 的优雅写法
如果你的需求就是“按条件删除”,Collection.removeIf()是最省事的写法:
list.removeIf(item -> item.startsWith("A"));removeIf的底层实现里,使用了一个内部迭代器,并且所有删除动作都发生在迭代器维护的模式下,所以不会抛ConcurrentModificationException。从代码可读性角度来说,这种写法也最清晰,别人一眼就知道你在做“条件过滤删除”。
5.4 Stream + filter + collect:不修改原集合
如果不想影响原集合,或者希望把筛选结果作为新数据传给下游,那么用Stream最合适:
List<String> filtered = list.stream() .filter(item -> !item.startsWith("A")) .collect(Collectors.toList());注意,这种方式是创建一个新列表,原来的list保持不变。如果你希望后续所有操作都使用过滤后的结果,直接把filtered作为后续数据源就行,不需要再把结果拷回原list。除非业务上明确要求原list必须改变,否则我更推荐这种不可变风格的处理。
5.5 使用普通的for循环反向遍历
普通for循环里,删除元素不报错,但正序遍历会漏数据。举个例子:
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5)); for (int i = 0; i < list.size(); i++) { if (list.get(i) % 2 == 0) { list.remove(i); // 删掉之后,后面的元素往前移,i++会跳过下一个元素 } }删除索引2的元素后,原来索引3的元素变成了索引2,但循环里的i已经变成3了,所以这个元素没被检查。这就是漏数据。解决方式是从后往前遍历,也就是反向删除:
for (int i = list.size() - 1; i >= 0; i--) { if (list.get(i) % 2 == 0) { list.remove(i); } }这样删除的元素都在当前索引之后,不影响前面还没检查的元素。虽然写法稍微“土”一点,但在某些场景下很实用,比如你同时需要访问前一个元素和后一个元素做联动判断时,反向遍历配合get(i-1)往往比迭代器更顺手。
6. 实际开发中我踩过的坑和排查技巧
6.1 症状与原因速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
ConcurrentModificationException | foreach遍历时直接调用list.remove()/list.add() | 改用Iterator.remove(),或先用临时集合收集再统一处理 |
IndexOutOfBoundsException | 普通for循环中i越过list.size(),或访问了不存在的下标 | 检查循环条件,注意删除元素后索引和size同步变化 |
| 死循环,CPU飙高 | 普通for循环条件里list.size()不断变大,且添加速度不小于索引增长速度 | 明确循环边界,不要在条件里依赖动态变化的size() |
| 遍历结果少了一个元素 | 正序遍历时删除元素后索引未回退,后一个元素被跳过 | 删除后执行i--,或改用反向遍历 |
IllegalStateException | 使用Iterator/ListIterator时连续调用remove(),或add()后立刻remove() | 确保每次remove()前有next()/previous() |
| 数据结果看似正常,但更新丢失 | 使用CopyOnWriteArrayList时,迭代器遍历的是快照,修改不影响遍历过程 | 理解弱一致性语义,不要依赖遍历过程中的实时修改 |
6.2 排查定位方法
遇到这类问题,第一步不是看业务逻辑,而是看异常栈。ConcurrentModificationException的栈信息往往会指向你的foreach代码行,但你要知道,真正的原因可能不是那一行,而是在任何并发线程里,同一个list被另一个线程修改了。如果只有一个线程在跑,那多半就是foreach里直接调用list.remove()。
定位代码时,推荐一个技巧:在代码里临时打印list.size()和modCount(如果可能)的变化,确认是否在遍历过程中发生了变化。不过modCount不是公开字段,实际开发中更实用的做法是把循环改成迭代器,再逐步替换条件,看异常是否消失。
如果怀疑是多线程环境,优先排查有没有共享同一个List实例在多个线程中读写。比如一个线程在遍历,另一个线程在往list里加数据,即使你遍历时什么都没做,也会抛ConcurrentModificationException。
6.3 一些经验心得
我在实际开发中有一个很深的体会:循环中修改集合长度,本质上不是“Java不让”,而是“数据一致性不允许”。与其纠结哪种写法能绕过异常,不如先想清楚业务上到底需要什么样的遍历结果。你是想边遍历边删除,还是想基于旧数据生成一个新集合?这两种思路对应完全不同的代码风格。
如果数据量不大,比如几千条以内,用Stream或临时集合是最省心的。如果数据量很大,比如几十万条,Stream和removeIf的性能其实都不差,removeIf底层对ArrayList做了优化,删除多个元素时会尽量合并移动,减少数组拷贝次数。反过来,如果每条数据都有复杂的删除条件,而且删除操作之间有依赖,那用ListIterator,边遍历边动态调整,反而更灵活。
还有一个我踩了好几次的坑:不要在循环里用list.size()作为循环边界同时还去增删元素。有时候你以为循环只跑几次,结果size()一直在变,边界失控。最好的做法是先把边界值存到一个局部变量里:
int size = list.size(); for (int i = 0; i < size; i++) { // 就算循环里增删,也不影响循环次数 }但注意,存了size之后如果循环里删除元素,访问靠后的索引会越界。所以这种方式适合“只添加不删除”或者“前部分处理,后部分保持不动”的场景。总体来说,“循环边界独立于集合大小”是个好习惯。
最后再分享一个调试小技巧:当你在foreach里遇到ConcurrentModificationException时,先别急着改成removeIf,先打印一下异常栈的完整信息。有时候你会发现是某个工具类、某个框架的拦截器在悄悄修改list。我遇到过好几次,表面上是业务代码的问题,实际是异步回调里动了同一个list。这种问题如果不看完整栈,光改业务循环根本没用。
个人经验是,处理集合遍历中的增删问题时,优先问自己一个问题:“这个集合是只属于当前线程,还是会被共享?”如果只属于当前线程,普通的Iterator.remove()和removeIf就够用;如果会被共享,先考虑加锁或改用并发集合。理清了这个,循环中修改集合长度就不再是玄学,而是一套非常清晰的决策流程。