☰
Java List删除元素避坑指南:Iterator原理与removeIf最佳实践
2026/10/15 16:52:12 网站建设 项目流程

1. 先聊聊这个“基础操作”为什么总有人翻车

List集合删除元素,听起来就是基础中的基础,任何一个写过 Java 的人可能都觉得闭着眼睛都能写。但实际情况是,这个话题在技术社区里隔三差五就会冒出来一次,而且每次都能炸出一堆“我当年也踩过这个坑”的回复。尤其是Iterator 删除 List 元素这个具体操作,很多人只知道“能删”,但说不清“为什么必须这么删”,更说不清“其他方式为什么不行”。

我最初碰这个问题是在一次线上数据清洗任务里。当时有一个大 ArrayList,要按条件筛掉约三分之一的数据,我先用了最直觉的写法——普通 for 循环加list.remove(),结果没报错,但数据漏删了;换成增强 for 循环,直接抛ConcurrentModificationException;再换成Iterator,一切都正常了。事后复盘,我才意识到这三个写法的差异其实对应着集合内部机制的几个核心问题:迭代器模式、fail-fast 机制、内部索引维护。

这篇文章我打算从这三个“翻车现场”入手,把底层原理拆开,再把正确写法、进阶方案、性能对比和排查技巧全部过一遍。适合刚接触集合框架的初学者,也适合写了两三年 CRUD 但没细看过 ArrayList 源码的开发者。毕竟这东西不是背个结论就完事的,你得知道“为什么”才敢在真实场景里放心用。

2. 三个典型的“翻车现场”,看看你中过几个

2.1 普通 for 循环直接 remove:不报错,但漏删

很多人第一个想到的写法是这样:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "b", "d")); for (int i = 0; i < list.size(); i++) { if ("b".equals(list.get(i))) { list.remove(i); } } System.out.println(list);

你觉得结果是什么?有人会猜[a, c, d],但实际输出是[a, c, b, d]——第二个"b"没有删掉。

原因其实很简单:当你执行list.remove(i)时,被删元素后面的所有元素都会往前移动一位,索引i指向的是原来的i+1位置的元素。紧接着i++又让索引跳过了它。相当于删一个、漏一个,这种问题在数据量大的时候非常隐蔽,因为不会抛异常,结果就是静默出错。

这里还有一个容易忽略的细节:如果集合里存在连续两个要删除的元素,漏删的概率更高。比如["b", "b"],删掉第一个后第二个补位到索引 0,但循环已经进入i=1了,直接漏掉。我在实际业务中见过最惨的一次就是这种漏删导致脏数据悄悄进了下游系统,排查了半天才发现是这里的问题。

2.2 增强 for 循环里 remove:一定会抛异常

紧接着有人会想,那我用增强 for 循环总行吧,反正不用自己管索引:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); for (String s : list) { if ("b".equals(s)) { list.remove(s); } }

运行一下,十有八九会见到这个老朋友:

java.util.ConcurrentModificationException

这个异常就是fail-fast(快速失败)机制的典型表现。简单说,ArrayList 内部维护了一个modCount字段,记录结构被修改的次数——添加、删除都会让它加一。而增强 for 循环本质上是使用迭代器遍历,迭代器内部也缓存了一个expectedModCount。每次取下一个元素时,迭代器会检查modCount是否等于expectedModCount,不等于就立刻抛异常。

所以list.remove(s)这个操作改变了modCount,但迭代器自己的expectedModCount没变,下一次循环一校验就炸了。

2.3 普通 for 循环倒着删:能用,但不算最优解

还有一种常见做法是倒序遍历:

for (int i = list.size() - 1; i >= 0; i--) { if ("b".equals(list.get(i))) { list.remove(i); } }

这个写法确实不会漏删,因为删除时后面的元素往前补,而你已经遍历过那些位置了。它的正确性没问题,但有一个隐患:每次remove都会触发一次元素搬移,频繁删除时性能不太理想。更关键的是,如果删除条件比较复杂,倒序写法的代码可读性通常不如迭代器清晰。

我自己倒序删除用得不多,只有在明确不需要迭代器的其他功能时才会考虑。如果你能接受它的性能开销,它倒是一个安全性还不错的选择。

