List容器一边遍历,一边删除在一些业务逻辑上总是会存在的。

小栗子

例如有一个列表,在UI上的反馈就是用户勾选了几个需要删除的,勾选后点击确认删除;代码逻辑就是在列表中标识勾选的项,然后遍历删除;

先不说这个例子有多少实现的方法,就针对遍历删除来讨论。

  • for-each
  • for-each修订版
  • for循环
  • for循环倒序
  • 迭代器

for-each

public class ForTest {

    public static void main(String[] args) {
        List<Integer> intList = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            intList.add(i);
        }
        
        //操作检测异常:ConcurrentModificationException;for-each遍历的时候,不允许删除;
        //对应的操作就是:for-each迭代初始化时内部会有一个标签expectedModCount记录全局的modCount(数字类型),这个modCount数字是全局的,做增删的时候会+1;
        // 而for-each内部的记录标签是局部的,所以当循环跑起来的时候,记录了一个数字,remove的时候modCount会改变,则循环下一次开始的时候检测比对不同则弹出该异常
        for (Integer integer : intList) {
            if (integer == 2 || integer == 3) {
                intList.remove(integer);
            }
        }
        System.out.println(Arrays.toString(intList.toArray()));
    }
}

上面的for循环,我没有使用int来遍历而是使用Integer是因为在调用List.remove这个API时,它有重载,

  • remove(Object object)
  • remove(int index);

而我想要的是第一个,所以用了装箱Integer

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)
	at ForTest.main(ForTest.java:27)

上面的操作会产生一个关于modCount的异常:ConcurrentModificationException,解释也在代码注释中写了,这里也放一下:

  • 操作检测异常:ConcurrentModificationException;for-each遍历的时候,不允许删除;
  • 对应的操作就是:for-each迭代初始化时内部会有一个标签expectedModCount记录全局的modCount(数字类型),这个modCount数字是全局的,做增删操作的时候会+1;
  • 而for-each内部的记录标签expectedModCount是局部的,所以当循环跑起来的时候(初始化),记录了一下(expectedModCount = modCount),而remove的时候全局modCount会改变,则循环下一次开始的时候检测比对不同则弹出该异常(expectedModCount !=modCount )

for-each修订版

public class ForTest {

    public static void main(String[] args) {
        List<Integer> intList = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            intList.add(i);
        }

        //正常,因为每次循环都只走了一次,然后就用了新的List,所以避开了第二次循环时的mountCount检查
        for (Integer integer : new ArrayList<Integer>(intList)) {
            System.out.println(integer);
            if (integer == 2 || integer == 3) {
                intList.remove(integer);
            }
        }

        System.out.println(Arrays.toString(intList.toArray()));
    }
}
[0, 1, 4]

上面这个结果是符合预期的;关键点就在于for (Integer integer : new ArrayList(intList)) {};

因为每次循环都只走了一次,然后就用了新的List,所以避开了第二次循环时的modCount检查。

结果虽然是对的,但是不提倡使用,因为List的长度越长,重新开辟的新的List越多,浪费大量内存;

这个方式是在《Thinking in Java》中看一个别的例子中看到的,最开始我看到的时候把那个 new ArrayList(intList) 看漏掉了,以为就是普通的for-each;然后就觉得不可思议,这明显是玩不通的,大师还会犯这种错吗,等发现了这个关键点的时候才发现大师是玩的真六。

上面这个仅限于可能性实践讨论,不建议使用。

for循环

public class ForTest {

    public static void main(String[] args) {
        List<Integer> intList = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            intList.add(i);
        }

        //下标错位,移除了第一个,下标前进,数据前移,数据总长度会改变,所以下标会跳跃一位
        for (int i = 0; i < intList.size(); i++) {
            if (intList.get(i) == 2 || intList.get(i) == 3 || intList.get(i) == 4) {
                intList.remove(i);
            }
        }

        System.out.println(Arrays.toString(intList.toArray()));
}
[0, 1, 3]

上面的for循环理论上应该删除[2, 3, 4],还剩下[0, 1]的,但是结果是剩下了[0, 1, 3];

首先0,1跑过去肯定没问题;下标到2的时候,2删除了;然后下标跑到3,此时因为2删除了,所以下标在3的位置就是4了;于是3躲过一劫,4被删除了;此时下标跑到4,但是List的长度只为3了,for循环结束;

总的来说就是移除一位后,List的size()就变了,List删除的后一位就会下标补位前进;如果遍历下标仍然按照之前的走,就会跳跃一位,造成下标错位,误删漏删。

for循环倒序

public class ForTest {

    public static void main(String[] args) {
        List<Integer> intList = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            intList.add(i);
        }
        
        //正常,因为下标是往前移动;如果下标对应的某个数据符合条件被移除,则下标对应的数组后面的数据往前移动,但是因为下标是往前走的,前面的数据没改变,所以移除是正常的
        for (int i = intList.size() - 1; i > 0; i--) {
            if (intList.get(i) == 2 || intList.get(i) == 3 || intList.get(i) == 4) {
                intList.remove(i);
            }
        }

        System.out.println(Arrays.toString(intList.toArray()));
    }
}
[0, 1]

上面这个是正确的;和正序相比,List删除后往前补位,但是只能补到被删除元素的当前下标,而下一轮遍历,下标是往前走的,所以没有影响。

迭代器

public class ForTest {

    public static void main(String[] args) {
        List<Integer> intList = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            intList.add(i);
        }

        //迭代器是正常的,因为游标每一步都在检测是否有下一个,不依托于下标,所以既不会越界也不会下标错乱
        Iterator<Integer> iterator = intList.iterator();
        while (iterator.hasNext()) {
            Integer next = iterator.next();
            if (next == 2 || next == 3 || next == 4) {
                iterator.remove();
            }
        }

        System.out.println(Arrays.toString(intList.toArray()));

    }
}
[0, 1]

迭代器是正常的;因为游标每一步都在检测是否有下一个,不依托于下标,所以既不会越界也不会下标错乱

所以以上可行的就是三个

  • for-each的修订版
  • for循环倒序
  • 迭代器

但是在投入使用中,还是for循环倒序和迭代器。

在Java8中的Collection接口中有一个叫做removeIf的default方法,它接收一个过滤函数,内部实现就是迭代器,可以做迭代器的简写版(操作符)

default boolean removeIf(Predicate<? super E> filter) {
        Objects.requireNonNull(filter);
        boolean removed = false;
        final Iterator<E> each = iterator();
        while (each.hasNext()) {
            if (filter.test(each.next())) {
                each.remove();
                removed = true;
            }
        }
        return removed;
    }

上面迭代器的使用可以改做:

public class ForTest {

    public static void main(String[] args) {
        List<Integer> intList = new ArrayList<>();

        for (int i = 0; i < 5; i++) {
            intList.add(i);
        }

        //迭代器是正常的,因为游标每一步都在检测是否有下一个,不依托于下标,所以既不会越界也不会下标错乱
        intList.removeIf(next -> next == 2 || next == 3 || next == 4);

        System.out.println(Arrays.toString(intList.toArray()));

    }
}