【Java】集合遍历remove-add操作,这些坑你掉过几个

前言

🍊缘由

集合遍历敢乱来?大坑小洼等你钻!

🐣闪亮主角

大家好,我是JavaDog程序狗

今天跟大家聊个程序员圈子里老生常谈,但又常常被"无意中"踩到的坑------Java集合遍历时,偷偷摸摸地进行修改(移除或添加)操作

本狗将这些年摸爬滚打,被集合"甩脸色"的血泪教训,结合实际案例和人话分析,让你一次性搞懂:为啥集合遍历时不能随便动?哪些姿势是正确的?哪些又是要命的?

😈你想听的故事

狗哥最近在线上排查一个诡异的Bug,现象是:明明代码逻辑没问题,但偶尔就会抛出个ConcurrentModificationException!这玩意儿,一看名字就让人头大,明明是单线程操作,哪来的"并发"?

好家伙,一番追查,最终锁定在一个不起眼的for-each循环里------有个小老弟在遍历的时候,神不知鬼不觉地就把集合里的元素给移除了!CME当场就不乐意了,直接给了个大大的"惊喜"。

于是乎,狗哥决定将我这些年被集合"折磨"的心得,总结整理,咱们今天就来盘盘这个"小脾气"的集合!

正文

🎯主要目标

1. ConcurrentModificationException(CME)是个啥?为啥它会"甩脸色"?

2. 遍历时移除(remove)元素,哪些是"坑"?哪些是"康庄大道"?

3. 遍历时添加(add)元素,你敢当面"偷人"?

4. Java 8+ 时代,如何优雅地"玩弄"集合?

5. HashMap/HashSet等集合,它俩有啥"特殊待遇"?

6. 狗哥的实战经验总结,让你从此告别CME!

🍪目标讲解

一. ConcurrentModificationException(CME)是个啥?

1. 你眼中的集合遍历:简单粗暴!

在程序员的江湖里,集合遍历那可是家常便饭,张口就来:

java 复制代码
List<String> names = new ArrayList<>(Arrays.asList("狗哥", "张三", "李四", "王五"));
for (String name : names) {
    System.out.println("大家好,我是 " + name);
}

这段代码多简单啊,闭着眼睛都能敲出来。但是,你有没有想过,如果你在遍历的时候,突然给集合里加个元素,或者移走一个元素,会发生什么?

2. CME闪亮登场:我生气了,后果很严重!

当你尝试在遍历过程中,通过非迭代器自身的方法 修改集合(比如直接调用list.remove(obj)或者list.add(obj))时,ConcurrentModificationException就会像幽灵一样,突然出现!

看,它来了!

java 复制代码
List<String> names = new ArrayList<>(Arrays.asList("狗哥", "张三", "李四", "王五"));
for (String name : names) {
    if ("李四".equals(name)) {
        names.remove(name); // 危险操作!
    }
}
// 运行这段代码,你就等着迎接CME的大驾光临吧!
3. 迭代器的小脾气

集合在被遍历的时候,会有一个**迭代器(Iterator)**在幕后默默工作。你可以把这个迭代器想象成一个带着"小账本"的巡检员,它心里记着两件事:

  1. 集合当前的版本号(modCount): 这个版本号代表了集合的结构性修改次数(比如添加、删除元素)。
  2. 自己已经走过多少步(当前位置): 它知道自己下一个该检查哪个元素。

当这个"巡检员"发现,它手里账本记的版本号,跟集合实际的版本号对不上了(也就是说,集合在它不知情的情况下被"动过手脚"),它立马就不干了!它会认为:"我正在认真巡检呢,你特么居然敢在我眼皮子底下修改我的工作对象?!这让我怎么保证数据一致性?!"

于是,它就给你甩个大大的ConcurrentModificationException,以此来表达它的"不满"和"抗议",告诉你:"老子正在干活,你敢动老子的东西?!后果自负!"

记住,这个CME,不仅仅在多线程环境下出现,单线程下,只要你在遍历时通过非迭代器自身的方法修改了集合的结构,它照样给你甩脸色!


