
在 Java 集合框架中,ArrayList 几乎是每个开发者都会频繁接触的数据结构。它基于动态数组实现,按下标读取元素的效率高,插入和删除在尾部时也比较直接,因此在大量业务代码中都能看到它的身影。不过,ArrayList 虽然常见,真正用好却并不总是那么简单。尤其是在“遍历集合的同时删除元素”这个问题上,很多人第一次接触时都会踩坑:代码看起来没有问题,运行时却抛出 ConcurrentModificationException;或者程序没有报错,但结果少删、漏删、顺序错乱,最终埋下逻辑漏洞。 这个问题之所以经典,是因为它同时涉及语法层面的写法差异、集合底层实现方式、迭代器工作原理以及工程实践中的代码可维护性。很多人会把它简单理解为“ArrayList 不能一边遍历一边删除”,但这句话其实并不准确。更精确的说法应该是:ArrayList 在遍历过程中能否安全删除元素,取决于你采用的遍历方式和删除方式是否匹配。如果方法选错了,程序就会触发 fail-fast 机制,或者在没有异常提示的情况下产生错误结果。 先从最常见的错误场景说起。很多开发者在业务代码里会写出类似下面的逻辑:遍历一个用户列表、订单列表或任务列表,当发现当前元素不再满足某个条件时,就调用 list.remove(element) 把它删掉。如果这段代码写在 foreach 循环中,看上去非常自然,甚至语义上也没有明显问题。但运行起来后,程序往往会在遍历过程中直接抛出 ConcurrentModificationException。这类异常名字里虽然有 concurrent,但它并不一定意味着多线程并发,而更常见的意思是:集合在迭代期间被以不被允许的方式修改了。 要理解这一点,必须先理解 foreach 的本质。很多人把 foreach 当成一种特殊的循环语法,认为它只是语法糖,写法简洁而已。实际上,foreach 确实是语法糖,但它在底层通常会被编译成基于 Iterator 的迭代过程。也就是说,当你写下 for (String item : list) 时,底层实际上会创建一个迭代器,并在每次循环时调用 hasNext() 与 next() 取得元素。这个迭代器不仅负责移动指针,还会维护一些状态信息,用来确保遍历过程中的集合结构是稳定的。 在 ArrayList 中,有一个和结构修改相关的重要概念,通常可以理解为“修改计数”。每当集合发生结构性变化,比如新增元素、删除元素、清空集合,这个计数就会增加。与此同时,迭代器在创建时也会记录一个自己认为“当前应该对应的修改计数”。如果迭代过程中,集合通过迭代器自身允许的方法之外的方式被改动,那么集合内部的真实修改计数就会变化,而迭代器手中的预期值却不会自动同步。这样一来,当下一次继续遍历时,迭代器就会发现“预期状态”和“真实状态”不一致,于是抛出 ConcurrentModificationException,阻止程序继续在不可靠的结构上运行。这就是 fail-fast 机制的核心思想。 从设计角度看,这其实是一种保护。因为一旦在遍历过程中随意修改底层数组,后续元素的下标、顺序和边界都可能发生变化。如果框架不立刻报错,而是任由程序继续执行,开发者得到的可能只是一个悄悄错误的结果。相比之下,立即失败虽然让人烦,但至少能把问题暴露出来。因此,看到 ConcurrentModificationException 时,不应简单把它理解成“Java 太严格”,而要意识到集合框架是在帮你尽早发现不安全操作。 既然问题出在遍历与删除方式不匹配,那么真正要解决的就不是“如何绕开异常”,而是“如何选择正确的删除策略”。第一种也是最容易想到的方案,是使用普通 for 循环,通过索引访问元素。因为普通 for 不依赖隐藏的迭代器,所以不会直接触发迭代器状态不一致的问题。比如从头到尾遍历,当发现某个元素满足删除条件时,调用 remove(i) 删除当前位置元素。需要特别注意的是,删除后后续元素会整体前移,所以如果下一轮仍然执行 i++,就可能跳过刚刚移动到当前位置的新元素。因此,最常见的处理方式是在删除后执行 i--,或者反向遍历集合,从最后一个元素开始往前删,这样就不会影响尚未处理的元素位置。 普通 for 循环的优点很明显:思路直接,执行过程容易可视化,调试时也方便观察索引变化。对于需要同时依赖索引和值的场景,比如“删除第 n 个之后的连续无效元素”,这种方式尤其合适。不过它也有缺点。首先,代码比 foreach 冗长;其次,开发者必须自己负责维护索引逻辑,如果忘了 i-- 或反向遍历,很容易出现漏删问题。也就是说,这种方式很灵活,但也对编码者的细致程度要求更高。 第二种更稳妥、更符合集合框架设计意图的方案,是显式使用 Iterator,并在遍历过程中调用 iterator.remove()。这是 Java 官方为“边遍历边删除”提供的标准手段。其关键点在于:删除动作不是直接交给集合本身,而是交给正在遍历的那个迭代器来完成。因为迭代器知道当前遍历位置,也能在删除后同步更新自己的内部状态,所以它不会像 list.remove() 那样破坏预期修改计数。换句话说,iterator.remove() 不只是“也能删除”,它实际上是“能在当前语境下合法删除”。 在工程实践里,如果业务代码只是简单地“遍历并按条件删除部分元素”,Iterator.remove 往往是最值得优先考虑的方案。它的语义清晰,阅读者一眼就能看出这是一个安全的遍历删除过程。相比普通 for,它少了下标维护的负担;相比 removeIf,它更适合需要在遍历中顺便做一些额外逻辑处理的场景,比如记录日志、统计删除数量、执行复杂判断等。因此,在强调稳定性和明确语义的团队里,Iterator.remove 通常是最经典也最可靠的做法。 第三种方案,是使用 Java 8 之后提供的函数式风格方法,比如 removeIf() 或 Stream 过滤。removeIf 的使用体验非常好:只需要给出一个条件表达式,就能让集合内部帮你把满足条件的元素删掉。例如删除所有状态为失效的对象,只需要一行条件判断即可。这样的代码不仅简洁,而且更贴近业务语义,读起来也很自然。对于“根据某个明确规则删除一批元素”这种场景,它通常比手写循环更加优雅。 如果需求不是“在原集合上删”,而是“从原集合中过滤出想保留的结果”,那么 Stream 的 filter 也非常合适。它的思路不是边遍历边改原集合,而是根据条件构造一个新的结果集合。这种方式的优点在于副作用更少,表达式更清晰,适合强调不可变风格或链式数据处理的代码体系。不过它也有成本:一是会创建新集合;二是对于复杂的业务逻辑,函数式写法未必总是比显式循环更容易维护。尤其当删除条件依赖外部状态,或者在遍历过程中需要执行多步副作用操作时,过度函数式化反而可能降低可读性。 第四种经常引发争议的方式,是“增强 for 中删除后立刻 break”。有些人会发现:如果集合里只删除一个元素,而且删除后立刻结束循环,某些情况下程序好像并不会抛异常。这种现象容易让人误以为“增强 for 其实也可以删”。但严格来说,这并不是一种值得推荐的通用方案。因为增强 for 的底层依然是迭代器,只是你的代码恰好在触发下一次 next() 之前结束了循环,所以没来得及触发状态检查。换句话说,这更像是侥幸成立的特殊情形,而不是语义稳定的正规写法。如果把它当作普适模式,未来稍微改动一下循环逻辑,就可能重新引爆异常。因此,除非你对代码路径和数据特征有绝对把握,否则不要依赖这种“看起来能跑”的偶然行为。 除了单线程场景,还需要考虑并发环境中的问题。有些开发者看到 ConcurrentModificationException 后,会误以为只要换成并发集合就一劳永逸。实际上,这要看业务语义。比如 CopyOnWriteArrayList 在遍历时对删除操作更宽容,因为它的核心思想是“写时复制”,遍历看到的是一个快照,修改发生在新副本上,因此不会直接影响当前迭代过程。这在读多写少的场景下很安全,也很方便。但它并非没有代价:每次写操作都要复制底层数组,成本较高,数据量大或写频繁时会明显拖慢性能。也就是说,并发集合解决的是并发访问语义问题,并不意味着它天然适合所有“边遍历边删除”的业务。 再往深一层看,是否选择 ArrayList 本身也值得思考。如果你的业务场景中删除操作非常频繁,而且往往发生在中间位置,那么 ArrayList 也许就不是最优选择。因为它的底层是数组,每次中间删除都意味着元素搬移。对于这类场景,LinkedList 或其他更适合频繁插删的数据结构可能更合适。当然,LinkedList 也有随机访问效率低的问题,因此不能简单说谁更好,只能说要根据实际访问模式选择结构。如果一个集合既需要高频删除、又需要高频随机读,那甚至可能要重新设计数据模型,而不是只在遍历写法上修修补补。 在真实业务中,这个问题还有一个经常被忽略的维度:可维护性。很多代码第一次写出来时能跑,但半年后别人接手时,可能根本看不懂为什么这里要 i--,为什么那里必须反向遍历,为什么另一个地方又用了 iterator.remove。对于团队协作来说,最优方案不一定是“最短”或“最快”的那个,而往往是“最容易让后来人理解”的那个。如果一段删除逻辑本身并不复杂,使用 removeIf 明确表达意图,通常会比手写复杂索引操作更友好;如果逻辑较复杂,显式 Iterator 可能会比链式 Stream 更容易维护。工程实践里,代码的生命周期远比一次运行结果更重要。 另外,很多人讨论“边遍历边删除”时,只盯着语法和异常,其实还应该关注测试策略。因为某些错误写法并不一定每次都抛异常,有时只是 quietly wrong。比如索引删除时忘记回退,程序不会报错,但会漏删一部分元素;再比如业务条件恰好让错误路径没有被覆盖,测试时看似正常,线上换一组数据就出问题。因此,对于涉及集合删除的逻辑,测试不应只验证“代码没报错”,还应验证“删除结果是否完整正确”“集合剩余顺序是否符合预期”“边界条件下是否存在空集合、单元素集合、多连续命中元素等特殊情况”。 从实践经验来看,如果只是面试或教学,通常会把答案总结成几条:不要在 foreach 中直接 remove;需要时用 Iterator.remove、普通 for 或 removeIf。但真正到了生产环境,这个问题并不只是背结论那么简单。你需要知道为什么 foreach 会失败,为什么 Iterator.remove 可以成功,为什么索引删除会漏删,为什么 removeIf 适合批量规则删除,为什么并发集合不能随便替代普通集合。只有把这些原理连起来理解,才能在不同业务场景下做出正确选择,而不是依赖记忆中的几个“技巧”。 总结一下,遍历 ArrayList 时删除元素之所以容易出错,本质上是因为遍历器状态和集合结构变化之间存在强约束。错误的删除方式会破坏这种约束,从而触发 fail-fast,或者在无异常情况下得到错误结果。安全的做法通常有三类:其一,使用普通 for 循环并正确处理索引变化;其二,使用 Iterator.remove 完成与当前迭代器同步的删除;其三,使用 removeIf 或 Stream 进行更声明式的数据过滤。至于直接在 foreach 中调用集合的 remove,虽然写起来最顺手,却恰恰是最不稳定的方式。理解并掌握这些差异,不只是为了解决一个面试题,更是为了在真实工程中写出既正确、又稳定、还便于维护的集合处理代码。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。