恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Java循环中修改List长度:从ConcurrentModificationException到安全删除方案
首页
资讯中心
/
Java循环中修改List长度:从ConcurrentModificationException到安全删除方案
Java循环中修改List长度:从ConcurrentModificationException到安全删除方案
发布时间:2026/10/21 8:38:31
写Java写了一段时间的人基本都遇到过这么个事在for循环里遍历一个List想顺手把不符合条件的元素删掉结果一运行直接抛ConcurrentModificationException查了半天不知道哪里并发修改了。还有人更狠在循环里直接list.add()运气好没报错但数据错得一塌糊涂运气差直接死循环CPU拉满整个服务卡死。数组和集合长度能不能改这个问题看起来基础实际上坑特别多。很多初学者以为“循环里不能改”是个死规矩但问深一点——为什么不能改Iterator为什么可以删CopyOnWriteArrayList为什么可以在循环里随便增删普通for循环和foreach到底差在哪能回答清楚的人真不多。这篇文章就把这件事彻底聊透从底层机制到实测表现再到日常开发里最稳妥的处理方案一次性讲清楚。1. 从一个很日常的报错说起1.1 为什么突然想聊这个话题先还原一个特别典型的场景。假设有个订单列表需要把状态为“已取消”的订单全部移除。刚写代码的朋友很容易这么干ListOrder orderList getOrders(); for (Order order : orderList) { if (order.isCancelled()) { orderList.remove(order); } }这段代码一跑十有八九抛异常。更奇怪的是有时候不抛异常但删除的结果不对——删了一个元素之后后面有个元素没被遍历到漏掉了。这两种情况分别对应两种不同的循环机制具体细节后面讲。先记住一个结论只要在遍历过程中修改了集合的结构add、remove、clear这种改变元素个数或顺序的操作要么抛异常要么结果不符合预期总之不能随便写。1.2 先给结论数组不能改集合看情况数组的情况最简单数组长度从创建那一刻就固定了没法改也没提供任何方法来改。你唯一能做的是改某个下标位置上的元素值但数组本身的长度永远不变。所以“循环中修改数组长度”这件事严格意义上不存在编译都不允许。所有关于“修改长度”的讨论核心都集中在集合上。集合的情况要分场景普通for循环遍历时增删元素是允许的但可能导致漏遍历或越界foreach增强for循环遍历时删除元素会抛ConcurrentModificationExceptionIterator遍历时调用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经典ConcurrentModificationExceptionforeach是语法糖编译后实际上用的是Iterator。对于ArrayListforeach遍历时会拿一个Iterator实例每次next()都会检查“期望的修改次数”和“实际的修改次数”是否一致。如果不一致直接抛ConcurrentModificationException。下面这段代码几乎每个人初学Java时都跑过一次ListString 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让迭代器知道“这个修改是我自己做的”所以不会误判为并发修改。ListString list new ArrayList(Arrays.asList(A, B, C, D)); IteratorString 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所以也是安全的。ListString list new ArrayList(Arrays.asList(A, B, C)); ListIteratorString 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呢试试这个ListInteger list new ArrayList(Arrays.asList(1, 2, 3, 4, 5)); for (int i 0; i list.size(); i) { list.add(i, 0); // 每次在开头插入0size不断变大 }这段代码不是报错而是死循环。因为i在增长size()也在增长永远到不了i list.size()为假的那一步。这种问题排查起来更隐蔽因为没有异常只有CPU飙升。还有另一种情况循环里add但控制不好边界可能把想要的数据重复加很多次或者导致IndexOutOfBoundsException。比如for (int i 0; i 10; i) { list.add(list.size() - 100, i); // size不够大越界 }4. 异常背后的机制快速失败与modCount4.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也不会感知到后续修改。CopyOnWriteArrayListString 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()。它适合在遍历过程中“删除满足条件的元素”不需要知道下标也不需要反向遍历。ListString list new ArrayList(Arrays.asList(A, B, C)); IteratorString it list.iterator(); while (it.hasNext()) { if (it.next().equals(B)) { it.remove(); } }这套代码可以放心用在ArrayList、LinkedList、HashSet等大部分非并发集合上。HashSet也可以用迭代器删除但它的遍历顺序不保证和插入顺序一致。5.2 先收集后处理两阶段删除有时候你不仅想删还想根据元素做更多操作或者需要判断“这个元素删了之后后面某个元素要不要一起删”而这种关联关系在遍历时不好处理。这时可以先把要删除的元素收集到一个临时集合里遍历结束之后再统一删除ListString toRemove new ArrayList(); for (String item : list) { if (item.startsWith(A)) { toRemove.add(item); } } list.removeAll(toRemove);这种方式的好处是不修改正在遍历的集合绝对安全。缺点是会多一份临时集合如果数据量特别大内存占用会多一点。还有一种类似思路把不需要删除的元素收集到新list里最后直接替换原list相当于“筛选重建”。5.3 使用removeIf/PredicateJava 8 的优雅写法如果你的需求就是“按条件删除”Collection.removeIf()是最省事的写法list.removeIf(item - item.startsWith(A));removeIf的底层实现里使用了一个内部迭代器并且所有删除动作都发生在迭代器维护的模式下所以不会抛ConcurrentModificationException。从代码可读性角度来说这种写法也最清晰别人一眼就知道你在做“条件过滤删除”。5.4 Stream filter collect不修改原集合如果不想影响原集合或者希望把筛选结果作为新数据传给下游那么用Stream最合适ListString filtered list.stream() .filter(item - !item.startsWith(A)) .collect(Collectors.toList());注意这种方式是创建一个新列表原来的list保持不变。如果你希望后续所有操作都使用过滤后的结果直接把filtered作为后续数据源就行不需要再把结果拷回原list。除非业务上明确要求原list必须改变否则我更推荐这种不可变风格的处理。5.5 使用普通的for循环反向遍历普通for循环里删除元素不报错但正序遍历会漏数据。举个例子ListInteger 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 症状与原因速查表症状可能原因解决方案ConcurrentModificationExceptionforeach遍历时直接调用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就够用如果会被共享先考虑加锁或改用并发集合。理清了这个循环中修改集合长度就不再是玄学而是一套非常清晰的决策流程。