写点什么

Java 移除 List 中的元素,这玩意讲究!

  • 2021 年 11 月 11 日
  • 本文字数:4842 字

    阅读完需:约 16 分钟

for (String str: list){


if ("C".equals(str)){


list.remove(str);


}


}


光这样看,我们只能知道,用了 foreach 的语法糖,那么我们看编译后的:



再看我们的报错信息:



源码分析:



通过我们反编译的代码,结合 ArrayList 的源码, 我们可以知道,?


Itr 就是 ArrayList 里面的内部类,


而 foreach 的语法糖其实就是帮我们 new 了一下 Itr,先调用 hashNext()


while(var2.hasNext())


显然是作为循环的条件,那么我们也一起来简单看下这个方法源码:


public boolean hasNext() {


return cursor != size;


}


size 是啥?



那 cursor 是啥?



所以,hashNext()?意思是, 当 cursor 不等于 size 的时候,代表 还有下一位,继续循环就完事了,这个值其实不是本次报错的重点。


我们继续看 Itr 的 next()方法中的 checkForComodification()方法,就是这玩意导致报错的。


那么我们直接定位到? checkForComodification()方法的源码:



代码简单, 也看到了我们刚才报的错?ConcurrentModificationException 在里面躺着。



只要 modCount 不等于 expectedModCount ,就抛错。


那么我们就得明白??modCount 和?expectedModCount 是啥?


expectedModCount 简单,是 Itr 里的一个属性 ,在初始化的时候,就已经把 modCount 的值 等赋给了?expectedModCount。



其实?expectedModCount 就是用来记录 一开始 迭代的 list 的 变更数 modCount,?至于 list 的 变更数 modCount 是啥,我们接着看。


点进去看 modCount 的源码:



可以看到作者真是苦口婆心,这


【一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义】
浏览器打开:qq.cn.hn/FTf 免费领取
复制代码


么一个字段属性,人家写了这么多注释, 那肯定是解释得非常细致了。


那么我来抽出一些核心的 翻译一下,给各位看看:


此列表在结构上被修改的次数。结构修改是指改变结构尺寸的修改。



如果此字段的值意外更改,则迭代器(或列表迭代器)将在



对{@code next}、{@code remove}、{@code previous}的响应,{@code set}或{@code add}操作。这提供了<i>快速失败</i>行为,而不是迭代过程中并发修改的情况。


我来简单再说一下:


这个 modCount,可以理解为记录 list 的变动值。 如果你的 list 里面连续 add 7 个元素,那么这个变动值就是 7 . 如果是 add 7 个元素,remove 1 个元素, 那么这个值就是 8 . 反正就是修改变动的次数的一个统计值。


而这个值,在使用迭代的时候,会在迭代器初始化传入,赋值给到迭代器 Itr 里面的内部记录值 ,也就是我们刚刚讲到的?expectedModCount 值 。 这样来防止使用的时候,有意外的修改,导致并发的问题。


这么一说,其实我们报错 ConcurrentModificationException? 的原因就很明显了。


一开始的情况:



所以在我们第一次循环检测,使用 foreach 语法糖,调用? Itr 的 next()方法时,会去调用 check 方法:


因为确实一开始大家都是 7,检测 modCount 和 expectedModCount 值是通过的:



接着,我们继续触发? Itr 的 next()方法,按照往常,也是调用了 check 方法,结果检测出来初始化传入的 list 变化记录数 expectedModCount 是 7,而 最新的 list 的变更记录数 modCount 因为在第一次的 list.remove 触发后,modCount++了,变成了 8,所以:?



两值不等, 抛出错误。


所以上述出现报错?ConcurrentModificationException 的原因非常明了, 其实就是因为调用了 Itr 的 next()方法, 而 next()方法每次执行时,会调 check 方法。 那么可以理解为,这是 foreach 语法糖+移除时的锅。


那么我们就避免这个语法糖 ,我们先来个习惯性编写的 for 循环方式:


List<String> list = new ArrayList();


list.add("C");


list.add("A");


list.add("C");


list.add("B");


list.add("F");


list.add("C");


list.add("C");


System.out.println("未移除前" + list.toString());


int size = list.size();


for (int i = 0; i < size; i++) {


if ("C".equals(list.get(i))){


list.remove("C");


}


}


System.out.println("移除后" + list.toString());


这样的执行结果是啥, 报错了,IndexOutOfBoundsException 数组索引边界异常:



为啥会错啊,原因很简单:



