「Fail-Fast与Fail-Safe机制」
写在前面
最近在刷题的过程中又重新熟悉一遍常用的数据结构,发现对Fail-Fast
与Fail-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
自身的add
、remove
方法。并且它们采用了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.
划重点这里是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
的天生缺点,会占用额外的内存空间。当然实际开发过程中,合理的技术方案跟选型还是很有必要的。