「Fail-Fast与Fail-Safe机制」

1,027 阅读7分钟

「Fail-Fast与Fail-Safe机制」

Offer 驾到,掘友接招!我正在参与2022春招打卡活动,点击查看活动详情

写在前面

最近在刷题的过程中又重新熟悉一遍常用的数据结构,发现对Fail-FastFail-Safe机制有点模糊了,这里重新整理一下,加深一下印象。提醒在平时开发过程中严谨处理数据结构相关的内容。

文档中的注释

Note that this implementation is not synchronized. If multiple threads access a linked list concurrently, and at least one of the threads modifies the list structurally, it must be synchronized externally. (A structural modification is any operation that adds or deletes one or more elements; merely setting the value of an element is not a structural modification.) This is typically accomplished by synchronizing on some object that naturally encapsulates the list. If no such object exists, the list should be "wrapped" using the Collections.synchronizedList method. This is best done at creation time, to prevent accidental unsynchronized access to the list: List list = Collections.synchronizedList(new LinkedList(...));

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.

摘自于LinkedList中的Javadoc,简述了LinkedList的实现并不是线程安全的。在多线程的场景下操作LinkedList时,并且改变了其结构(结构上的改变简单的理解就是添加,或者删除了元素,对元素本身的值修改不属于结构的改动),此时需要同步给予其他线程。也就是说需要采用同步锁的方式,如果没有相应的同步机制。可以使用Collections下的synchronizedList对其包裹。主要是为了构建迭代器iterator。而对数据结构的修改(add、remove)必须使用这个迭代器iterator自身的addremove方法。并且它们采用了fail-fast机制,也就是说不按照这个规则来操作数据结构就会报ConcurrentModificationException,并发修改异常。简单的总结可以得出结论(非线程安全的数据结构):

  • 非线程安全的线性数据结构,并发修改时(对结构的改动)必须使用iterators中的方法(add、remove)。
  • 并发修改不一定仅仅是多线程的情况下才会发生,单线程情况下,遍历时作删除操作同样会报ConcurrentModificationException。(使用本身自带的add、remove方法)。
  • 由于是Fail-Fast的机制,可以使用自身定义的同步机制,或者使用Collections对其包裹来并使用iterators来操作数据结构。
什么是Fail-Fast?

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 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.

简单的来说,fail-fast仅仅是一种检测机制,当在程序开发的过程中因为粗心写出了类似这种结构修改而未使用任何同步方法的情况下直接抛出异常来提醒开发人员。既然是一种提醒机制,也就决定了其局限性(并不能百分之百的检测出),要求开发者在实际开发过程中不能依赖此机制来保证代码的健壮性(Fail-Fast不是硬保证)。

看两种场景例子
public class FailFast {
  public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    list.add("1");
    list.add("2");
    list.add("3");
    list.add("4");
    list.add("5");
    list.add("6");
  }
  
  private static void deleteItem(List<String> list) {
    for (String item: list) {
      if ("2".equals(item)) {
        list.remove(item);
      }
    }
  }

  public static void deleteItem2(List<String> list) {
    for (int i = 0; i < list.size(); i++) {
      String item = list.get(i);
      if ("2".equals(item)) {
        list.remove(item);
      }
    }
  }
}
//(--> deleteItem)Exception in thread "main" java.util.ConcurrentModificationException
//at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911)
//at java.util.ArrayList$Itr.next(ArrayList.java:861)

分别执行删除方法deleteItem、deleteItem2,可以发现增强 for循环方法deleteItem在删除元素时直接报了并发修改异常,而普通for循环删除元素时则不会,并且可以成功删除元素。WTF?难道是分析的不对?还是本身没有检测到这个异常,而这也确确实实是对结构进行了修改。回头仔细读一下注释note

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.

划重点这里是iteratorfail-fast机制,而ArrayList实现了List接口,而List接口继承自CollectionCollection又继承自IterableArrayList内部对Iterable作了具体的实现,增强for循环普通for循环调用的remove方法一样的。

public boolean remove(Object o) {
  if (o == null) {
    for (int index = 0; index < size; index++)
      if (elementData[index] == null) {
        fastRemove(index);
        return true;
      }
  } else {
    for (int index = 0; index < size; index++)
      if (o.equals(elementData[index])) {
        fastRemove(index);
        return true;
      }
  }
  return false;
}

private void fastRemove(int index) {
  modCount++;
  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
}

那么问题肯定是出在增强for循环的底层实现上,借助IDEA可以查看字节码的信息,看看与普通for循环到底差别在哪儿。

增强for循环.png

可以清楚的看到的是,增强for循环底层使用的是迭代器iterator进行遍历的,而之前javadoc中就明确表示了,这个iterator被设计成了fail-fast机制,对结构性的修改时只能使用iterator中的add、remove方法。不然就会报并发修改的异常。看看源码是怎样来检测的:

@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];
}

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

关键就在checkForComodification中,无论是添加还是删除元素内部维护的modCount计数字段都会进行自增操作,而增强for循环中底层使用的迭代器迭代(其实是对迭代器迭代的语法糖),并且next方法中对这个modCount作了校验。当发现数据的结构上有了修改就会抛出ConcurrentModificationException并发修改异常。