ps: cv 习惯了,蓝色字体里已经 cv 不分了,也不改了,大家意会即可。


所以这个示例报错的原由很简单,我编码问题,把 size 值提前固定为 7 了, 然后 list 的 size 是实时变化的。


那么我把 size 不提前获取了,放在 for 循环里面。这样就不会导致 i++使 i 大于 list 的 size 了:


List<String> list = new ArrayList();


list.add("C");


list.add("A");


list.add("C");


list.add("B");


list.add("F");


list.add("C");


list.add("C");


System.out.println("未移除前" + list.toString());


for (int i = 0; i < list.size(); i++) {


if ("C".equals(list.get(i))) {


list.remove("C");


}


}


System.out.println("移除后" + list.toString());


}


这样的运行结果是什么:



虽然没报错,但是没有移除干净,为什么?


其实还是因为 list 的 size 在真实的变动 。每次移除,会让 size 的值 -1 , 而 i 是一如既往的 +1 .


而因为 ArrayList 是数组, 索引是连续的,每次移除,数组的索引值都会 ’重新编排‘ 一次。


看个图,我画个简单的例子给大家看看:



也就是说,其实每一次的 remove 变动, 因为我们的循环 i 值是一直 增加的,


所以会造成,我们想象的? 数组内第二个 C 元素 的索引是 2, 当 i 为 2 时会 拿出来检测,这个假想是不对的。?


因为如果 第二个 C 元素前面的 元素发生了变化, 那么它自己的索引也会往前 移动。


所以为什么会出现 移除不干净的 现象? ??**,


其实简单说就是? ? 最后一个 C 元素因为前面的元素变动移除/新增,它的 index 变化了。


然后 i > list.size() 的时候就会 跳出循环, 而这个倒霉蛋 C 元素排在后面,index 值在努力往前移,而 i 值在变大, 但是因为我们这边是执行 remove 操作, list 的 size 在变小。**


在 i 值和 size 值 两个 交锋相对的时候,最后一个 C 元素没来得及匹对, i 就已经大于 list.size ,导致循环结束了。


这么说大家不知道能不能懂,因为对于初学者来说,可能没那么快可以反应过来。


没懂的兄弟,看我的文章,我决不会让你带着疑惑离开这篇文章,我再上个栗子,细说(已经理解的可以直接往下拉,跳过这段罗嗦的分析)。


上栗子:


我们的 list 里面 紧紧有 三个元素? ? "A"? "C"? "C" , 然后其余的不变,也是循环里面移除”C“ 元素 。


List<String> list = new ArrayList();


list.add("A");


list.add("C");


list.add("C");


System.out.println("未移除前" + list.toString());


for (int i = 0; i < list.size(); i++) {


if ("C".equals(list.get(i))) {


list.remove("C");


}


}


System.out.println("移除后" + list.toString());


先看一下结果,还是出现移除不干净:



分析:


1. list 的样子:



2. 循环触发,第一次 i 的值为 0, 取出来的 元素是 A ,不符合要求:



3.继续循环,?此时 list 的 size 值 依然是不变,还是 3 ,而 i 的值因为 i++ 后变成了 1 , 1 小于 3,条件符合,进入循环内,取出 list 里 index 为 1 的元素:



4.这个 C 符合要求, 被移除, 移除后,我们的 list 状态变成了:



5. 此时此刻 list 的 size 是 2 ,再一轮 for 循环?,??i 的值 i++ 后继续变大,从 1 变成了 2 ,? 2 不小于 2 ,所以循环结束了。


但是我们这时候 list 里面排在最后的那个 C 元素?原本 index 是 2,变成了 index 1 ,这个家伙 都还没被 取出来, 循环结束了,它就逃过了检测。 所以没被移除干净。


PS: 很可能有些看客 心里面会想(我 YY 你们会这么想), 平时用的 remove 是利用 index 移除的, 跟我上面使用的 remove(Object o) 还不一样的,是不是我例子的代码使用方法问题。?


然而并不然,因为这个 remove 调用的是哪个,其实不是重点,看图:



结果还是一样:



其实 这样的 for 循环写法, 跟? list 的 remove 到底使用的是 Object 匹配移除 还是 Index 移除 , 没有关系的。 移除不干净是因为 循环 i 的值 跟 list 的 size 变动 ,跳出循环造成的。

能看到这里的兄弟, 辛苦了。

那么 使用 remove 这个方法,结合循环,那就真的没办法 移除干净了吗?

行得通的例子:

