• Java ArrayList在foreach中remove的问题分析


    都说ArrayList在用foreach循环的时候,不能add元素,也不能remove元素,可能会抛异常,那我们就来分析一下它具体的实现。我目前的环境是Java8。

    有下面一段代码:

    public class TestForEachList extends BaseTests {
    
        @Test
        public void testForeach() {
            List<String> list = new ArrayList<>();
            list.add("1");
            list.add("2");
            list.add("3");
    
            for (String s : list) {
            }
        }
    
    }
    

    代码很简单,一个ArrayList添加3个元素,foreach循环一下,啥都不干。那么foreach到底是怎么实现的呢,暴力的方法看一下,编译改类,用 javap -c TestForEachList查看class文件的字节码,如下:

    javap -c TestForEachList 
    Warning: Binary file TestForEachList contains collection.list.TestForEachList
    Compiled from "TestForEachList.java"
    public class collection.list.TestForEachList extends com.ferret.BaseTests {
      public collection.list.TestForEachList();
        Code:
           0: aload_0
           1: invokespecial #1                  // Method com/ferret/BaseTests."<init>":()V
           4: return
    
      public void testForeach();
        Code:
           0: new           #2                  // class java/util/ArrayList
           3: dup
           4: invokespecial #3                  // Method java/util/ArrayList."<init>":()V
           7: astore_1
           8: aload_1
           9: ldc           #4                  // String 1
          11: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
          16: pop
          17: aload_1
          18: ldc           #6                  // String 2
          20: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
          25: pop
          26: aload_1
          27: ldc           #7                  // String 3
          29: invokeinterface #5,  2            // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
          34: pop
          35: aload_1
          36: invokeinterface #8,  1            // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
          41: astore_2
          42: aload_2
          43: invokeinterface #9,  1            // InterfaceMethod java/util/Iterator.hasNext:()Z
          48: ifeq          64
          51: aload_2
          52: invokeinterface #10,  1           // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
          57: checkcast     #11                 // class java/lang/String
          60: astore_3
          61: goto          42
          64: return
    }
    
    

    可以勉强读,大约是调用了List.iterator,然后根据iterator的hasNext方法返回结果判断是否有下一个,根据next方法取到下一个元素。

    但是是总归是体验不好,我们是现代人,所以用一些现代化的手段,直接用idea打开该class文件自动反编译,得到如下内容:

    public class TestForEachList extends BaseTests {
        public TestForEachList() {
        }
    
        @Test
        public void testForeach() {
            List<String> list = new ArrayList();
            list.add("1");
            list.add("2");
            list.add("3");
    
            String var3;
            for(Iterator var2 = list.iterator(); var2.hasNext(); var3 = (String)var2.next()) {
                ;
            }
    
        }
    }
    

    体验好多了,再对比上面的字节码文件,没错

    for(Iterator var2 = list.iterator(); var2.hasNext(); var3 = (String)var2.next()) {
                ;
            }
    

    这就是脱掉语法糖外壳的foreach的真正实现。

    接下来我们看看这三个方法具体都是怎么实现的:

    iterator

    ArrayList的iterator实现如下:

    public Iterator<E> iterator() {
        return new 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
        int expectedModCount = modCount;
        //省略部分实现
    }
    

    Itr是ArrayList中的内部类,所以list.iterator()的作用是返回了一个Itr对象赋值到var2,后面调用var2.hasNext()var2.next()就是Itr的具体实现了。

    这里还值的一提的是expectedModCount, 这个变量记录被赋值为modCountmodCount是ArrayList的父类AbstractList的一个字段,这个字段的含义是list结构发生变更的次数,通常是add或remove等导致元素数量变更的会触发modCount++

    下面接着看itr.hasNext()``var2.next()的实现。

    itr.hasNext 和 itr.next 实现

    hasNext很简单

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

    当前index不等于size则说明还没迭代完,这里的size是外部类ArrayList的字段,表示元素个数。

    在看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];
            }
    
    final void checkForComodification() {
                if (modCount != expectedModCount)
                    throw new ConcurrentModificationException();
            }
    

    next方法第一步 checkForComodification(),它做了什么? 如果modCount != expectedModCount就抛出异常ConcurrentModificationException。modCount是什么?外部类ArrayList的元素数量变更次数;expectedModCount是什么?初始化内部类Itr的时候外部类的元素数量变更次数。

    所以,如果在foreach中做了add或者remove操作会导致程序异常ConcurrentModificationException。这里可以走两个例子:

        @Test(expected = ConcurrentModificationException.class)
        public void testListForeachRemoveThrow() {
            List<String> list = new ArrayList<>();
            list.add("1");
            list.add("2");
            list.add("3");
    
            for (String s : list) {
                list.remove(s);
            }
        }
    
        @Test(expected = ConcurrentModificationException.class)
        public void testListForeachAddThrow() {
            List<String> list = new ArrayList<>();
            list.add("1");
            list.add("2");
            list.add("3");
    
            for (String s : list) {
                list.add(s);
            }
        }
    

    单元测试跑过,都抛了ConcurrentModificationException

    checkForComodification()之后的代码比较简单这里就不分析了。

    倒数第二个元素的特殊

    到这里我们来捋一捋大致的流程:

    1. 获取到Itr对象赋值给var2
    2. 判断hasNext,也就是判断cursor != size,当前迭代元素下标不等于list的个数,则返回true继续迭代;反之退出循环
    3. next取出迭代元素
      1. checkForComodification(),判断modCount != expectedModCount,元素数量变更次数不等于初始化内部类Itr的时元素变更次数,也就是在迭代期间做过修改就抛ConcurrentModificationException
      2. 如果检查通过cursor++

    下面考虑一种情况:remove了倒数第二个元素会发生什么?代码如下:

    @Test
    public void testListForeachRemoveBack2NotThrow() {
        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");
        list.add("3");
    
        for (String s : list) {
            System.out.println(s);
            if ("2".equals(s)) {
                list.remove(s);
            }
        }
    }
    

    猜一下会抛出异常吗?答案是否定的。输出为:

    1
    2

    发现少了3没有输出。 分析一下

    在倒数第二个元素"2"remove后,list的size-1变为了2,而此时itr中的cur在next方法中取出元素"2"后,做了加1,值变为2了,导致下次判断hasNext时,cursor==size,hasNext返回false,最终最后一个元素没有被输出。

    如何避坑

    foreach中remove 或 add 有坑,

    • 在foreach中做导致元素个数发生变化的操作(remove, add等)时,会抛出ConcurrentModificationException异常
    • 在foreach中remove倒数第二个元素时,会导致最后一个元素不被遍历

    那么我们如何避免呢?不能用foreach我们就用fori嘛,如下代码:

    @Test
        public void testListForiMiss() {
            List<String> list = new ArrayList<>();
            list.add("1");
            list.add("2");
            list.add("3");
    
            for (int i = 0; i < list.size(); i++) {
                System.out.println(list.get(i));
                list.remove(i);
            }
        }
    

    很明显上面是一个错误的示范,输出如下:

    1
    3

    原因很简单,原来的元素1被remove后,后面的向前拷贝,2到了原来1的位置(下标0),3到了原来2的位置(下标1),size由3变2,i+1=1,输出list.get(1)就成了3,2被漏掉了。

    下面说下正确的示范:

    方法一,还是fori,位置前挪了减回去就行了, remove后i--

    @Test
        public void testListForiRight() {
            List<String> list = new ArrayList<>();
            list.add("1");
            list.add("2");
            list.add("3");
    
            for (int i = 0; i < list.size(); i++) {
                System.out.println(list.get(i));
                list.remove(i);
                i--;  //位置前挪了减回去就行了
            }
        }
    

    方法二,不用ArrayList的remove方法,用Itr自己定义的remove方法,代码如下:

    @Test
        public void testIteratorRemove() {
            List<String> list = new ArrayList<>();
            list.add("1");
            list.add("2");
            list.add("3");
    
            Iterator<String> itr = list.iterator();
            while (itr.hasNext()) {
                String s = itr.next();
                System.out.println(s);
                itr.remove();
            }
        }
    

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

    依然有 checkForComodification()校验,但是看到后面又重新赋值了,所以又相等了。

    ok,以上就是全部内容。介绍了foreach中list remove的坑,以及如何避免。

  • 相关阅读:
    Bzoj1305 [CQOI2009]dance跳舞
    Bzoj1269 [AHOI2006]文本编辑器editor
    Bzoj2957 楼房重建
    POJ1704 Georgia and Bob
    UVa11427 Expect the Expected
    POJ2096 Collecting Bugs
    Bzoj3041 水叮当的舞步
    Bzoj3894 文理分科
    Bzoj1426 收集邮票
    Bzoj1076 [SCOI2008]奖励关
  • 原文地址:https://www.cnblogs.com/chrischennx/p/9610853.html
Copyright © 2020-2023  润新知