那些年,我们在Java ArrayList Remove方法遇到的坑

来源:互联网 发布:易企秀电脑版 mac 编辑:程序博客网 时间:2024/05/20 00:50

我们经常会使用ArrayList的remove方法删除元素,看起来是很简单的调用,但是真的是机关重重。

1. 删除jdk中的类对象

我们先来创建一个ArrayList数组列表

ArrayList<Integer> array = new ArrayList<>();array.add(2);array.add(2);array.add(1);array.add(1);array.add(3);array.add(3);

当遍历这个数组列表,可以依次打印2,2,1,1,3,3。好,现在要完成一个功能,删除这个array里面所有为1的元素。思路如下:for循环遍历数组列表,如果元素为1,就remove掉

- 使用for循环遍历

public void remove(ArrayList<Integer> list) {    Integer in = 1;    for (int i = 0; i < list.size(); i++) {        Integer s = list.get(i);        if ( s.equals(in) )         {            list.remove(s);        }    }}

看上去我们的代码很完美,在理论上肯定会达到预期的结果,但是当遍历删除后的array,会发现,打印结果为2,2,1,3,3。怎么还有一个1没删除呢,现在我们来看在这段代码中用到的remove方法

public boolean remove(Object o) {    if (o == null) {        for (int index = 0; index < size; index++)            if (elementData[index] == null) {                fastRemove(index);                return true;            }    } else {        for (int index = 0; index < size; index++)            if (o.equals(elementData[index])) {                fastRemove(index);                return true;            }    }    return false;}private void fastRemove(int index) {    modCount++;    int numMoved = size - index - 1;    if (numMoved > 0)        System.arraycopy(elementData, index+1, elementData, index,                         numMoved);    elementData[--size] = null; // Let gc do its work}

先不用管remove方法中的逻辑是怎么样的,看代码可以发现,最终删除元素的操作是fastRemove这个方法执行的。具体来看fastRemove中的代码,它把要删除的那个元素之后的所有元素都往前移了一个位置,然后把最后空出来的位置设置为null,删除的原理是直接覆盖被删除元素的空间位置。具体解释一下:一开始数组列表是2,2,1,1,3,3, 当循环到下标为2,也就是第一个“1”元素出现时,执行remove,而具体删除元素的操作是fastRemove执行的,fastRemove会将第一个“1”元素后面所有的元素都往前移一位,这样第一次remove后的数组就变成了2,2,1,3,3。第二个“1”元素代替了原先第一个“1”的位置,也就是下标为2的位置,但是我们下一次循环比较的是下标为3的元素,如果不往前移,就是比较第二个“1”,不会出现任何问题。现在这个“1”往前移了,就会比较“3”元素,正好漏掉了第二个“1”,所以会出现那样的结果。这样看来上面的代码还有一个注意点,就是for循环的条件,既然数组列表的size是会变的,那我们就不能这样写,不然会抛出IndexOutBoundsException

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

现在我们把数组列表里的元素换一换,换成2,2,1,1,3,3,1。还是执行以上的代码,会发现删除后的结果为:2,2,3,3,1。这很奇怪,如果按照上面的逻辑,当第一个“1”被删除之后,第二个“1”往前移,应该漏掉的是第二个“1“啊,怎么反而第二个“1”没漏掉,第三个漏掉了。现在我们仔细看一下remove方法,看else部分,当我们把要删除的元素以参数的形式传进来之后,remove方法内部居然又做了一次循环。看代码可以知道这个循环的功能是删除数组中首次出现的与参数相同的元素。这就解释了为什么在循环到最后一个“1”时删除的却是第二个“1”了。

我们将上面的删除代码稍微修改一下,将list.remove(s);改为list.remove(i);

public void remove(ArrayList<Integer> list) {    Integer in = 1;    for (int i = 0; i < list.size(); i++)     {        Integer s = list.get(i);        if ( s.equals(in) )         {            list.remove(i);        }    }}

原来的代码是根据具体的Integer对象来删除元素,现在改为根据下标来删除元素。可以这么改,是因为ArrayList提供了两个remove方法,remove(Object o)和remove(int index)。现在来看一下执行的结果,初始的数组元素还是2,2,1,1,3,3,1。删除后的数组为2,2,1,3,3。两个remove方法执行的结果却是不一样的,好神奇。来看一下这个remove方法的源码

public E remove(int index) {    rangeCheck(index);    modCount++;    E oldValue = elementData(index);    int numMoved = size - index - 1;    if (numMoved > 0)        System.arraycopy(elementData, index+1, elementData, index,                         numMoved);    elementData[--size] = null; // Let gc do its work    return oldValue;}

原来这个remove方法不是删除首次出现的指定元素,而是下标传进来是什么,就删除那个下标的元素,但还是会出现有元素漏删的情况。先总结一下两个remove方法的功能,再讨论一下如何去避免元素漏删。

  • remove(Object o)移除此列表中首次出现的指定元素(如果存在)。
  • remove(int index) 删除此列表中指定位置上的元素。

这里我想插几句,嘿嘿嘿

int a = 1;remove(a);Integer b = 1;remove(b);

这两次调用到底访问的是哪个remove方法呢,第一次remove会不会a自动装箱成Integer类型,访问remove(Object o)呢?第二次remove会不会b自动拆箱成int类型,访问remove(int index)呢?
答案肯定是否定的,编译器在编译的时候,发现执行remove(a),有相应的remove方法与之匹配,就不会进行自动装箱这个操作了。(自动拆装箱发生在编译期)[可以看一下我另一篇文章 使用Java自动拆装箱出现的一些意外和原理]

言归正传,怎么去避免元素漏删的问题呢?既然删除后的元素都是往前面移动位置,那么我们可以倒序遍历列表

public void remove2(ArrayList<Integer> list) {    Integer in = 1;    for (int i = list.size() - 1; i >= 0; i--)     {          Integer s = list.get(i);           if ( s.equals(in) )            {            list.remove(i);           }    }}

这里我使用的remove方法是remove(int index),因为我在自定义的方法里已经通过for循环找到要删除的元素是哪个了,那就根据下标直接删除好了,另一个remove方法还要在内部再次for循环,就显得多此一举了,还影响性能。

有些人不喜欢用for循环,喜欢用foreach,那么我们用foreach再次上演上面的错误

- 使用foreach

public void remove3(ArrayList<Integer> list) {    Integer in = 1;    for (Integer i : list)     {        if ( i.equals(in) )         {            list.remove(i);        }    }}

运行发现,这玩意居然报错了java.util.ConcurrentModificationException。
先看一下反编译后的代码

Integer integer = Integer.valueOf(1);Iterator iterator = arraylist.iterator();do{    if(!iterator.hasNext())        break;    Integer integer1 = (Integer)iterator.next();    if(integer1.equals(integer))        arraylist.remove(integer1);} while(true);

foreach其实是根据list对象创建一个Iterator对象,用这个迭代对象去遍历列表,如果要对list进行增删操作,都是要经过Iterator的。Iterator有两个重要的方法,hasNext判断是否有下一个元素,next返回下一个元素。看来问题出在next代码上,看看ArrayList的父类AbstractList是怎么实现这个方法的

public E next() {    checkForComodification();    try {        int i = cursor;        E next = get(i);        lastRet = i;        cursor = i + 1;        return next;    } catch (IndexOutOfBoundsException e) {        checkForComodification();        throw new NoSuchElementException();    }}final void checkForComodification() {    if (modCount != expectedModCount)        throw new ConcurrentModificationException();}

终于找到异常抛出的地方了(checkForComodification),next方法会先检查modCount和expectedModCount是否相等。modCount表示list对象从new出来之后到现在被修改的次数,使用add和remove方法,这个值都会相应的增加和减少;expectedModCount指的是Iterator现在期望list被修改的次数是多少次。

原来的数组列表是2,2,1,1,3,3,1, modCount和expectedModCount相等,当删除了第一个“1”元素时,modCount会自动减一,当进行下一次next时,modCount和expectedModCount肯定就不相等了,也就会报错。
但是也是会有不报错的时候的,当删除的是倒数第二个元素,且这个元素在这个列表中是第一次出现,例如:2,2,1,1,3,3,4,1要删除4这个元素。为什么会出现这种情况呢,因为删了4之后,就不会再去检查4后面的1了,看hasNext代码,此时cursor和size()正好相等。即使这没有报错,但还是会把最后一个元素漏删,如果数组是2,2,1,1,3,3,4,4,最后一个4就被漏删了

int cursor = 0;public boolean hasNext() {    return cursor != size();}具体cursor如何增加可以看next方法

所以,要使用ArrayList的remove方法时就不要用foreach

还是使用Iterator中的remove方法去代替ArrayList中的remove吧

public void remove4(ArrayList<Integer> list) {    Integer in = 1;    Iterator<Integer> it = list.iterator();    while (it.hasNext())     {        Integer s = it.next();        if (s.equals(in))         {            it.remove();        }    }}

到这里,你以为ArrayList的remove里的陷阱已经被挖透了么,这还远远没有,以上都是jdk自带的类。现在来试试remove自定义的类对象

2. 删除自定义的类对象

class Student{    private String name;    private int id;    public Student(String name, int id) {        this.name = name;        this.id = id;    }    @Override    public String toString() {        return "Student [name=" + name + ", id=" + id + "]";    }}ArrayList<Student> array = new ArrayList<>();array.add(new Student("zhang1",1));array.add(new Student("zhang2",2));array.remove(new Student("zhang1",1));

打印结果,会发现对象并没有被删掉,看remove方法中有一行代码是这样的,if (o.equals(elementData[index])) 这里的equals调用的是Object类中的equals方法,而Object中的equals比较的是二者的引用,那就肯定删不掉了,这时需要重写equals方法(Integer也是重写了equals方法)

class Student{    private String name;    private int id;    public Student(String name, int id) {        this.name = name;        this.id = id;    }    @Override    public String toString() {        return "Student [name=" + name + ", id=" + id + "]";    }    @Override    public boolean equals(Object otherObject){        if(this == otherObject){return true;}        if(otherObject == null){return false;}        if(getClass() != otherObject.getClass()){return false;}        Student other = (Student)otherObject;        return Objects.equals(name,other.name) && id == other.id ;    }}

结果发现,id为1,name为zhang1的学生被删掉了

总结:慎用ArrayList的remove方法

原创粉丝点击