迭代器(Iterator)中的fail-fast机制

2023/07/01 Java Java SE

迭代器的快速失败行为应该只用于检测错误

什么是fail-fast机制

原文

The iterators returned by this class’s {@link #iterator() iterator} and {@link #listIterator(int) listIterator} methods are fail-fast if the list is structurally modified at any time after the iterator is created, in any way except through the iterator’s own {@link ListIterator#remove() remove} or {@link ListIterator#add(Object) add} methods, the iterator will throw a {@link ConcurrentModificationException}. Thus, in the face of concurrent modification, the iterator fails quickly and cleanly, rather than risking arbitrary, non-deterministic behavior at an undetermined time in the future.

Note that the fail-fast behavior of an iterator cannot be guaranteed as it is, generally speaking, impossible to make any hard guarantees in the presence of unsynchronized concurrent modification. Fail-fast iterators throw {@code ConcurrentModificationException} on a best-effort basis. Therefore, it would be wrong to write a program that depended on this exception for its correctness: the fail-fast behavior of iterators should be used only to detect bugs.

翻译

此类的 iterator() 和 listIterator(int) 方法返回的迭代器是快速失败的。如果在迭代器创建后的任何时间,列表的结构被修改,除了通过迭代器自己的 ListIterator#remove() 或 ListIterator#add(Object) 方法,迭代器将抛出一个 ConcurrentModificationException。因此,在面对并发修改时,迭代器会快速而干净地失败,而不是在未来不确定的时间冒任意的、不确定的行为的风险。

注意,迭代器的快速失败行为不能得到保证,因为它,一般来说,在非同步并发修改的情况下,不可能做出任何硬性保证。快速失败的迭代器在尽最大努力的基础上抛出 ConcurrentModificationException。 因此,编写一个依赖于这个异常的程序是错误的:迭代器的快速失败行为应该只用于检测错误。

举例

fail-fast 机制是集合世界中比较常见的错误检测机制,通常出现在遍历集合元素 的过程中 。下面通过校园生活中的一个例子来体会 fail-fast 机制。

上课前,班长开始点名。刚点到一半,这时从教室外三三两两进来若干同学,同学们起哄点错了!班长重新开始点名,点到中途,又出去几位同学,同学们又起哄说点错了,班长又需要重新遍历,这就是 fail-fast 机制。它是一种对集合(班级)遍历操作时的错误检测机制,在遍历中途出现意料之外的修改时,通过 unchecked 异常暴力地反馈出来。这种机制经常出现在多线程环境下,当前线程会维护一个计数比较器,即 expectedModCount,记录已经修改的次数。在进入遍历前,会把实时修改次数 modCount 赋值给 expectedModCount,如果这两个数据不相等,则抛出异常。java.util 下的所有集合类都是 fail-fast,而 concurrent 包中的集合类都是 fail-safe。与 fail-fast 不同, fail-safe 对于刚才点名被频繁打断的情形,相当于班长直接拿出手机快速照相,然后根据照片点名,不再关心同学们的进进出出。

什么时候会出现fail-fast?

在以下两种情况下会导致 fail-fast,抛出 ConcurrentModificationException

  • 单线程环境
    • 遍历一个集合过程中,集合结构被修改。注意,listIterator.remove() 方法修改集合结构不会抛出这个异常。
  • 多线程环境
    • 当一个线程遍历集合过程中,而另一个线程对集合结构进行了修改。

单线程

package com.czff.study.knowledge.collection;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

/**
 * @author 疾风劲草
 * @date 2022/11/30 20:13
 * @description
 */
public class SingletonTreadFailFast {
    public static void main(String[] args) {
        try {
            // 测试迭代器的remove方法修改集合结构会不会触发checkForComodification异常
            itrRemoveTest();
            System.out.println("----分割线----");
            // 测试集合的remove方法修改集合结构会不会触发checkForComodification异常
            listRemoveTest();
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

    private static void listRemoveTest() {
        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");
        list.add("3");
        for (String temp : list) {
            System.out.println(temp);
            list.remove(temp);
        }
        //foreach循环等效于迭代器
        /*Iterator<String> iterator=list.iterator();
        while(iterator.hasNext()){
            list.remove(iterator.next());
        }*/
    }

    private static void itrRemoveTest() {
        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");
        list.add("3");
        Iterator<String> iterator = list.iterator();
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
            iterator.remove();
        }
    }
}
//    结果:
//    1
//    2
//    3
//    ----分割线----
//    1
//    java.util.ConcurrentModificationException
//    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
//    at java.util.ArrayList$Itr.next(ArrayList.java:859)
//    at com.czff.study.knowledge.collection.SingletonTreadFailFast.listRemoveTest(SingletonTreadFailFast.java:31)
//    at com.czff.study.knowledge.collection.SingletonTreadFailFast.main(SingletonTreadFailFast.java:19)

从结果中可以看到迭代器 itr 的 remove 操作并没有出现 ConcurrentModificationException 异常。而集合的 remove 操作则产生了异常。

foreach循环为什么等效于迭代器?

public class FailFast {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("1");
        list.add("2");
        list.add("3");
        for (String temp : list) {
            list.remove(temp);
        }
    }
}
  • FailFast.class 文件反编译后的文件
public class FailFast {
    public FailFast() {
    }

    public static void main(String[] args) {
        List<String> list = new ArrayList();
        list.add("1");
        list.add("2");
        list.add("3");
        Iterator var2 = list.iterator(); // 迭代器

        while(var2.hasNext()) {
            String temp = (String)var2.next();
            list.remove(temp);
        }

    }
}
  • FailFast.class 可阅读的字节码指令文件
 0 new #2 <java/util/ArrayList>
 3 dup
 4 invokespecial #3 <java/util/ArrayList.<init>>
 7 astore_1
 8 aload_1
 9 ldc #4 <1>
11 invokeinterface #5 <java/util/List.add> count 2
16 pop
17 aload_1
18 ldc #6 <2>
20 invokeinterface #5 <java/util/List.add> count 2
25 pop
26 aload_1
27 ldc #7 <3>
29 invokeinterface #5 <java/util/List.add> count 2
34 pop
35 aload_1
36 invokeinterface #8 <java/util/List.iterator> count 1 // 迭代器
41 astore_2
42 aload_2
43 invokeinterface #9 <java/util/Iterator.hasNext> count 1
48 ifeq 72 (+24)
51 aload_2
52 invokeinterface #10 <java/util/Iterator.next> count 1
57 checkcast #11 <java/lang/String>
60 astore_3
61 aload_1
62 aload_3
63 invokeinterface #12 <java/util/List.remove> count 2
68 pop
69 goto 42 (-27)
72 return

多线程

/**
 * @author 疾风劲草
 * @date 2022/11/30 20:13
 * @description
 */
public class MultiTreadFailFast {
    private static List<String> list = new ArrayList<>();

    /**
     * 多线程情况测试
     */
    public static void main(String[] args) {
        list.add("1");
        list.add("2");
        list.add("3");
        // 同时启动两个线程对list进行操作!
        new ErgodicThread().start();
        new ModifyThread().start();
    }

    /**
     * 遍历集合的线程
     */
    private static class ErgodicThread extends Thread {
        @Override
        public void run() {
            int i = 0;
            while (i < 10) {
                printAll();
                i++;
            }
        }
    }

    /**
     * 修改集合的线程
     */
    private static class ModifyThread extends Thread {
        @Override
        public void run() {
            list.add("5");
        }
    }

    /**
     * 遍历集合
     */
    private static void printAll() {
        Iterator iter = list.iterator();
        while (iter.hasNext()) {
            System.out.print(iter.next() + ", ");
        }
        System.out.println();
    }
}
//    结果:
//    1, 2, 3,
//    1, 2, 3,
//    1, 2, 3,
//    1, 2, 3,
//    1, 2, 3,
//    1, 2, Exception in thread "Thread-0" java.util.ConcurrentModificationException
//    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
//    at java.util.ArrayList$Itr.next(ArrayList.java:859)
//    at com.czff.study.knowledge.collection.failfast.MultiTreadFailFast.printAll(MultiTreadFailFast.java:57)
//    at com.czff.study.knowledge.collection.failfast.MultiTreadFailFast.access$200(MultiTreadFailFast.java:12)
//    at com.czff.study.knowledge.collection.failfast.MultiTreadFailFast$ErgodicThread.run(MultiTreadFailFast.java:35)

从结果中可以看出当一个线程遍历集合,而另一个线程对这个集合的结构进行了修改,确实有可能触发 ConcurrentModificationException 异常。

fail-fast实现原理

通过对 .class 文件反编译,可以发现对于List集合,foreach 实际上是调用了 itearator() 方式通过迭代器进行遍历。断点调试进入 java.util.ArrayList.Itr#next() 方法查看源码:

/**
 * An optimized version of AbstractList.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() {}

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

    @SuppressWarnings("unchecked")
    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];
    }

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

    @Override
    @SuppressWarnings("unchecked")
    public void forEachRemaining(Consumer<? super E> consumer) {
        Objects.requireNonNull(consumer);
        final int size = ArrayList.this.size;
        int i = cursor;
        if (i >= size) {
            return;
        }
        final Object[] elementData = ArrayList.this.elementData;
        if (i >= elementData.length) {
            throw new ConcurrentModificationException();
        }
        while (i != size && modCount == expectedModCount) {
            consumer.accept((E) elementData[i++]);
        }
        // update once at end of iteration to reduce heap write traffic
        cursor = i;
        lastRet = i - 1;
        checkForComodification();
    }

    // 抛出异常原因 modCount != expectedModCount
    final void checkForComodification() {
        //    modCount字段表示这个列表在结构上被修改的次数。
        //    结构修改是指改变列表的大小,或者以某种方式扰乱列表,从而使进行中的迭代可能产生不正确的结果。
        //    该字段由iterator和listIterator方法返回的迭代器和列表迭代器实现使用。
        //    如果该字段的值发生意外变化,迭代器(或列表迭代器)将抛出ConcurrentModificationException,
        //    以响应next、remove、previous、set或add操作。
        //    这提供了快速故障行为,而不是在迭代期间面对并发修改时的不确定性行为。
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
}

modCount 什么时候发生了改变?

断点调试list.remove(temp)当 java.util.ArrayList.Itr#next() 方法执行完,进入了 java.util.ArrayList#remove(int) 方法查看源码:

public E remove(int index) {
    rangeCheck(index);

    modCount++; // 每次list集合进行删除操作的时候 modCount+1
    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;
}

当循环再次进入 java.util.ArrayList.Itr#next() 方法中的 checkForComodification() 方法时 modCount != expectedModCount 抛出 ConcurrentModificationException 异常。

为什么使用Iterator不会抛出异常呢?

断点调试 iterator.remove() 会进入 java.util.ArrayList.Itr#remove() 迭代器的删除方法,得知java.util.ArrayList.Itr 是 ArrayList 的内部类,此类定义如下,并没有实现序列化接口,无法网络传输。

private class Itr implements Iterator<E> {}

查看 java.util.ArrayList.Itr#remove() 方法源码:

public void remove() {
    if (lastRet < 0)
        throw new IllegalStateException();
    checkForComodification();

    try {
        ArrayList.this.remove(lastRet); // 此处调用 ArrayList 的删除操作 modCount+1
        cursor = lastRet;
        lastRet = -1;
         // 将 modCount 赋值给 expectedModCount,再次调用 java.util.ArrayList.Itr#next() 方法中的 checkForComodification() 方法时不会抛异常
        expectedModCount = modCount;
    } catch (IndexOutOfBoundsException ex) {
        throw new ConcurrentModificationException();
    }
}

如何避免fail-fast?

  • 普通的 for 循环,这种方案其实存在一个问题,那就是 remove 操作会改变 List 中元素的下标,可能存在漏删的情况。
public class Test {
    public static void main(String[] args) {
        List<String> userNames = new ArrayList<String>() ;
        for (int i = 0; i < 1; i++) {
            if (userNames.get(i).equals("czfff")) {
                userNames.remove(i);
            }
        }
        System.out.println(userNames);
    }
}
  • 使用迭代器的 fail-fast 机制进行遍历时的删除操作,如果是多线程并发,还需要在迭代器遍历时加锁。如下源码:
Iterator<String> iterator = list.iterator();
synchronized (对象) {
    while(iterator.hasNext()){
        if (删除元素的条件) {
            iterator.remove();
        }
    }
}
  • 使用 java.util.concurrent.CopyOnWriteArrayList 并发包下的 CopyOnWriteArrayList 代替 ArrayList,该容器内部会对 add、remove、clear 等方法进行加锁操作,是一个线程安全的 List。其实现原理在于,每次add、remove 等操作都是重新创建一个新的数组,再把引用指向新的数组。 如下源码 :
public class FailSafe {
    public static void main(String[] args) {
        List<String> list = new CopyOnWriteArrayList<>();
        list.add("1");
        list.add("2");
        list.add("3");
        for (String temp : list) {
            list.remove(temp);
        }
    }
}

认识一下COW家族

顺便简单介绍一个 COW(奶牛)家族,即 Copy-On-Write。它是并发的一种新思路,实行读写分离,如果是写操作,则复制一个新集合,在新集合内添加或删除元素。待一切修改完成之后,再将原集合的引用指向新的集合。这样做的好处是可以高并发地对 COW 进行读和遍历操作,而不需要加锁,因为当前集合不会添加任何元素。使用COW 时应注意两点:第一,尽量设置合理的容量初始值,它扩容的代价比较大;第二,使用批量添加或删除方法,如 addAll 或 removeAll 操作,在高并发请求下,可以攒一下要添加或 者删除的元素,避免增加个元素复制整个集 合。如果集合数据是 100MB,再写入 50MB,那么某个时间段内占用的内存就达到(100MB x 2)+50MB=250MB,内存的大量占用会导致GC的频繁发生,从而降低服务器的性能,我们观察如下代码:

public static void main(String[] args) {
    List<String> list = new CopyOnWriteArrayList<>();
    list.add("1");
    list.add("2");
    list.add("3");
    for (String temp : list) {
        list.remove(temp);
    }
}

循环20万次,不断地进行数据插人,这对 Cow 类型的集合来说简直是灾难性的操作,本示例执行时间为97.8秒,如果换成 ArrayList,则只需39毫秒,差距巨大!要初始化这样的 COW 集合,建议先将数据填充到 ArayList 集合中去,然后把 ArrayList 集合当成 COW 的参数,这就是使用批量添加的另一种方式。这种一个接一个往里增加元素的场景,简直就是 COW 的阿喀珫斯之踵。所以明显 COW 适用于读多写极少的场景。

COW是 fail-safe 机制的,在并发包的集合中都是由这种机制实现的, fail-safe 是在安全的副本(或者没有修改操作的正本)上进行遍历,集合修改与副本的遍历是没有任何关系的,但是缺点也很明显,就是读取不到最新的数据。这也是 CAP 理论中 C (Consistency)与 A (Availability)的矛盾,即一致性与可用性的矛盾。

参考资料

Search

    Table of Contents