二. 移除元素,优雅地分手!

既然CME这么凶,那我们在遍历时想移除元素怎么办呢?别怕,有的是"康庄大道"!

1. 错误姿势:for循环直接remove

前面我们说了,直接list.remove()会抛CME。除了CME,如果集合是ArrayList这种基于索引的,你还会遇到另一个坑:跳过元素

java 复制代码
List<Integer> numbers = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
// 假设我们要移除所有偶数
for (int i = 0; i < numbers.size(); i++) {
    if (numbers.get(i) % 2 == 0) {
        numbers.remove(i); // 这里移除了一个元素,后面的元素会往前移,导致i跳过下一个元素!
    }
}
System.out.println(numbers); // 结果可能是 [1, 3, 5] 或者 [1, 3] 甚至其他奇怪结果,取决于具体情况
// 比如移除2,3就成了新的索引1,下次循环i++,就跳过了3,直接检查4了!

这种方式,不仅危险,而且容易导致逻辑错误,因为当你移除一个元素后,它后面的元素会自动"补位",导致你当前的索引i会跳过一个元素。

2. 正确姿势:Iterator.remove()

Iterator提供了一个专门的方法remove()这才是正宫,亲儿子! 当你使用Iterator来遍历,并通过iterator.remove()来移除元素时,迭代器会感知到这个操作,并正确地更新它内部的状态(比如modCount),避免抛出CME

java 复制代码
List<String> names = new ArrayList<>(Arrays.asList("狗哥", "张三", "李四", "王五"));
Iterator<String> iterator = names.iterator(); // 获取迭代器
while (iterator.hasNext()) {
    String name = iterator.next();
    if ("李四".equals(name) || "王五".equals(name)) {
        iterator.remove(); // 看,这就是正确的姿势!
    }
}
System.out.println(names); // 输出:[狗哥, 张三]

👽人话解释: 迭代器就像一个司机,它带着乘客(元素)一站一站地走。当乘客(你)要下车(移除元素)时,你得告诉司机(调用iterator.remove()),让司机知道有人下车了,他会调整车内乘客数量,然后继续开车。如果你自己偷偷摸摸地把乘客扔下车,司机一看人数不对,立马停车罢工,这就是CME!

3. Java 8+ 的优雅姿势:removeIf()

Java 8及以后的版本,为Collection接口引入了removeIf()方法,这个方法接收一个Predicate(谓词)函数式接口,让你用更简洁、更安全的方式移除符合条件的元素。

java 复制代码
List<String> names = new ArrayList<>(Arrays.asList("狗哥", "张三", "李四", "王五"));
names.removeIf(name -> "李四".equals(name) || "王五".equals(name));
System.out.println(names); // 输出:[狗哥, 张三]

👽人话解释: removeIf()就像一个"智能管家"。你不用自己去遍历,也不用管迭代器那些"小脾气",你只需要告诉管家:"管家啊,你把那些名字是'李四'或者'王五'的人,都给我请出去!" 管家会自己内部处理好遍历和移除的细节,保证不出乱子。优雅,实在是太优雅了!

4. 其他"野路子":倒序遍历、新建集合

倒序遍历(仅适用于List): 如果你实在不想用迭代器,并且你的集合是List(支持索引访问),那你可以尝试倒序遍历。因为倒序移除元素不会影响前面元素的索引。

csharp 复制代码
```java
List<String> names = new ArrayList<>(Arrays.asList("狗哥", "张三", "李四", "王五"));
for (int i = names.size() - 1; i >= 0; i--) {
    if ("李四".equals(names.get(i)) || "王五".equals(names.get(i))) {
        names.remove(i);
    }
}
System.out.println(names); // 输出:[狗哥, 张三]
```
这个姿势看起来没毛病,但只适用于`List`,而且代码可读性不如`Iterator.remove()`或`removeIf()`。狗哥不推荐多用,除非你对性能有极致要求,并且对底层集合实现非常了解。

