Java开发中的常见陷阱:List遍历删除元素你踩过吗

Java开发中的常见陷阱:List遍历删除元素你踩过吗 那天我盯着那个异常看了三小时就为了删掉列表里一个元素你写代码的时候有没有过这种经历一个简单的“遍历列表删除元素”功能运行起来却抛出ConcurrentModificationException或者莫名其妙地漏删了几个元素甚至数组越界。我敢打赌十有八九的Java初学者都在这个问题上摔过跟头。更讽刺的是有些工作了三五年的老手换了场景依然会掉进同一个坑里。今天我们就来把这张“老照片”彻底翻出来解剖一遍看看List遍历删除到底藏了多少陷阱。那些年我们写过的“自杀式”删除代码先来看一个最典型的场景你有一个ArrayList里面存着一些整数你想把其中的偶数全部删除。大部分人第一反应就是写一个for-each循环然后在条件满足时调用list.remove()。代码大概是这样的ListInteger list new ArrayList(Arrays.asList(1, 2, 3, 4, 5, 6)); for (Integer num : list) { if (num % 2 0) { list.remove(num); } }一运行ConcurrentModificationException立即砸在脸上。为什么因为for-each本质上是用迭代器遍历而ArrayList的remove方法会修改modCount迭代器内部维护的expectedModCount与modCount不一致迭代器就立刻抛出异常。你可能会想那换成普通for循环正序遍历总可以吧for (int i 0; i list.size(); i) { if (list.get(i) % 2 0) { list.remove(i); } }代码不抛异常了但你检查结果原本是[1,2,3,4,5,6]期望去掉偶数后剩下[1,3,5]实际得到的却是[1,3,5]你可能运气好得到了正确结果。但试一下[2,4,6,1,3]你会发现删不干净。普通for循环正序遍历删除的最大问题在于删除元素后所有后面的元素会前移而索引i继续递增导致被删除元素的下一个元素被跳过。比如删除第0个元素2后原索引1的4变成了新索引0而i变成1直接访问了原索引2的6于是4被漏掉了。迭代器才是“正确”的入门方案既然for-each原生迭代器会抛异常那我们就显式使用迭代器并且用迭代器自己的remove方法。这是官方文档推荐的经典做法IteratorInteger iterator list.iterator(); while (iterator.hasNext()) { Integer num iterator.next(); if (num % 2 0) { iterator.remove(); } }迭代器的remove方法会在删除后同步更新expectedModCount因此不会抛出异常。这是初学者需要掌握的第一个“标准答案”。但注意这个remove方法必须紧跟在next之后不能在两次next之间连续调用remove否则会抛出IllegalStateException。因为迭代器内部记录了一个lastRet的索引状态。然而很多人在实际项目中并不喜欢写这么啰嗦的代码。于是Java 8推出了Collection.removeIf()它可以让你用Lambda一行完成list.removeIf(num - num % 2 0);这行代码简洁优雅removeIf内部实现正是用了迭代器是官方提供的安全且高效的批量删除方案。但你真的理解它背后的原理吗很多人只是“背了模板”换个场景照样犯错。你以为的“正确”在并发环境里一文不值上面的迭代器方案和removeIf只适用于单线程场景。如果在多线程环境中一个线程在遍历另一个线程在删除仍然会抛出ConcurrentModificationException。因为ArrayList的所有修改操作都不是线程安全的。即便是迭代器remove也只是保证了同一个线程内的modCount一致跨线程的修改它无能为力。有些同学会想到加synchronized包裹整个遍历删除逻辑。但加锁会引入性能瓶颈也不一定能覆盖所有情况。一个更好的选择是使用CopyOnWriteArrayList。这个类在每次修改add、remove等时都会复制底层数组读取操作则无需加锁。ListInteger list new CopyOnWriteArrayList(new Integer[]{1, 2, 3, 4, 5, 6}); for (Integer num : list) { // 即使遍历中删除也不会抛异常 if (num % 2 0) { list.remove(num); } }CopyOnWriteArrayList的迭代器是“快照”式的遍历的是创建迭代器时的底层数组快照后续修改不会影响现有迭代器。但代价是每次修改都复制数组如果写操作频繁性能会急剧下降。所以它适合读多写少的场景比如配置缓存、白名单等。倒序遍历一个技巧搞定索引错位还有一种常见的“脏活”是你没办法使用迭代器比如需要在遍历过程中同时删除多个元素并且还要根据删除操作调整后续行为。这时可以借助“倒序遍历”来规避索引位移问题。代码很简单for (int i list.size() - 1; i 0; i--) { if (list.get(i) % 2 0) { list.remove(i); } }因为从后往前删删除一个元素时前面的元素索引不会受影响所以不会出现漏删。这个技巧在Android开发中常用来批量删除ListView的数据源因为ListView的adapter更新需要同步操作原列表。但要注意倒序遍历仍然存在数组拷贝的开销ArrayList的remove会移动元素时间复杂度依然是O(n²)只是逻辑上正确而已。“删除时还要读”的复杂场景有时候你不光要删除还要在删除过程中读取被删除元素或相邻元素。比如遍历列表如果当前元素和下一个元素满足某个条件就一起删除。这时候普通的迭代器remove就难以处理了因为你需要在遍历中访问多个元素。我们来看看一个经典错误想用迭代器遍历同时调用list.get(i1)来获取下一个元素。但迭代器并没有索引概念你只能通过iterator.next()顺次获取。如果你在循环体内又调用了list.remove(index)直接触发异常。一种可行方案是使用普通for循环配合“标记删除”或“新建列表收集”的方式ListInteger toRemove new ArrayList(); for (int i 0; i list.size() - 1; i) { if (list.get(i) % 2 0 list.get(i 1) % 2 0) { toRemove.add(list.get(i)); toRemove.add(list.get(i 1)); // 注意需要跳过已被标记删除的元素否则会重复处理 i; // 简单跳过下一个 } } list.removeAll(toRemove);这种“标记-删除”模式把遍历和删除分离避免了在遍历过程中修改结构。但它会导致额外的内存开销并且需要谨慎处理标记索引的偏移。深入底层modCount和expectedModCount的致命博弈很多人只知道“for-each循环中不能删除”却不明白背后的实现机制。让我们看看ArrayList的源码片段简化private class Itr implements IteratorE { int cursor; int lastRet -1; int expectedModCount modCount; public E next() { checkForComodification(); // ... 正常next逻辑 } public void remove() { if (lastRet 0) throw new IllegalStateException(); checkForComodification(); ArrayList.this.remove(lastRet); cursor lastRet; lastRet -1; expectedModCount modCount; // 关键同步expectedModCount } final void checkForComodification() { if (modCount ! expectedModCount) throw new ConcurrentModificationException(); } }关键点在于ArrayList.this.remove(int index)会执行modCount而迭代器的remove()方法在调用ArrayList的remove之后立刻将expectedModCount更新为新的modCount。所以迭代器自身的remove不会触发异常。但当你直接调用list.remove(obj)时modCount改变了迭代器却不知道下一次调用next()就会抛出异常。理解了这一点你就能解释很多奇怪的现象。比如为什么在for-each循环中如果只删除倒数第二个元素且是单线程有时竟然不抛异常因为next()的检查时机依赖于游标位置如果恰好删除的是最后一个元素迭代器hasNext()返回false根本不会进入next()异常就不会被触发。但这种“侥幸”代码绝对不应该出现在生产环境。Stream API的“假遍历”陷阱Java 8的Stream API提供了一种函数式方式来处理集合。有人会尝试这样list.stream() .filter(num - num % 2 0) .forEach(list::remove);这段代码会在forEach内部调用list.remove同样会抛出ConcurrentModificationException。因为forEach操作也是在遍历集合底层使用了Spliterator而list.remove修改了modCount。Stream的“无状态”操作并不能豁免并发修改异常。正确做法是使用collect收集结果list list.stream() .filter(num - num % 2 ! 0) .collect(Collectors.toList());这是最纯粹的函数式解法不修改原集合而是创建新集合。它安全、清晰但会创建新对象内存开销稍大。如果你对性能敏感可以先用stream筛选再清空原列表并添加ListInteger evens list.stream().filter(num - num % 2 0).collect(Collectors.toList()); list.removeAll(evens);但这种做法等于遍历了两次不值得推荐。更好的方法还是removeIf。警惕子列表和不可变列表的“暗箭”List.subList()返回的是原列表的一个视图修改子列表会影响原列表。如果你在对子列表遍历时删除元素同样会面临上述所有问题并且因为子列表的修改会同步到原列表modCount的变化会更复杂。一个常见的误区是认为子列表的迭代器可以安全地修改子列表但如果你在子列表迭代器中调用子列表的remove实际上调用的还是原ArrayList的removemodCount更新的是原列表但子列表的迭代器期望的modCount却是子列表自己的modCount实际共享同一个modCount这可能导致难以调试的异常。另外Arrays.asList()返回的是Arrays$ArrayList它是一个不可变长度的数组列表不支持add/remove操作。如果你试图对它进行删除会抛出UnsupportedOperationException。很多人把Arrays.asList的结果当作普通ArrayList直接删除就会踩到这个坑。性能取舍什么时候该用什么方式我们比较几种常见方式的性能以ArrayList为例迭代器remove每次删除都需要数组元素移动时间复杂度O(n)每次总复杂度O(n²)倒序遍历remove同理O(n²)removeIf内部也是迭代器每次移动O(n²)Stream collectO(n)遍历新建数组空间开销大但时间上线性新建列表收集类似StreamO(n)如果列表非常大比如百万级且要删除大量元素O(n²)的算法会难以接受。此时更优的做法是遍历一次把要保留的元素收集到新列表最后替换原列表引用。或者使用LinkedList但LinkedList的随机访问是O(n)遍历删除同样低效。如果要频繁删除中间元素CopyOnWriteArrayList并不适合。真正的解决方案是使用LinkedList配合迭代器删除因为LinkedList的remove是O(1)前提是你持有节点引用但遍历本身是O(n)总体O(n)。不过LinkedList的内存占用和CPU缓存不友好在大多数场景下还是ArrayList更快。实际开发中数据量在几千以内时不用过于纠结O(n²)的问题选择最清晰安全的写法即可。如果数据量上万建议使用Stream collect或者手动构建新列表的O(n)方案。并发环境下的终极方案在多线程场景中除了CopyOnWriteArrayList还可以考虑使用Collections.synchronizedList包装但这只是将每个方法加锁遍历时仍然需要手动同步否则在遍历中也可能被其他线程修改。同步包装后的列表你仍然需要在遍历删除时在外层加synchronized块。使用ConcurrentLinkedQueue或ConcurrentSkipListSet等真正的并发容器。但它们是Queue/Set不是List如果你需要索引访问可能不合适。采用“不可变集合”策略不修改原集合始终返回新集合。这在响应式编程中非常常见。一个被低估的绝招是利用List.replaceAll进行条件替换。比如你想把偶数删除可以把偶数替换成一个null标记最后再整体删除null。replaceAll是在底层使用ListIterator.set不会触发并发修改异常但会改变size不它只是替换值。然后你可以用removeIf(Objects::isNull)删除所有null。这其实是两步操作但避免了反复移动元素。list.replaceAll(num - num % 2 0 ? null : num); list.removeIf(Objects::isNull);这个方案比直接遍历删除更高效因为replaceAll内部使用ListIterator逐个设置只修改值不删除最后的removeIf是一次性批量删除所有null底层优化了移动次数。可以说这是性能和清晰度的良好平衡点。最后的忠告养成“写前思考”的习惯写代码时碰到“遍历删除”的需求不妨先花10秒钟想清楚是单线程还是多线程数据规模多大删除后还需要后面的元素吗还是直接重新建一个集合有没有更简单的函数式替代removeIf / collect不要觉得这类问题简单就掉以轻心。很多线上事故的根源就是这种不起眼的细节。我见过有人因为for-each删除抛异常在catch里吞掉异常继续执行导致数据不一致。也见过有人为了图省事在普通for循环中加上i--来补偿索引位移最后代码逻辑混乱到无法维护。理解modCount和迭代器机制不是让你死记硬背“不能用for-each删除”而是让你明白凡是在一次遍历中修改集合结构增删元素的操作都需要特别小心。当你掌握了原理你就不会对“为什么这个写法不抛异常但结果不对”、“为什么换个列表类型就没事”等问题感到困惑了。最后我想说编程的智慧不在于记住所有Api而在于理解每个操作背后的数据结构约定。List遍历删除这个坑你踩过吗如果踩过那么恭喜你你又变强了一点。如果还没踩过那只是时间问题——因为这意味着你写的代码还不够多。