List遍历时删除与迭代器(Iterator)解惑

List集合使我们非常熟悉的,ArrayList等集合为我们提供了remove()方法,但在遍历时却不能随便使用,我们我们今天便从实现层面讨论下原因以及Iterator的相关知识。

ArrayList 遍历时删除方法

for循环向后遍历的陷阱

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

从前向后for循环遍历同时如果调用ArrayList提供的remove方法的话主要你删除第一个元素后会导致后面的元素向前移动,比如你删除了第0个元素后后面的n-1个元素都向前移动一个位置,但是i的值变为了1,而实际上一开始位于index=1位置的元素已经被移动到了index=0位置上,导致漏掉部分元素。

解决办法

从list最后1个元素开始从后向前遍历。

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

增强型for循环(foreach)遇到的问题

for(String s:list){  
    if(s.equals("two")){  
        list.remove(s);  
    }  
}  

如上代码运行会报错如下

Exception in thread "main" java.util.ConcurrentModificationException  
    at java.util.AbstractList$Itr.checkForComodification(AbstractList.java:372)  
    at java.util.AbstractList$Itr.next(AbstractList.java:343)  
    at Test.main(Test.java:22)  

为什么会突然报错?我们先考虑一个问题,什么是foreach?

通过对class文件反编译,我们可以发现对于List集合,foreach实际上是调用了itearator()方式通过迭代器进行遍历。那思路就清晰了,我们来看一看ArrayList实现的Itr迭代器的next()方法源码

// ArrayList.java#Itr

public E next() {
    // 校验是否数组发生了变化
    checkForComodification();
    // 判断如果超过 size 范围,抛出 NoSuchElementException 异常
    int i = cursor; // <1> i 记录当前 cursor 的位置
    if (i >= size)
        throw new NoSuchElementException();
    // 判断如果超过 elementData 大小,说明可能被修改了,抛出 ConcurrentModificationException 异常
    Object[] elementData = ArrayList.this.elementData;
    if (i >= elementData.length)
        throw new ConcurrentModificationException();
    // <2> cursor 指向下一个位置
    cursor = i + 1;
    // <3> 返回当前位置的元素
    return (E) elementData[lastRet = i]; // <4> 此处,会将 lastRet 指向当前位置
}

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

我们在这发现了抛出的异常,也看到了抛出异常的原因modCount != expectedModCount,这个modCount和expectedModCount是怎么回事呢,我们先来看看expectedModCount

// ArrayList.java#Itr

/**
 * 下一个访问元素的位置,从下标 0 开始。
 */
int cursor;       // index of next element to return
/**
 * 上一次访问元素的位置。
 *
 * 1. 初始化为 -1 ,表示无上一个访问的元素
 * 2. 遍历到下一个元素时,lastRet 会指向当前元素,而 cursor 会指向下一个元素。这样,如果我们要实现 remove 方法,移除当前元素,就可以实现了。
 * 3. 移除元素时,设置为 -1 ,表示最后访问的元素不存在了,都被移除咧。
 */
int lastRet = -1; // index of last element returned; -1 if no such
/**
 * 创建迭代器时,数组修改次数。
 *
 * 在迭代过程中,如果数组发生了变化,会抛出 ConcurrentModificationException 异常。
 */
int expectedModCount = modCount;

// prevent creating a synthetic constructor
Itr() {}

从源码中我们可以知道,expectedModCount是Itr的1个属性,记录创建迭代器时数组的修改次数。

我们再来看看modCount又是在哪发生变化的呢?

// ArrayList.java
public E remove(int index) {
    // 校验 index 不要超过 size
    Objects.checkIndex(index, size);
    final Object[] es = elementData;

    // 记录该位置的原值
    @SuppressWarnings("unchecked") E oldValue = (E) es[index];
    // <X>快速移除
    fastRemove(es, index);

    // 返回该位置的原值
    return oldValue;
}