什么情况下不抛异常?

思考一个问题,增强for循环中对元素删除时(不使用iterator)是否一定会抛出ConcurrentModificationException异常?答案是否定的,这里需要看一下ArrayList中对Iterator<E>内部实现类Itr

hasnext.png

在删除操作while循环的判断条件就是hasNext(),判断还有没有剩余元素需要遍历,继而走到next方法。以本文的删除为例,则整个调用流程为:

hasNext() -->next(checkForComodification) -->remove(modeCount++) -->hasNext().

直观上看,只要这个删除操作后满足hasNext() == false,也即是cursor!=sizefalse,则cursor==size时,循环退出,那么自然就无法检测到这个数据的结构被修改了,自然也不会抛出checkForComodification的异常了。remove操作时数组的size会递减,--size,而next中的游标cursor的赋值为cursor = i+1。继续推导可以得到i+1=--size,恰好是倒数第二个元素。是不是这样呢?测试一下:

//给定的测试数据分别为:[1,2,3],删除元素“2”
List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
list.add("3");
//给定的测试数据分别为:[1,2,3,4,5,6],删除元素“5”
List<String> list = new ArrayList<>();
list.add("1");
list.add("2");
list.add("3");
list.add("4");
list.add("5");
list.add("6");

对应的结果为:

删除2.png

删除5.png

跟验证的结果是一样的,只要满足删除的是集合的倒数第二个元素,就可以绕过checkForComodification异常的检测,但是这么做是很危险的⚠️。这也说明了Fail-Fast仅仅是一种内部实现的检测机制,可能“容错机制”都谈不上。而注释也说明的非常清楚,仅仅用来检测Bug, the fail-fast behavior of iterators should be used only to detect bugs.程序在设计之初就应该避免依赖这种机制为自己兜底,尴尬的是它其实并不能完全兜住😊。总结一下:

  • Fail-Fast对于并发修改异常的抛出不是百分之百的,仅仅只是用来检测bug的机制。

  • 增强For循环底层的实现是iterator,其实就是迭代器iterator遍历的一种语法糖。而iterator遵循fail-fast机制(非iterator自身方法改变数据结构时立即抛并发修改异常)。

  • 在非线程安全的数据结构ArrayList、LinkedList等这种,需要并发修改时必须使用内部iteratoradd、remove方法,当然多线程时,还需要使用同步机制。

  • 普通for循环虽然没有校验,但是这种操作也是不安全的,存在重复数据时,是会有遗漏的情况存在的,就不再验证了。

  • 并发修改不仅仅指的是多线程的情况下,这个不能混淆,这里的例子都是在单线程情况下操作出来的。

为什么要了解Fail-Fast机制

引用国外小哥文章的一句话,原文

Difference between Fail fast and fail safe iterator or Fail fast vs Fail Safe iterator is one of those questions which are used to test your knowledge about the topic Concurrency.

觉得解释的很好,包括stackoverflow上也有很多关于这个的讨论。stackoverflow,是并发的基础,包括与Fail-Safe对比,可以更好的理解这两个概念。平时在程序开发过程中,也会提醒注意这方面的内容,多一点思考。

“Fail-Safe”机制

为什么给fail-safe打上引号,因为在javadoc中还是注释并没有发现这个官方的说法fail-safe。还是以之前的删除为例,将ArrayList替换为线程安全的类CopyOnWriteArrayList,按照之前的操作看看会发生什么。

public static void main(String[] args) {
  List<String> list = new CopyOnWriteArrayList<>();
  list.add("1");
  list.add("2");
  list.add("3");
  list.add("4");
  list.add("5");
  list.add("6");
  
  deleteItem(list);
}
private static void deleteItem(List<String> list) {
  for (String item : list) {
    if ("2".equals(item)) {
      list.remove(item);
    }
  }
}
  
private static void deleteItem3(List<String> list) {
  Iterator<String> iterator = list.iterator();
  while (iterator.hasNext()) {
    String item = iterator.next();
    if ("2".equals(item)) {
      iterator.remove();
    }
  }
}
//打印的信息就不贴了,字节码信息没有改变

可以发现在增强for循环中调用remove方法,不会发生任何异常,但是在方法deleteItem3中,直接抛出异常:

copyonwrite删除.png

CopyOnWriteArrayListIterator<E>的实现类为COWIterator<E>并且其规定了,作删除操作时直接抛出异常:

COWIterator.png

同时在其next中也没有对像ArrayList中那样对modCount的校验,当然也就不存在并发操作异常的情况了。应该是很多开发者在将这种“机制”对比于fail-fast取名为“Fail-Safe”。(至少在文档中是没看到这个词的)。

戳这里

copynext校验.png

再来看一看,CopyOnWriteArrayListremove方法:

copyRemove方法.png

线程安全的原因是不仅仅对操作加了,并且操作的其实是数据的拷贝,而这也是CopyOnWriteArrayList的天生缺点,会占用额外的内存空间。当然实际开发过程中,合理的技术方案跟选型还是很有必要的。

链接

stackoverflow

Fail Fast and Fail Safe Iterators in Java