3. 迭代器到底做了什么,才能安全删除

3.1 最标准的删除模板

用Iterator删除的经典写法是下面这样的:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "b", "d")); Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String item = iterator.next(); if ("b".equals(item)) { iterator.remove(); } } System.out.println(list); // 输出 [a, c, d]

看起来平平无奇,但它保证了三点:遍历不遗漏、不触发ConcurrentModificationException、删除时集合结构保持一致。很多人只是背下了这个模板,并不知道它为什么有效。下面我把源码层面的逻辑拆开讲。

3.2 源码层面的真相:modCount 的对账机制

以 ArrayList 为例,它的内部迭代器是Itr类,核心字段有三个:

  • cursor:下一个要返回的元素下标
  • lastRet:最近一次返回的元素下标,初始为 -1
  • expectedModCount:构造迭代器时记录的modCount值

每次调用next()时,Itr会先执行:

checkForComodification();

这个方法就是校验modCount != expectedModCount,不相等就直接抛异常。这保证了迭代过程中如果集合结构被外部修改,迭代器能第一时间发现问题,而不是带着“过期认知”继续遍历。

remove()方法的实现则是问题的关键:

public void remove() { if (lastRet < 0) throw new IllegalStateException(); checkForComodification(); try { ArrayList.this.remove(lastRet); cursor = lastRet; lastRet = -1; expectedModCount = modCount; } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } }

注意最后一行:expectedModCount = modCount。也就是说,迭代器在调用remove()之后,主动把内部缓存的 expectedModCount 同步为最新的 modCount。这样下次next()校验时两边一致,就不会抛异常。

这也是为什么说“迭代器删除”是唯一同时在语法层面和语义层面都正确处理删除的方式——它不只是“恰好不报错”,而是设计上就允许你在遍历过程中安全删除当前元素。

3.3 为什么必须先 next 再 remove

这里有个很多人忽视的约定:必须在next()之后调用remove(),而且同一个元素只能调用一次。否则会抛IllegalStateException。

原因很简单:remove()删除的是lastRet指向的元素,也就是上一次next()返回的那个元素。如果还没调用过next(),lastRet是 -1,迭代器不知道要删谁;如果连续调用两次remove(),第二次调用时lastRet已经被重置为 -1,还是会抛异常。

用生活化的类比来说:迭代器像一条流水线,next()就是把当前工件拿出来放到操作台上,remove()是把这个工件销毁。你还没拿工件就按销毁按钮,机器当然报错。同一个工件销毁完了,再按一次按钮,同样报错。

3.4 边界情况和性能细节

迭代器删除的性能是 O(n),因为底层remove的时候,ArrayList 要把后面所有元素整体前移。你每删一次就要搬一次,最坏情况———比如从一个大集合里删掉一半元素——代价相当可观。所以如果你确定要删大量元素,可以考虑用下面第 4 节里的removeIf,它在底层做了一层过滤后再整体搬移,一次搬完,而不是删一次搬一次。

另一个值得注意的点是:iterator.remove()之后不能再用iterator.set()或者继续依赖lastRet做别的操作,因为lastRet已经重置为 -1。如果需要修改元素,可以用ListIterator的set()方法,这个我放到第 4 节讲。

4. 除了 Iterator,还有哪些能用的删除方案

4.1 removeIf:Java 8 之后的简洁之选

JDK 8 给 Collection 接口新增了默认方法removeIf:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "b", "d")); list.removeIf("b"::equals); System.out.println(list); // 输出 [a, c, d]

一行搞定,干净利落。实现原理上,它会先遍历整个集合,用BitSet记录所有满足条件的下标,然后统一做一次元素搬移。相比迭代器逐个删除,避免了反复搬移,批量删除时性能更好。

我在实际项目中,如果是 Java 8 以上的环境,且业务逻辑不复杂、不需要中途抢跑或提前终止,优先推荐removeIf。它语义清晰,可读性也远超手写循环。不过要注意一点:如果你的删除条件里需要在循环中做额外操作(比如把被删元素记入日志),那removeIf就不够灵光了,因为它的接口只表达“删还是不删”,没有给你在中间插入副作用的空间。

