遍历集合的过程中可以删除元素吗?

537 阅读5分钟

增强for循环

很多初学者都有过这样的经历,在for循环遍历一个集合时,有时删除其中的某一个元素会报错,示例代码如下:

public class Test {
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>() {
            {
                add("a");
                add("b");
                add("c");
            }
        };
        for (String s : list) {
            if (s.equals("a")) {
                list.remove(s);
            }
        }

    }
}

输出:Exception in thread "main" java.util.ConcurrentModificationException
	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911)
	at java.util.ArrayList$Itr.next(ArrayList.java:861)
	at com.hard.qz.kayakta.test.Test.main(Test.java:20)

根据报错提示,我们可以发现,我在调用ArrayList的内部类Itr的next()方法,next()方法调用了checkForComodification()方法报错了。但是我们并没有去调用next方法。通过查询Test类编译后的字节码文件发现,增强for循环在编译后,被优化为迭代器Iterator遍历的方式。

//上述源码编译后的字节码
Iterator var2 = list.iterator();

while(var2.hasNext()) {
    String s = (String)var2.next();
    if (s.equals("a")) {
        list.remove(s);
    }
}

至此,我们找到了调用Itr类的next()方法的原因,但是为什么会报错呢?我们看下checkForComodification()方法体的内容

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

modCount变量是ArrayList从AbstractList类继承来的,它用来记录list被修改的次数,每次执行add()方法或remove方法时,都会对modCount+1。

expectedModCount变量是内部类Itr的成员变量,它的初始值等于modCount。

看到这里我恍然大悟了,原来这个方法是为了防止,在用Iterator迭代器遍历List时,有线程对List进行修改做的安全性校验。

但为什么有时候使用增强for循环删除元素不会报错呢?示例代码如下:

public class Test {
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>() {
            {
                add("a");
                add("b");
                add("c");
            }
        };
        for (String s : list) {
            if (s.equals("b")) {
                list.remove(s);
            }
        }
        System.out.println(list);
    }
}

输出:[a, c]

我们来进入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; // clear to let GC do its work
}

原来我们每调用remove删除一个元素,底层都会调用System.arraycopy方法拷贝元素。在上述代码中,删除前的元素在内存中的格式为**["a","b","c"],删除后的元素格式为["a","c",null]**,且数组的size被修改为2了。

从remove()方法中没有看什么端倪,此时我们将目光转向iterator的hasNext()方法。

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

这里的size是数组的元素个数,cursor是什么呢?

private class Itr implements Iterator<E> {
    int cursor;       // index of next element to return
    ...
}

从源码注释中,可以看出cursor是指向返回的下一个元素的索引。

在遍历到元素b时,cursor为2,删除b后,c被拷贝到原来b的位置,size减小为2,cursor依然是2,此时执行hasNext()方法,cursor == size,hasNex()方法返回了false,还没有执行next()方法遍历到元素c,程序就退出了while循环,所以删除b元素没有报错。至此我们找到了在增强for循环中删除倒数第二个元素不报错的原因---即因为执行remove后,size-1,正好等于cursor,所以最后一个元素就不会被遍历了。

总结:增强for循环在编译后,是Iterator迭代器遍历的形式,虽然有时删除特定位置上的元素不会报错,但是我们也不能在for增强循环中增删改元素。

普通for循环

普通for循环中删除元素的代码示例:

public class Test {
    public Test() {
    }

    public static void main(String[] args) {
        List<String> list = new ArrayList<String>() {
            {
                this.add("a");
                this.add("b");
                this.add("c");
            }
        };

        for(int i = 0; i < list.size(); ++i) {
            if (((String)list.get(i)).equals("c")) {
                list.remove(i);
            }
        }

        System.out.println(list);
    }
}

输出:[a, b]

通过和增强for循环对比,普通for循环编译后还是原来的模样,没有被优化为Iterator的形式。所以在普通for循环中删除或新增元素并不会报错。

但是当删除两个相邻且重复的元素时,会有漏删的问题,示例代码如下:

public class Test {
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>() {
            {
                add("a");
                add("b");
                add("b");
                add("c");
            }
        };
        for (int i = 0; i < list.size(); i++) {
            if(list.get(i).equals("b")){
                list.remove(i);
            }
        }
        System.out.println(list);
    }
}
输出:[a, b, c]

我们预期输出的结果应该为[a,c],为什么有一个b元素没有被删除呢?

再次点开remove()方法我们发现

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

当遍历到第一个b元素时,程序执行了remove操作,后面的b和c往前挪了一个位置,第二个b占了第一个b的位置,所以删除完之后,for循环的指针指向的是第二个b元素,接着执行i++,进入下次循环,访问到c元素,跳过了第二个b元素,所以导致了漏删。

总结:当不删除相邻重复的元素时,在for循环体中执行删除操作是没有问题的。但是我们仍然不建议在普通for循环中删除元素,我们推荐使用Interator迭代器去删除元素。如果非要使用普通for循环删除元素时,执行remove操作后,需要进行i--操作。

Iterator迭代器

Iterator是rt包下的一个接口,其下有两个抽象方法,两个有默认实现的方法。

public interface Iterator<E> {
	//判断是否有元素
    boolean hasNext();
	//获取下一个元素
    E next();
	//删除元素,默认是不允许的
    default void remove() {
        throw new UnsupportedOperationException("remove");
    }

    default void forEachRemaining(Consumer<? super E> action) {
        Objects.requireNonNull(action);
        while (hasNext())
            action.accept(next());
    }
}

在ArrayList中有一个内部类实现了Iterator接口

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
    int expectedModCount = modCount;

    Itr() {}
    ...
    //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();
    }
  }
}
public class Test {
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>() {
            {
                add("a");
                add("b");
                add("b");
                add("c");
            }
        };
        Iterator<String> iterator = list.iterator();
        while(iterator.hasNext()){
            String next = iterator.next();
            if(next == "b"){
                iterator.remove();
            }
        }
        System.out.println(list);
    }
}
输出:[a, c]

可以看到,当我们使用Iterator删除任意元素时,都不会报错且结果符合我们预期。

最后我们回答下标题提出的问题,遍历集合的过程中可以删除元素吗?可以,但是必须使用Iterator迭代器删除元素。

Iterator抛出异常是快速失败(fail-fast)的体现。

什么是快速失败呢?

快速失败是指在遍历的过程中,如果当前线程发现容器中的元素被修改了,就直接抛出异常,这里抛出的是concurrentModificationException(并发修改异常),java.util包下的hashMap、ArrayList等集合都是采用fail-fast的方式。而ConcurrentHashMap、CopyOnWriteArrayList是采用fail-safe的方式,这些集合在遍历的过程中允许修改元素,但是它遍历的是原有集合的副本。