前言
前阵子,一名java初学者 遇到了list 使用remove的问题,当时我暂且给他说了一种解决方案。
事后,我细想,
是不是很多初学者都会碰到这种问题?
虽然阿里开发手册里面有说到这个坑,但是是不是每个人都清楚?
这个错误的出现原由是什么?
怎么避免?怎么解决?
只能使用迭代器iterator 方式吗?
removeAll ? stream?removeIf ?
这篇文章里, 上面的种种疑问,都会涉及,但不限于。
因为我经常写着写着就扯远了,可能会说到一些其他东西。
正文
跟着我的思路走,耐心读完,没有收获你直接打我。
有个list :
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");
[C, A, B, C, F, C, C]
怎么移除掉list里面的某个元素呢 ?
list里面给我们提供了4个方法 :
先看 remove ( Object o) :
这个方面字面意思看,就是,你想移除list里面的哪个 Object ,你传进来就可以。
看源码,如下图:
也就是说并不是想移除哪个传哪个就能移除完, 而仅仅是只移除首个符合规则的元素。
结合例子:
现在这个List里面,存在4 个 "C" 元素 , 使用remove("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.remove("C"); System.out.println("移除后"+list.toString());
结果:
未移除前[C, A, C, B, F, C, C]
移除后[A, C, B, F, C, C]
所以,光这样使用remove是不行的,不能实现我们需求 : 移除 list中的所有符合条件的元素,仅仅移除了符合条件的第一个 元素了。
这时候,大家可能就会想,那么我们就循环+remove呗,这样就能把每个符合条件的移除了。
真的吗?
接着看。
循环 + remove ( Object o)/ remove(Index i):
没错,我们可以配合循环,把list里面的“C”元素都移除。
循环自然有分 while循环和 for循环(包含foreach) 。
先看 foreach方式 :
不得行! 切记!
for (String str: list){ if ("C".equals(str)){ list.remove(str); } }
代码看似没问题,但是在foreach 使用 list的 remove / add 方法都是不行的!
报错:
ConcurrentModificationException : 并发异常
PS: 其实如果大家曾阅读过阿里的开发规范,也许会有一点印象。
7.【强制】不要在foreach循环里进行元素的remove/add 操作。remove元素请使用Iterator方式,如果并发操作,需要对Iterator对象加锁。
那么先不管,如果你阅读过,可能也不一定知道里面的原理,所以继续往下看吧。
在分析这个错误前,我来提一嘴 ,一部分的ArrayList的特性:
ArrayList是基于数组实现的,是一个动态数组,其容量能自动增长。
ArrayList不是线程安全的。
支持快速随机访问,通过下标序号index进行快速访问。
接下来,跟着我,一起来分析这个报错的出现 (当然我的挖错方式不一定适合大家,但是也可以参考):
1. 分析出错的代码段
for (String str: list){ if ("C".equals(str)){ list.remove(str); } }
光这样看,我们只能知道,用了foreach的语法糖,那么我们看编译后的:
再看我们的报错信息:
源码分析:
通过我们反编译的代码,结合ArrayList的源码, 我们可以知道,
Itr 就是ArrayList里面的内部类,
而foreach的语法糖其实就是帮我们 new了一下 Itr,先调用hashNext()
while(var2.hasNext())
显然是作为循环的条件,那么我们也一起来简单看下这个方法源码:
1. public boolean hasNext() { 2. return cursor != size; 3. }
size是啥?
那cursor是啥?
所以,hashNext() 意思是, 当cursor 不等于 size的时候,代表 还有下一位,继续循环就完事了,这个值其实不是本次报错的重点。
我们继续看 Itr的next()方法中的 checkForComodification()方法,就是这玩意导致报错的。
那么我们直接定位到 checkForComodification()方法的源码:
代码简单, 也看到了我们刚才报的错 ConcurrentModificationException 在里面躺着。
只要modCount 不等于 expectedModCount ,就抛错。
那么我们就得明白 modCount 和 expectedModCount是啥? expectedModCount简单,是Itr里的一个属性 ,在初始化的时候,就已经把 modCount的值 等赋给了 expectedModCount。
其实 expectedModCount 就是用来记录 一开始 迭代的 list的 变更数modCount, 至于 list的 变更数modCount是啥,我们接着看。
点进去看modCount的源码:
可以看到作者真是苦口婆心,这么一个字段属性,人家写了这么多注释, 那肯定是解释得非常细致了。
那么我来抽出一些核心的 翻译一下,给各位看看:
此列表在结构上被修改的次数。结构修改是指改变结构尺寸的修改。
如果此字段的值意外更改,则迭代器(或列表迭代器)将在
对{@code next}、{@code remove}、{@code previous}的响应,{@code set}或{@code add}操作。这提供了快速失败行为,而不是迭代过程中并发修改的情况。
我来简单再说一下:
这个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);
运行结果:
再来,我们写的更加通用一点,还是移除 null 元素:
list.removeIf( o -> null ==o );
运行结果,没有问题:
我们移除的条件更多一点 ,把C元素 和 null 元素 和 ”“ 元素都移除了 :
list.removeIf( o -> null ==o || o.equals("C") || o.equals(""));
运行结果,完美执行:
removeIf 我个人比较喜欢,推荐
最后再说个移除某个元素的方法吧, stream 流结合过滤器条件使用 :
其实跟上边的removeIf差不多,只不过 stream流的filter 的用法是把 符合条件 的留下, 不符合条件的都去除 :
所以我们想把C元素 和 null 元素 和 ”“ 元素都移除了 ,要写成:
List<String> listNew = list.stream().filter( o -> !(null == o || o.equals("C") || o.equals("")) ).collect(Collectors.toList());
执行结果:
最后再罗嗦一下:
removeIf 、stream+filter 方式, 不仅仅局限于 list 中使用 。
只要父类是 Collection ,都可以用(所以处理map的时候可以把里面的EntrySet取出来使用)。
SET :
Map:
好吧,该篇暂且就到此吧。