4.2 ListIterator:能删能改能添加

ListIterator是Iterator的增强版,专门为 List 设计,额外支持反向遍历、set()修改元素、add()添加元素。删除场景下它的用法和 Iterator 几乎一样,但多了一个好处:你可以在遍历过程中修改当前元素。

List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5)); ListIterator<Integer> it = list.listIterator(); while (it.hasNext()) { Integer num = it.next(); if (num % 2 == 0) { it.set(num * 10); } else if (num == 5) { it.remove(); } } System.out.println(list); // 输出 [1, 20, 30, 40]

注意示例里set()和remove()都必须在next()之后调用,这个约束和 Iterator 一致。实际业务中ListIterator最值的场景是“既要条件删除,又要对留下来的元素做修改”,这种组合需求其他方式都很难优雅地表达。

4.3 借助临时集合:以空间换安全

如果你用的集合类型不支持迭代器删除(比如某些第三方库实现的特定 List),或者你想保留原始数据,可以先收集满足条件的元素,最后统一 removeAll:

List<String> toRemove = new ArrayList<>(); for (String s : list) { if ("b".equals(s)) { toRemove.add(s); } } list.removeAll(toRemove);

这个方案的优点是逻辑清晰、不修改正在遍历的集合,不会触发任何并发修改异常。缺点是要额外占用一块内存,而且removeAll底层也是逐个remove或通过批量搬移实现,性能不一定最优。它最大的适用场景是:你想在遍历过程中基于原始数据做复杂判断,但又不想让删除影响遍历逻辑。

4.4 四种方案的横向对比

方案是否修改原集合是否抛异常风险性能(批量删除)可读性适用场景
普通 for + remove是不漏删需倒序较差,反复搬移一般小规模、简单场景
增强 for + remove是必抛异常不可用差不适用
Iterator.remove是低(正确使用)单次删除 O(n),批量较差较好遍历中删除,简单条件
removeIf是低批量删除最佳最好Java 8+,条件简单清晰
临时集合 + removeAll否→是低中等较好需要保留原数据做判断

注意,这张表里“性能较差”是相对而言的。数据量在几百几千的时候,差异完全感知不到;但上到十万级甚至百万级,removeIf和 Iterator 的差距就非常明显了。我实测过一个 50 万条记录的 ArrayList,按条件删除约 10 万条,Iterator 方式耗时大约是 removeIf 的 3 倍左右,具体数值受 JVM 状态影响,但趋势是稳定的。

5. 多线程和不可变集合下的删除,更多注意点

单线程环境讲完了,但真实项目里往往还有并发问题。这里说的多线程场景不是让你在多个线程里同时操作同一个 ArrayList——ArrayList 本身就不是线程安全的容器,那种玩法必须加锁或者用并发集合。这里我要讲的是:迭代器与主线程操作之间的冲突。

举个例子:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); Iterator<String> it = list.iterator(); list.add("d"); // 外部结构修改 it.next(); // 这里就会抛 ConcurrentModificationException

这就是快速失败机制在起作用:只要迭代器创建之后,集合结构发生过任何一次修改(不管是不是通过另一个线程),迭代器都会立刻失效。所以“先拿到迭代器,再在别的线程里改集合”这种操作,从根上就不成立。这也是为什么并发场景下更推荐使用CopyOnWriteArrayList——它的迭代器是基于快照的,创建之后集合随便改,迭代器依然安安静静地遍历旧数据,不会抛异常,代价是弱一致性,看到的数据可能不是最新的。

另外,Java 9 之后List.of()创建的是不可变集合,任何add、remove操作都会直接抛UnsupportedOperationException。这种集合在设计上就不允许结构修改,别想着用迭代器删了。

6. 常见问题与排查技巧实录

6.1 问题速查表

现象直接原因解决方案
普通 for 删除后数据漏删删除导致索引前移,i++ 跳过元素倒序遍历,或改用 Iterator
增强 for 删除抛 ConcurrentModificationExceptionmodCount 与 expectedModCount 不一致改用 Iterator.remove 或 removeIf
Iterator 连续删除抛 IllegalStateException未 next 就 remove,或连续 remove确保每次 remove 前都有 next
用 iterator 删除时集合被其他线程修改结构变更导致 modCount 变化加锁,或用 CopyOnWriteArrayList
removeIf 不生效删的是自定义对象,equals 没重写重写 equals/hashCode,或写具体判断条件

