List遍历时如何删除其子元素

每一个问题的出现都会有它的原因和背景。

List遍历何其简单,之所以会提出这个问题,是因为你可能会遇到过或者曾经写过类似下的代码:

List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
list.add("e");

list.forEach(v -> System.out.println(v));

for (String val : list) {
  if (val.equals("a") || val.equals("b")) {
    list.remove(val);
  }
}

list.forEach(v -> System.out.println(v));

如果你是上面那样写,不出意外就会报ConcurrentModificationException:

Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
	at java.util.ArrayList$Itr.next(ArrayList.java:851)
	at com.wuling.test.App.main(App.java:23)

Process finished with exit code 1

报异常的原因,可参考另一篇文章,里面有详细描述。

链接: CME异常

那我们如何安全地在遍历过程中,移除掉list中的某个元素呢?

方案一:继续使用for遍历

发生CME异常的根本原因是,在遍历过程中,你不能对List的元素进行增加、删减的改动,既然如此,可通过维护一个下标索引集合来达到删除效果,如下:

List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
list.add("e");

list.forEach(v -> System.out.println(v));

Stack<Integer> stack = new Stack();
int i = 0;

for (String val : list) {
    if (val.equals("a") || val.equals("b")) {
        stack.add(i);
    }
    i++;
}

while (!stack.empty()) {
    int idx = stack.pop();
    list.remove(idx);
}

System.out.println("移除元素后:");
list.forEach(v -> System.out.println(v));

执行结果:

a
b
c
d
e
移除元素后:
c
d
e

Process finished with exit code 0

方案一是可以实现遍历后,删除掉想要删除的元素,只是……是不是有一种多此一举的感觉?而且也并不是在遍历中删除,它是遍历后才删除,其实不算是符合要求,这里当做提供一种实现思路。

方案二:使用迭代器Iterator来删除

代码如下:

List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
list.add("e");

list.forEach(v -> System.out.println(v));

Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String val = iterator.next();
    if (val.equals("a") || val.equals("b")) {
        iterator.remove();
    }
}

System.out.println("移除元素后:");
list.forEach(v -> System.out.println(v));

执行结果:

a
b
c
d
e
移除元素后:
c
d
e

Process finished with exit code 0

分析:

通过源码分析可以了解到,for遍历过程中,移除元素之所有会报ConcurrentModificationException异常,主要是因为for遍历的底层也是间接用到了Iterator,在Iterator在获取下一个元素时(next方法),会检查一下modCount是否等于expectedModCount,如果不等则抛出CME异常。而expectedModCount是初始迭代器时就赋值好且不会改变了的,但是modCount会在list调用remove、add、clear方法后会改变,也因此会造成modCount与expectedModCount的不相等,进而抛出异常。下面简单搬一下官方源码:

ArrayList -> Itr#next():

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];
}

ArrayList -> Itr#checkForComodification():

final void checkForComodification() {
    if (modCount != expectedModCount)
       throw new ConcurrentModificationException();
}

ArrayList#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; // clear to let GC do its work

    return oldValue;
}

那为何直接使用Iterator遍历,然后移除就不会抛出异常了呢?无需多言,看下面一小段源码你就明白了。

ArrayList -> Itr#remove():

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();
    }
}

检查是否抛出异常的时机是在增删元素后的下一次调用next()时,而直接调用Iterator的remove方法,expectedModCount又被重新赋值为modCount了,因此,在下一次next()时expectedModCount仍始终等于modCount,也就不会抛出异常了。

方案三:for正序循环删除

List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
list.add("e");

list.forEach(v -> System.out.println(v));

for (int i = 0; i < list.size(); i++) {
    if (list.get(i).equals("a") || list.get(i).equals("b")) {
        list.remove(i);
        i = i - 1;
    }
}

System.out.println("移除元素后:");
list.forEach(v -> System.out.println(v));

执行结果也能如期正常。这种方案关键在于在list.remove(i)之后,要对i减1,即i = i - 1;,这也是平时容易犯的一个错误。

因为list移除一个元素后,其内部所有元素的下标索引都比原来变小了,看图就明白了:

List遍历时如何删除其子元素_第1张图片

在移除a之后,b原本的下标1就变成了0,但是在for循环中,i的值已经递增到了1,这时b就会跳过判等检测,因此我们需要把i减去1,避免在移除元素之后,漏掉某个元素的遍历或检查。

方案四:for逆序循环

List<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
list.add("e");

list.forEach(v -> System.out.println(v));

for (int i = list.size() - 1; i >= 0; i--) {
    if (list.get(i).equals("a") || list.get(i).equals("b")) {
        list.remove(i);
    }
}

System.out.println("移除元素后:");
list.forEach(v -> System.out.println(v));

执行结果也是正确的,逆序for循环处理就不需要i = i - 1;了,因为即使循环中删除了某个元素,删除前和删除后的元素下标索引没有改变,如图示:

List遍历时如何删除其子元素_第2张图片

结语

在遍历元素同时删除元素,这个操起本身很恶心,很容易出现数据上的各种问题或者奇怪现象。但是,或多或少,还真的就有这种实际业务需求,看似平常又简单的操作,对于经验不足的人来说,可能很难考虑周到,进而引发各种BUG,希望本文能帮助到你~

除了以上四种方法,你还能想到那些方法吗?

本文到这里就结束了咯~

你可能感兴趣的:(java精选,java,记录与分享,java,iterator)