再学Java基础-list遍历删除元素5种方式哪种才是正确的?

161 阅读3分钟

1 前言

基础不牢,地动山摇!!!刚学Java基础那会,使用list循环删除总是搞不清楚哪种方式是正确的,只有运行代码去尝试每一种写法才知道结果,写多了以后就记住了要用迭代器的方式去删除,但是只知其然,而不知其所以然,偶尔有那么一两次翻开源码查看其原理,却总是蜻蜓点水,过一段时间又忘了,所以现在决定记录一下加深印象。

2 错误的遍历删除的方式

日常开发中最常用的集合可能就是ArrayList了,所以下面给出的例子都是以ArrayList示例,但集合类的遍历删除操作基本都相似,所以掌握了ArrayList的正确删除元素方式那么其他集合类就差不多掌握了。

2.1 for-each遍历删除方式

ArrayList<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(4);
for (Integer e : list) {
    if (e == 2) {
        list.remove(e);
    } else {
        System.out.println(e);
    }
}

这种方式是最常见的错误了,大部分初学者应该都犯过这样的错,它会抛出下面这个异常。

Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
	at java.util.ArrayList$Itr.next(ArrayList.java:859)

细心的你应该从异常信息发现个比较奇怪的地方,为什么抛出的异常是ArrayList$Itr.next开始的?实际上这种方式的遍历只是个语法糖,只要是实现了Iterable接口就可以使用,但本质上是调用Iterable的iterator方法,之后返回了一个迭代器,调用hasNext和next方法,因此这个错是调用next方法抛出来的异常,我们继续从源码上分析一遍。

// 返回了一个迭代器
public Iterator<E> iterator() {
    return new Itr();
}

iterator方法返回了一个ArrayList的内部类Itr

private class Itr implements Iterator<E> {
    int cursor;       // index of next element to return
    int lastRet = -1; // index of last element returned; -1 if no such
    // 把modCount赋值给expectedModCount,modCount表示ArrayList被修改的次数,
    // 也就是说调用remove和add方法时modCount的值会变
    int expectedModCount = modCount;

    Itr() {}

    public boolean hasNext() {
        return cursor != size;
    }

    @SuppressWarnings("unchecked")
    public E next() {
        // 检查是否并发修改
        checkForComodification();
        int i = cursor;
        if (i >= size)
            throw new NoSuchElementException();
        Object[] elementData = ArrayList.this.elementData;
        if (i >= elementData.length)
            throw new ConcurrentModificationException();
        cursor = i + 1;
        return (E) elementData[lastRet = i];
    }

    public void remove() {
        if (lastRet < 0)
            throw new IllegalStateException();
        // 检查是否并发修改
        checkForComodification();

        try {
            ArrayList.this.remove(lastRet);
            cursor = lastRet;
            lastRet = -1;
            expectedModCount = modCount;
        } catch (IndexOutOfBoundsException ex) {
            throw new ConcurrentModificationException();
        }
    }

    // 省略部分非重点源码。。。

  
    final void checkForComodification() {
        // modCount被修改时抛出异常
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
}

可以看到expectedModCount被赋予了modCount的初始值,在Itr的next和remove方法上都调用了checkForComodification方法,如果modCount被修改了就会抛出异常,但是看到这里是不是又有一个疑问,从上面的源码看不管调用next方法还是remove方法modCount的值都没有变啊,那从上面异常信息看不是调next方法时发生的吗?别急,我们慢慢说来,回到给出的迭代遍历删除的例子,实际上这里调用的是ArrayList的remove方法,这里才是关键!!我们看下ArrayList的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被修改了
    modCount++;
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--size] = null; // clear to let GC do its work
}

当调用ArrayList的remove方法时,modCount会被修改,所以在例子中当代码执行了list.remove后,modCount的值就不等于expectedModCount了,因此等下一次循环调用next方法时就会抛出异常。你以为到这里就完了吗?No、No、No,在这里我还想补充的一点,当删除的是倒数第二个元素时,还会抛出这样的异常吗?答案是否定的。

    public boolean hasNext() {
        // cursor不等于size返回true,size是ArrayList的成员变量,
        // 调用一次ArrayList的remove方法会减小1,cursor是Itr的成员变量。每次调用next方法就会加1
        return cursor != size;
    }

这里关键看hasNext方法,删除倒数第二个元素后,size就减1了,此时的cursor是等于size的,因此这时hasNext方法就返回false,不会往下执行了。最后再提个问题,当删除的是最后一个元素时,会不会抛出异常?可以自行分析下(别嫌我啰嗦,细节才是魔鬼)

2.2 for i 遍历删除方式

通过上面的分析,我们知道list for-each遍历删除会抛出ConcurrentModificationException异常,下面的方式不会抛出异常,因为这种方式并不会调用迭代器的next方法。

ArrayList<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
for (int i = 0; i < list.size(); i++) {
    String e = list.get(i);
    if (e.equals("b")) {
        list.remove(e);
    } else {
        System.out.println(e);
    }
}

虽然并不会抛出异常,但也不是完美的,这个例子list的size等于4,当删除元素b时,list.size就变为3,此时的i等于1,下次循环i已经变成2了,直接跳过了c(c的下标变成2了),所以结果没有打印元素c,开发中这种方式我们也是要避免的。

2.3 forEach lambda方法遍历删除方式

ArrayList<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
list.forEach(e -> {
    if (e.equals("b")) {
        list.remove(e);
    } else {
        System.out.println(e);
    }
});

这种方式也会抛出异常,直接来看源码

@Override
public void forEach(Consumer<? super E> action) {
    Objects.requireNonNull(action);
    final int expectedModCount = modCount;
    @SuppressWarnings("unchecked")
    final E[] elementData = (E[]) this.elementData;
    final int size = this.size;
    for (int i=0; modCount == expectedModCount && i < size; i++) {
        action.accept(elementData[i]);
    }
    if (modCount != expectedModCount) {
        throw new ConcurrentModificationException();
    }
}

代码的最后一步判断modCount是否等于expectedModCount,不等于则抛出异常。

3 正确的遍历删除的方式

上面分析了几种错误的遍历删除方式,下面看看怎样才是正确的。

3.1 迭代器Iterator遍历删除的方式

ArrayList<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String next = iterator.next();
    if (next.equals("b")) {
        iterator.remove();
    } else {
        System.out.println(next);
    }
}

在2.1中已经对迭代器的方式分析过了,iterator.remove()和ArrayList.remove()是有区别的,前者modCount是不会变的,后者是会变的,至于为什么这样设计,我自己也还没想明白。

3.1 Stream过滤的方式

ArrayList<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
List<String> newList = list.stream().filter(e -> !e.equals("2")).collect(Collectors.toList());
for (String e : newList) {
    System.out.println(e);
}

jdk8或以上版本用的最多的应该是这种方式了,也是我个人比较推荐的一种方式,返回新的list,无副作用。stream的源码比较复杂,不是本文分析的重点,以后有时间再研究研究。

4 总结

下面通过一张脑图来总结本文的内容

uTools_1686154268268.png