新建集合(万能但耗性能): 最笨但最安全的办法,就是遍历的时候,把不想移除的元素 放到一个新集合里,或者把要移除的元素放到一个临时集合里,等遍历完了再统一处理。

csharp 复制代码
```java
// 方式一:只保留想保留的
List<String> originalNames = new ArrayList<>(Arrays.asList("狗哥", "张三", "李四", "王五"));
List<String> newNames = new ArrayList<>();
for (String name : originalNames) {
    if (!"李四".equals(name) && !"王五".equals(name)) {
        newNames.add(name);
    }
}
originalNames = newNames; // 或者直接用newNames
System.out.println(originalNames); // 输出:[狗哥, 张三]

// 方式二:先收集要移除的,再统一移除
List<String> names = new ArrayList<>(Arrays.asList("狗哥", "张三", "李四", "王五"));
List<String> toRemove = new ArrayList<>();
for (String name : names) {
    if ("李四".equals(name) || "王五".equals(name)) {
        toRemove.add(name);
    }
}
names.removeAll(toRemove); // 遍历完再统一移除
System.out.println(names); // 输出:[狗哥, 张三]
```
这种方式虽然安全,但会创建新的集合对象,增加了内存开销和GC压力,在大数据量下要慎用。

三. 添加元素,你敢当面"偷人"?

移除元素都这么麻烦了,那添加元素呢?

直接在遍历时往集合里add()元素,那可真是"当面偷人"!同样会触发CME。

java 复制代码
List<String> names = new ArrayList<>(Arrays.asList("狗哥", "张三"));
for (String name : names) {
    if ("张三".equals(name)) {
        names.add("赵四"); // 大写的危险!
    }
}
// CME,跑不掉的!

👽人话解释: 同样是"司机"和"乘客"的故事。司机(迭代器)开着车,数着车上有多少人。你突然在车里塞了一个人进去,司机一看,咦,人数不对啊?!立马停车(CME)! 而且,如果你的集合是LinkedList这种链式结构的,当你添加元素后,可能会导致无限循环!因为每次添加都会影响集合的大小,并且可能导致迭代器永远到不了终点。

1. 正确姿势:先搜集,后添加

最稳妥的方式是:在遍历的时候,把想添加的元素 先收集到一个临时集合里,等遍历结束了,再把这些临时元素一次性添加到原集合中。

java 复制代码
List<String> names = new ArrayList<>(Arrays.asList("狗哥", "张三"));
List<String> toAdd = new ArrayList<>(); // 临时集合,专门放要添加的元素
for (String name : names) {
    System.out.println("当前处理:" + name);
    if ("张三".equals(name)) {
        toAdd.add("赵四"); // 先偷偷记下来,等会儿再加
        toAdd.add("刘能");
    }
}
names.addAll(toAdd); // 遍历结束,一口气加进去
System.out.println(names); // 输出:[狗哥, 张三, 赵四, 刘能]

👽人话解释: 这就像你在家里打扫卫生,发现需要买点东西。你不会在打扫的过程中跑出去买,而是先记下购物清单,等家里打扫干净了(遍历结束),你再拿着清单一次性出门采购(统一添加)。出去干完再回来,家里不乱套!


四. HashMap/HashSet,你俩有点"特殊"!

虽然前面我们主要以List举例,但道理对SetMap也是一样通用的:不要在遍历的时候,通过它们自身的方法去修改集合结构!

  • 对于HashSet,你不能直接set.remove(obj),要用Iterator.remove()removeIf()
  • 对于HashMap,你不能直接map.remove(key)map.put(key, value)

如果想修改HashMap,你需要遍历它的entrySet()或者keySet(),然后通过对应迭代器的remove()方法来操作。

java 复制代码
// 移除Map中的某个Entry
Map<String, String> userMap = new HashMap<>();
userMap.put("dogge", "狗哥");
userMap.put("lisi", "李四");
userMap.put("wangwu", "王五");

Iterator<Map.Entry<String, String>> iterator = userMap.entrySet().iterator();
while (iterator.hasNext()) {
    Map.Entry<String, String> entry = iterator.next();
    if ("李四".equals(entry.getValue())) {
        iterator.remove(); // 通过EntrySet的迭代器移除
    }
}
System.out.println(userMap); // 输出:{wangwu=王五, dogge=狗哥}

