「Fail-Fast与Fail-Safe机制」
前言
最近在刷题的过程中又重新熟悉一遍常用的数据结构,发现对Fail-Fast与Fail-Safe机制有点模糊了,这里重新整理一下,加深一下印象。提醒在平时开发过程中严谨处理数据结构相关的内容。
Iterator
ArrayList 实现了 List 接口,而 List 接口继承自 Collection,Collection 又继承自 Iterable,ArrayList 内部对 Iterable 作了具体的实现。
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 的 javadoc,当我们使用 iterator 进行遍历时,如果遍历途中使用集合的 add/remove 方法进行了结构化修改(结构修改即添加或者删除元素,对元素本身的值修改不属于此范围),那么此时会抛出 ConcurrentModificationException 异常,产生 fail-fast 事件。
如果遍历途中,改为使用 iterator/listIterator 的 remove/add 方法进行结构化修改则可以避免异常发生。
什么是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.
划重点这里是iterator是fail-fast机制,而ArrayList实现了List接口,而List接口继承自Collection,Collection又继承自Iterable,ArrayList内部对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循环底层使用的是迭代器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:
在删除操作while循环的判断条件就是hasNext(),判断还有没有剩余元素需要遍历,继而走到next方法。以本文的删除为例,则整个调用流程为:
hasNext() -->next(checkForComodification) -->remove(modeCount++) -->hasNext().
直观上看,只要这个删除操作后满足hasNext() == false,也即是cursor!=size为false,则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");
对应的结果为:
跟验证的结果是一样的,只要满足删除的是集合的倒数第二个元素,就可以绕过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等这种,需要并发修改时必须使用内部iterator的add、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中,直接抛出异常:
在CopyOnWriteArrayList中Iterator<E>的实现类为COWIterator<E>并且其规定了,作删除操作时直接抛出异常:
同时在其next中也没有对像ArrayList中那样对modCount的校验,当然也就不存在并发操作异常的情况了。应该是很多开发者在将这种“机制”对比于fail-fast取名为“Fail-Safe”。(至少在文档中是没看到这个词的)。
再来看一看,CopyOnWriteArrayList的remove方法:
线程安全的原因是不仅仅对操作加了锁,并且操作的其实是数据的拷贝,而这也是CopyOnWriteArrayList的天生缺点,会占用额外的内存空间。当然实际开发过程中,合理的技术方案跟选型还是很有必要的。
尾言
总结一下 fail-safe:在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发 ConcurrentModificationException。
最后,喜欢的朋友可以点个关注,这样就不会错过我的文章,如果这篇文章对你有帮助的话,不妨点个赞,留个言,我们下次再见。