private void fastRemove(Object[] es, int i) {
    // 增加数组修改次数
    modCount++;
    // <Y>如果 i 不是移除最末尾的元素,则将 i + 1 位置的数组往前挪
    final int newSize;
    if ((newSize = size - 1) > i) // -1 的原因是,size 是从 1 开始,而数组下标是从 0 开始。
        System.arraycopy(es, i + 1, es, i, newSize - i);
    // 将新的末尾置为 null ,帮助 GC
    es[size = newSize] = null;
}

至此我们就明白了,ArrayList在进行增加/删除操作时会对modCount进行修改,记录修改次数,这本没什么问题,但使用itearator遍历时会进行checkForComodification()操作,从而导致modCount != expectedModCount抛出ConcurrentModificationException。

整体来说,也就是Iterator遍历时不允许并发调用ArrayList的remove/add操作进行修改,否则会抛出异常。

那我们应该怎样在遍历时进行增改操作呢?

使用迭代器进行遍历同时修改操作

Iterator<String> it = list.iterator();
while(it.hasNext()){
    String x = it.next();
    if(x.equals("del")){
        it.remove();
    }
}

如此我们便可以正常的循环及删除。可能有同学还会有疑虑为什么这样不会抛出刚才的异常呢?我们仍然可以从Itr类的remove()方法源码中找到答案。

// ArrayList.java#Itr

public void remove() {
    // 如果 lastRet 小于 0 ,说明没有指向任何元素,抛出 IllegalStateException 异常
    if (lastRet < 0)
        throw new IllegalStateException();
    // 校验是否数组发生了变化
    checkForComodification();

    try {
        // <1> 移除 lastRet 位置的元素
        ArrayList.this.remove(lastRet);
        // <2> cursor 指向 lastRet 位置,因为被移了,所以需要后退下
        cursor = lastRet;
        // <3> lastRet 标记为 -1 ,因为当前元素被移除了
        lastRet = -1;
        // <4> 记录新的数组的修改次数
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

原来,Itr提供的remove方法也是调用了ArrayList的remove方法,但是他在调用之后还修改了expectedModCount的值,这样就可以在遍历过程中分清修改操作的“敌我”啦。

iterator调用remove()方法为什么要先调用next()方法?

这也是在使用iterator时可能会让部分同学感到困惑的问题。我们看一看上文中的Itr#remove()方法可以发现,它实际上是删除lastRet指向的元素,而lastRet在每次remove调用后会默认置为-1,并将cursor指针向前走一个位置(因为由于删除元素接下来要把被删除元素后面的所有数组元素向前挪一个位置)。接下来我们再看next()方法源码,next调用后会将lastRet指向上个元素的索引,cursor指向下一个位置,所以调用remove()方法要先调用next()方法,注意,next方法返回值为上一个元素的值。

所以我们可以总结下,next方法返回的为上一个元素的值,remove删的也是上一个元素。cursur指向的是后一个元素,在发生remove后,cursor会回退一个位置从而保证遍历不漏元素。因此,也就不难理解hasNext()方法的实现逻辑了。

// ArrayList.java#Itr

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

源码看完了,我们再回过头来看看概念,相信就好理解多啦。

快速失败(fail-fast)

对于ArrayList,HashMap 这些不是线程安全的集合类,如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。这一策略在源码中的实现是通过 modCount 域,modCount 顾名思义就是修改次数,对集合内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的 expectedModCount。在迭代过程中,判断 modCount 跟 expectedModCount 是否相等,如果不相等就表示已经有其他线程修改了 Map:注意到 modCount 声明为 volatile,保证线程之间修改的可见性。

场景:java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)。

安全失败(fail-safe)

采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。
缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。
场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。

参考资料

Java快速失败(fail-fast)和安全失败(fail-safe)区别

posted @ 2020-04-24 08:39  wunsiang  阅读(9521)  评论(0编辑  收藏  举报