while 循环 :


List<String> list = new ArrayList();


list.add("C");


list.add("A");


list.add("C");


list.add("B");


list.add("F");


list.add("C");


list.add("C");


System.out.println("未移除前"+list.toString());


while (list.remove("C"));


System.out.println("移除后"+list.toString());


}


结果,完美执行:



为什么这么些 不会报 ConcurrentModificationException 错,也不会报?IndexOutOfBoundsException 错 呢?


我们看看编译后的代码:



可以看到时单纯的调用 list 的 remove 方法而已,只要 list 里面有"C",那么移除返回的就是 true,那么就会继续触发再一次的 remove(“C”),所以这样下去,会把 list 里面的“C”都移除干净,简单看一眼源码:



所以这样使用是行得通的。

那么当然还有文章开头我给那位兄弟说的使用迭代器的方式动态删除也是行得通的:


Iterator


List<String> list = new ArrayList();


list.add("C");


list.add("A");


list.add("B");


list.add("C");


list.add("F");


list.add("C");


list.add("C");


System.out.println("未移除前" + list.toString());


Iterator<String> it = list.iterator();


while(it.hasNext()){


String x = it.next();


if("C".equals(x)){


it.remove();


}


}


System.out.println("移除后" + list.toString());


执行结果:



PS:


但是这个方式要注意的是, if 判断里面的顺序,


一定要注意把 已知条件值前置 :? "C".equals ( xxx) , 否则当我们的 list 内包含 null 元素时, null 是无法调用 equals 方法的,会抛出空指针异常。


那么其实我们如果真的想移除 list 里面的某个 元素,移除干净 。


我们其实 用 removeAll ,就挺合适。

removeAll

list.removeAll(Collections.singleton("C"));


或者


list.removeAll(Arrays.asList("C"));


List<String> list = new ArrayList();


list.add("C");


list.add("A");


list.add("C");


list.add("B");


list.add("F");


list.add("C");


list.add("C");


System.out.println("未移除前" + list.toString());


list.removeAll(Collections.singleton("C"));


System.out.println("移除后" + list.toString());


运行结果:



这里使用 removeAll ,我想给大家提醒一点东西 !


list.removeAll(Collections.singleton("C"));?


list.removeAll(Arrays.asList("C"));


这两种写法 运行移除 C 的时候都是没问题的。


但是当 list 里面有 null 元素的时候,我们就得多加注意了, 我们想移除 null 元素的时候 ,先回顾一下 remove 这个小方法,没啥问题,使用得当即可:



意思是,remove 可以移除 空元素,也就是 null 。


但是我们看看 removeAll :




也就是说如果 list 里面有 null 元素, 我们又想使用 removeAll, 怎么办?


首先我们使用??


list.removeAll(Collections.singleton(null));



运行结果,没问题:



接着我们也试着使用


list.removeAll(Arrays.asList(null));



运行结果,报错,空指针异常:



其实是因为 Arrays.asList 这个方法 , 请看源码:



再看 new ArrayList 的构造方法,也是不允许为空的:



PS: 但是这只是构造方法的规定,千万别搞错了 ,ArrayList 是可以存储 null 元素的 。 add(null) 可没有说不允许 null 元素传入。



回到刚刚的话题, 那么我们运行没有问题的?Collections.singleton(null) 怎么就没报 空指针异常呢?


那是因为返回的是 Set, 而 Set 的构造方法也是允许传入 null 的 :




所以在使用 removeAll 的时候,想移除 null 元素, 其实只需要传入的集合里面 是 null 元素 就可以,也就是说,可以笨拙地写成这样,也是 ok 的 (了解原理就行,不是说非得这样写,因为后面还有更好的方法介绍):



从一开始的 移除? C 元素, 到 现在更特殊一点的移除 null 元素 。?


到这里,似乎已经有了一个 了解和 了结, remove 和 removeAll 使用起来应该是没啥问题。


但是本篇文章还没结束, 越扯越远。


因为我想给大家 了解更多,不废话继续说。

removeIf?

这个方法,是 java? JDK1.8 版本,?Collection 以及其子类 新引入的 。?


那既然是新引入的,肯定也是有原因了,肯定是更加好用更加能解决我们移除 list 里面的某元素的痛点了。


我们直接结合使用方式过一下这个方法吧:



移除 list 里面的 null 元素 :


list.removeIf(Objects::isNull);



运行结果:



评论

发布
暂无评论
Java 移除List中的元素,这玩意讲究!