前言
🍊缘由
集合遍历敢乱来?大坑小洼等你钻!

🐣闪亮主角
大家好,我是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)**在幕后默默工作。你可以把这个迭代器想象成一个带着"小账本"的巡检员,它心里记着两件事:
- 集合当前的版本号(
modCount
): 这个版本号代表了集合的结构性修改次数(比如添加、删除元素)。 - 自己已经走过多少步(当前位置): 它知道自己下一个该检查哪个元素。
当这个"巡检员"发现,它手里账本记的版本号,跟集合实际的版本号对不上了(也就是说,集合在它不知情的情况下被"动过手脚"),它立马就不干了!它会认为:"我正在认真巡检呢,你特么居然敢在我眼皮子底下修改我的工作对象?!这让我怎么保证数据一致性?!"
于是,它就给你甩个大大的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
举例,但道理对Set
和Map
也是一样通用的:不要在遍历的时候,通过它们自身的方法去修改集合结构!
- 对于
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=狗哥}
👽人话解释: HashMap
和HashSet
就像是那种有"门把手"的容器,你不能直接穿墙进去拿东西。你得通过它们的"门把手"(entrySet()
或keySet()
)进去,然后让"门卫"(迭代器)帮你把东西(Entry或Key)移走。
五. 狗哥的实战经验总结!
- 首选
removeIf()
(Java 8+): 如果你的项目支持Java 8及以上,并且只是想移除符合特定条件的元素,removeIf()
是你的不二之选。代码简洁,意图清晰,而且最安全。 - 次选
Iterator.remove()
: 如果需要更复杂的遍历逻辑(比如遍历时同时处理,或者不支持removeIf
的情况),Iterator.remove()
是你的标准答案。这是Java官方推荐的、安全地在遍历时移除元素的方式。 - 避开
for
循环直接remove/add
: 无论何时何地,请把这种方式列入"危险操作"黑名单!它不仅可能抛CME,还可能导致数据错漏或逻辑错误。 - 添加元素,绝不"当面偷人": 遍历时添加元素,唯一安全的方式就是:先收集到临时集合,等遍历结束后,再统一添加到原集合中。
- 理解原理,而非死记硬背: 搞清楚
modCount
和迭代器的工作原理,你就能举一反三,避免在各种集合操作中踩坑。 - 一个循环,干一件"正事": 尽量保持循环逻辑的单一性。如果既要遍历又要修改,考虑分步进行或者使用前面提到的安全方法。
🍈猜你想问
如何与博主联系进行探讨
关注公众号【JavaDog程序狗】
公众号回复【入群】或者【加入】,便可成为【程序员学习交流摸鱼群】的一员,问题随便问,牛逼随便吹,目前群内已有超过380+个小伙伴啦!!!
2. 踩踩博主博客
里面有博主的私密联系方式呦 !,大家可以在里面留言,随意发挥,有问必答😘
🍯猜你喜欢
文章推荐
【实操】Spring Cloud Alibaba AI,阿里AI这不得玩一下(含前后端源码)
【项目实战】SpringBoot+uniapp+uview2打造H5+小程序+APP入门学习的聊天小项目
【项目实战】SpringBoot+uniapp+uview2打造一个企业黑红名单吐槽小程序
【模块分层】还不会SpringBoot项目模块分层?来这手把手教你!