// Java 8+ 更优雅
userMap.entrySet().removeIf(entry -> "王五".equals(entry.getValue()));
System.out.println(userMap); // 输出:{dogge=狗哥}

👽人话解释: HashMapHashSet就像是那种有"门把手"的容器,你不能直接穿墙进去拿东西。你得通过它们的"门把手"(entrySet()keySet())进去,然后让"门卫"(迭代器)帮你把东西(Entry或Key)移走。


五. 狗哥的实战经验总结!

  1. 首选 removeIf() (Java 8+): 如果你的项目支持Java 8及以上,并且只是想移除符合特定条件的元素,removeIf()是你的不二之选。代码简洁,意图清晰,而且最安全。
  2. 次选 Iterator.remove(): 如果需要更复杂的遍历逻辑(比如遍历时同时处理,或者不支持removeIf的情况),Iterator.remove()是你的标准答案。这是Java官方推荐的、安全地在遍历时移除元素的方式。
  3. 避开 for循环直接remove/add: 无论何时何地,请把这种方式列入"危险操作"黑名单!它不仅可能抛CME,还可能导致数据错漏或逻辑错误。
  4. 添加元素,绝不"当面偷人": 遍历时添加元素,唯一安全的方式就是:先收集到临时集合,等遍历结束后,再统一添加到原集合中。
  5. 理解原理,而非死记硬背: 搞清楚modCount和迭代器的工作原理,你就能举一反三,避免在各种集合操作中踩坑。
  6. 一个循环,干一件"正事": 尽量保持循环逻辑的单一性。如果既要遍历又要修改,考虑分步进行或者使用前面提到的安全方法。

🍈猜你想问

如何与博主联系进行探讨

关注公众号【JavaDog程序狗】

公众号回复【入群】或者【加入】,便可成为【程序员学习交流摸鱼群】的一员,问题随便问,牛逼随便吹,目前群内已有超过380+个小伙伴啦!!!

2. 踩踩博主博客

javadog.net

里面有博主的私密联系方式呦 !,大家可以在里面留言,随意发挥,有问必答😘

🍯猜你喜欢

文章推荐

【实操】Spring Cloud Alibaba AI,阿里AI这不得玩一下(含前后端源码)

【规范】看看人家Git提交描述,那叫一个规矩

【项目实战】SpringBoot+uniapp+uview2打造H5+小程序+APP入门学习的聊天小项目

【项目实战】SpringBoot+uniapp+uview2打造一个企业黑红名单吐槽小程序

【模块分层】还不会SpringBoot项目模块分层?来这手把手教你!

相关推荐
伍六星5 分钟前
更新Java的环境变量后VScode/cursor里面还是之前的环境变量
java·开发语言·vscode
风象南11 分钟前
SpringBoot实现简易直播
java·spring boot·后端
这里有鱼汤19 分钟前
有人说10日低点买入法,赢率高达95%?我不信,于是亲自回测了下…
后端·python
万能程序员-传康Kk20 分钟前
智能教育个性化学习平台-java
java·开发语言·学习
落笔画忧愁e29 分钟前
扣子Coze飞书多维表插件-列出全部数据表
java·服务器·飞书
鱼儿也有烦恼32 分钟前
Elasticsearch最新入门教程
java·elasticsearch·kibana
eternal__day42 分钟前
微服务架构下的服务注册与发现:Eureka 深度解析
java·spring cloud·微服务·eureka·架构·maven
一介草民丶1 小时前
Jenkins | Linux环境部署Jenkins与部署java项目
java·linux·jenkins
武子康1 小时前
Java-39 深入浅出 Spring - AOP切面增强 核心概念 通知类型 XML+注解方式 附代码
xml·java·大数据·开发语言·后端·spring
米粉03051 小时前
SpringBoot核心注解详解及3.0与2.0版本深度对比
java·spring boot·后端