Java集合中的快速失败和失败安全

440 阅读8分钟

快速失败和失败安全是容错机制

Java集合-快速失败(fail-fast)


何为快速失败

维基百科上对快速失败(fail-fast)的介绍是:

In systems design, a fail-fast system is one which immediately reports at its interface any condition that is likely to indicate a failure. Fail-fast systems are usually designed to stop normal operation rather than attempt to continue a possibly flawed process.

简单来说就是系统运行中,如果有错误发生,那么系统立即结束,而不是继续冒不确定的风险继续执行,这种设计就是"快速失败"。

Java集合迭代器的快速失败机制

Java集合框架中的一些集合类的迭代器也是被设计为快速失败的。集合迭代器中的快速失败机制是说:

在使用迭代器遍历集合时,如果迭代器创建之后,通过 除了迭代器提供的修改方法之外 的其他方式对集合进行了结构性修改(添加、删除元素等),那么迭代器应该抛出一个ConcurrentModificationException异常,表示在此次遍历中集合发生了"并发修改",应该提前终止迭代过程。因为在迭代器遍历集合的过程中,如果有别的行为改变了集合本身的结构,那么迭代器之后的行为可能就是不符合预期的,可能会出现错误的结果,所以提前检测并抛出异常是一个更好的做法。

Java中大部分基本的集合类的迭代器都实现了快速失败机制,包括ArrayListLinkedListVectorHashMapHashSet等等。但是对于并发集合类,例如ConcurrentHashMapCopyOnWriteArrayList等,这些类本身就是设计来支持并发的,是线程安全的,所以也不存在快速失败这一说。

ArrayList为例,ArrayList (Java Platform SE 8 )中对fail-fast的介绍如下:

The iterators returned by this class's iterator and 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 remove or add methods, the iterator will throw a 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.

ArrayList的iterator和listIterator方法返回的迭代器都是fail-fast的:如果在迭代器创建之后,通过除了迭代器自身的add/remove方法之外的其他方式,对ArrayList进行了结构性修改(添加、删除元素等),那么该迭代器应该抛出一个ConcurrentModificationException异常。所以,迭代器在面对并发修改时,迭代器将快速而干净地失败,而不是冒着在未来不确定的时间发生不确定行为的风险继续执行。

快速失败的现象

现象:在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了增加、删除、修改操作,则会抛出ConcurrentModificationException。

快速失败的实现原理

原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出ConcurrentModificationException异常,终止遍历。

注意:这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。

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

ArrayList为例来说明快速失败机制的实现原理,其他类的实现方式也是大致相同的。

  1. ArrayList中有一个属性modCount,是一个记录ArrayList修改次数的计数器。
  2. ArrayListiterator/listIterator方法被调用时,会创建出Iteartor/ListIterator的实现类 ArrayList.Itr/ArrayList.ListItr迭代器对象,其中有一个属性expectedModCount。当迭代器被创建时,ArrayList本身的modCount将被复制给Itr/ListItr中的expectedModCount属性。
  3. 当使用迭代器的remove/add方法增删元素时,在修改ArrayListmodCount之后,还会将其值复制给迭代器自身的expectedModCount。而通过ArrayListremove/add方法增删元素时,仅仅修改了ArrayListmodCount
  4. 当迭代器执行next/remove/add/set操作时是会检查迭代器自身的expectedModCountArrayListmodCount是否相等,如果不相等则会抛出ConcurrentModificationException异常。

所以在迭代过程中,如果只使用迭代器的remove/add方法增删元素,是不会出现问题的,因为在增删元素之后迭代器始终会将ArrayListmodCount值赋值给迭代器自身的expectedModCount,所以下次迭代两者一定相等。而如果迭代过程中使用了ArrayListremove/add方法增删元素,或者有另外一个迭代器进行了增删元素,就会造成ArrayList中的modCount与迭代器中的expectedModCount不一致,抛出ConcurrentModificationException异常。

快速失败的"bug"

快速失败机制也不能够保证百分之百生效,例如,在下面这段代码中,使用迭代器遍历ArrayList过程中,使用ArrayListremove方法删除倒数第二个元素,程序能够正确删除,并不会像我们上面所说的抛出ConcurrentModificationException异常。

List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
    Integer item = iterator.next();
    if (item == 2) {
        list.remove(item);
    }
}
list.forEach(System.out::println);  // 输出 1 3

这个"bug"发生的原因在于,在第二次执行iterator.next()后,迭代器记录的下一次将要访问的下标应该是2,而在执行list.remove()删除元素后,listsize变为了2,所以在下次执行iterator.hasNext()时认为已经没有元素要继续迭代了,返回false,结束循环。

所以fail-fast机制并不能够完全保证所有的并发修改的情况都抛出ConcurrentModificationException异常,在程序中也不应该依赖于这个异常信息。 在ArrayList (Java Platform SE 8 )中也指出了fail-fast这一性质

Java集合-失败安全

现象:采用失败安全机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。

原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发ConcurrentModificationException。

缺点:基于拷贝内容的优点是避免了ConcurrentModificationException,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。这也就是他的缺点,同时,由于是需要拷贝的,所以比较吃内存。

场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。

典型实践


源码中的实现

如果看过阿里的《JAVA开发手册》,会知道里面有这一条规范:

【强制】不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator方式,如果并发操作,需要对 Iterator 对象加锁。

上面说的foreach循环指的上面我们提到的使用增强for循环进行遍历的方式。这种方式本质上使用的是迭代器方式。所以更明确一点,这个规范其实就是在说:

在使用Iterator迭代器遍历集合过程中,不要通过集合本身的remove/add方法来进行元素的 remove/add 操作,remove请使用Iterator提供的remove方法。

正确方式:

List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
    Integer item = iterator.next();
    if (item == 1) {
        iterator.remove();
    }
}
list.forEach(System.out::println); // 输出 2 3

错误方式:

List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
for (Integer item : list) {  // 会在这里抛出异常
    if (item == 1) {
        list.remove(item);  // 使用 list.remove 删除
    }
}
list.forEach(System.out::println);

上述代码会在第五行抛出java.util.ConcurrentModificationException异常。因为其本质还是使用迭代器遍历,所以为了方便理解其原因,我们将上述方式改写为:

List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
    Integer item = iterator.next();  // 会在这里抛出异常
    if (item == 1) {
        list.remove(item);  // 使用 list.remove 删除
    }
}
list.forEach(System.out::println);

这段代码会在第7行抛出java.util.ConcurrentModificationException异常。与第一种方式的差别仅仅在于使用了List.remove方法而不是Iterator.remove方法。

Dubbo中的快速失败

快速失败对应的实现类是:org.apache.dubbo.rpc.cluster.support.FailfastClusterInvoker启用该实现类,只需要在Dubbo xml中指定cluster属性为failfast

  • FailfastClusterInvoker 只会进行一次调用,失败后立即抛出异常。适用于幂等操作,比如新增记录。

实现类的源码如下:

dubbo中的快速失败.png

Dubbo中的失败安全

失败安全对应的实现类是:org.apache.dubbo.rpc.cluster.support.FailsafeClusterInvoker启用该实现类,只需要在Dubbo xml中指定cluster属性为failsafe:

  • FailsafeClusterInvoker 是一种失败安全的 Cluster Invoker。所谓的失败安全是指,当调用过程中出现异常时,FailsafeClusterInvoker 仅会打印异常,而不会抛出异常。适用于写入审计日志等操作。

实现类的源码如下:

dubbo中的失败安全.png