6.2 一个实战案例:从 100 万条日志里筛数据

有一次我在处理一个分析任务,有一个 ArrayList 里装了 100 万条日志对象,需要按条件筛掉约 70 万条。最开始我用的是 Iterator 删除,跑了大概 6 秒多,虽然不是不能接受,但总觉得别扭。

后来我换成removeIf,耗时降到了 2 秒左右。原因前面说过了:removeIf先标记再统一搬移,而 Iterator 是删一次搬一次。这个对比让我养成了一个习惯:凡是确定要大批量删除的,优先考虑 removeIf;只有在需要边遍历边做其他操作时才用 Iterator。

还有一次,我在删除条件里不小心引用了被删对象的一个字段,而这个字段恰好是 null,结果 NPE 直接打断循环。排查之后发现不是删除方式的问题,是业务判断逻辑的漏洞。所以用迭代器或 removeIf 的时候,条件判断里的边界情况一定要先想清楚,比如 null 值、空字符串、特殊字符等。

6.3 独家避坑技巧

讲几个常规文档里不会写的细节。

第一个是关于next()和remove()配合的“提前终止”需求。如果你想遍历到某个元素就停止,不再继续删了,记得直接 break,不要 rely 在迭代器的状态上。中断之后迭代器虽然还有效,但继续使用它的语义会变复杂,不如直接开一个新的迭代器。

第二个是嵌套遍历的问题。如果你在一个集合的迭代器内部又嵌套了另一个集合的迭代器,内层迭代器先调用了 next,外层才调 remove,这个顺序看似有点绕,但因为每个迭代器维护自己的 lastRet,只要各自调用顺序正确,就不会互相干扰。不过嵌套迭代的代码可读性很差,我通常会拆成独立方法。

第三个是 ArrayList 的subList视图。subList返回的是原列表的一个视图,共享modCount。如果你在遍历一个subList,同时原列表发生了什么修改,也会触发ConcurrentModificationException。这个坑非常隐蔽,很多人根本不知道subList和父列表是联动的。

6.4 怎样快速定位“是哪一行抛的异常”

如果线上出现ConcurrentModificationException,最直接的办法是看异常栈的堆栈信息,定位到具体循环。但有时候异常发生在框架代码里,堆栈不够直观。我的经验是:先查有没有在同一线程里对集合做了结构修改,比如循环体内调用了list.add()或list.remove();再排查是否有另一个线程在并发修改同一个集合对象;最后查subList相关代码。90% 以上的情况都逃不出这三类。

7. 一些关于编码习惯的真心建议

说实话,用完这个功能本身,写完代码只是最表层的事情。我在实际开发里更在意的是:这个写法在半年后、一年后,别人接手代码时能不能一眼看明白。

Iterator删除是传统写法,能看懂它的人很多,但写起来略显啰嗦。removeIf简洁但要求团队对 Java 8 流式写法足够熟悉。如果团队里有经验较浅的成员,或者代码要供多渠道共用,我往往会折中——统一在项目规范里规定“List 删除一律使用 Iterator.remove 或 removeIf”,避免不同人写出风格迥异的循环。

另外一个好习惯是:删除前先确认集合是否为 null,删除后确认集合是否为空,这两个防御性检查能省掉大量空指针和后续逻辑问题。我在实际项目中见过不少因为没有检查空集合导致的下游异常,其实排查到最后就是少了一行if (list == null || list.isEmpty()) return;。

还有一点想分享的是关于测试。删除逻辑往往是数据清洗、过滤业务的核心,建议针对“全部删除”“不删除”“连续重复删除”“删除最后一个元素”“集合只有一个元素”这几个边界场景各写一个单测。这些测试写起来很简单,但能拦下绝大多数回归问题。

我个人的体会是,集合删除这个问题看起来小,但它暴露的是对集合内部机制的理解深度。所以别再背答案了,把 modCount、expectedModCount、lastRet 这几个概念吃透,以后遇到类似的并发修改问题,你一眼就能判断出方向。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询