不知道大家在翻阅集合源码的时候是否注意到了这个问题,ArrayList、LinkedList、Vector、HashMap、TreeMap、HashSet等集合实现类的类注释中都有下面这两段话(见下面的图片)。在这两段英文中,官方给出了一个名词:fail-fast
那么fail-fast机制是什么?它是干什么的?它是怎样实现的呢?我们接下来呢探究一下
目录
一、什么是 fail-fast机制
二、 引入fail-fast机制的目的
三、fail-fast机制的实现原理
四、fail-safe机制
先翻译一下官方给的解释:
这个类的迭代器和listIterator方法返回的迭代器是快速失效的:如果在迭代器创建后的任何时候,列表在结构上被修改,除了通过迭代器自己的remove或add方法,迭代器将抛出ConcurrentModificationException。因此,在面对并发修改时,迭代器会快速、干净地失败,而不是在未来的不确定时间冒着任意、不确定性行为的风险。
请注意,迭代器的快速失效行为无法得到保证,因为一般来说,在存在非同步并发修改的情况下,不可能做出任何硬保证。快速失败的迭代器会尽最大努力抛出ConcurrentModificationException。因此,编写一个依赖于此异常的程序是错误的:迭代器的快速失败行为应该只用于检测错误。
概括起来主要有以下几点:
我们先来看一个例子体会一下fail-fast机制
ArrayListarrayList=new ArrayList<>();
//LinkedListlinkedList=new LinkedList<>();
//Vectorvector=new Vector<>();
for (int i = 0; i < 5; i++) {
arrayList.add(i);
//linkedList.add(i);
// vector.add(i);
}
for (int x:arrayList){
if(x==4){
arrayList.remove(x);
}
//System.out.println(x);
}
foreach循环又称增强for循环,是jdk1.5为了简化数组或者和容器遍历而产生,foreach循环的适用范围:对于任何实现了Iterable接口的容器都可以使用foreach循环。因此在使用foreach循环遍历ArrayList的时候,可以看作就是在使用List的迭代器进行遍历。查看控制台,果然报了错误。但是注意fail-fast机制并不保证在不同步的修改下一定会抛出异常,它只是尽最大努力去抛出,所以这种机制一般仅用于检测bug。
上面是单线程环境下的fast-fail实例,下面再看看多线程环境下的
public class FastFailTest {
public static List list=new ArrayList<>();
public static void main(String[] args) {
for(int i = 0 ; i < 10;i++){
list.add(i+"");
}
MyThread1 thread1 = new MyThread1();
MyThread2 thread2 = new MyThread2();
thread1.setName("Thread1");
thread2.setName("Thread2");
thread1.start();
thread2.start();
}
private static class MyThread1 extends Thread {
@Override
public void run() {
Iterator iterator = list.iterator();
while (iterator.hasNext()){
String next = iterator.next();
System.out.println(this.getName()+":"+next);
}
}
}
private static class MyThread2 extends Thread{
@Override
public void run() {
for (int i = 0; i < 10; i++) {
if(i==5){
list.remove(i);
System.out.println(this.getName()+":"+i);
}
}
}
}
}
在mysql数据库中我们知道,在读数据的时候不允许同时修改、添加、删除,修改、添加、删除的同时不允许读。否则将会导致脏数据,数据前后不一致。对集合进行遍历、添加、删除也是一样的道理,但是很遗憾的是集合并不能想数据库那样有一系列的方法去保证数据的一致性。而只能通过异常的方式提醒用户,遍历的同时去删除、添加数据有可能会导致脏数据。既然知道了fail-fast机制实质上是一种通过抛出异常的消息提醒,那么这个异常是怎么抛出的呢?下面我们来看一看fail-fast机制的实现原理。
我们以最常用的ArrayList的iterator迭代器为例,一步一步翻阅源码,探究这个异常是怎么产生的。
public Iterator iterator() {
return new Itr();
}
该方法本质上返回的是一个Itr实例化对象。我们下面再来看看Itr内部类
private class Itr implements Iterator {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
Itr() {}
}
ArrayList定义了一个Itr内部类实现了Iterator接口,Itr内部有三个属性。
cursor:代表的是下一个访问的元素下标;
lastRet:代表的是上一个访问元素的下标,默认值为-1;
expectedModCount:代表的是对ArrayList修改的次数,初始值等于modCount
我们再来看一下这个内部类中的一些方法:
public boolean hasNext() {
return cursor != size;
}
hasNext()方法
判断是否存在下一个元素,返回值只要cursor下一个元素的下标不等于集合的元素个数size就返回true,其他情况返回false
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();
}
next() 方法
获取集合中的下一个元素。该方法首先调用了checkForComodification()方法。我们再来看一下这个方法内部做了些什么
final void checkForComodification() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); }原来在迭代的过程中,会不断的去检查 expectedModCount 与 modCount 的值是否相等,expectedModCount表示这个迭代器预期该集合被修改的次数。其值随着Itr被创建而初始化。只有通过迭代器对集合进行操作,该值才会改变。而modCount在对集合进行添加、删除等操作的时候就都会增加1。如果这两个变量不相等,就说明在容器迭代的过程中,有其他的操作修改了容器,导致 modCount 的值增加,那么就会报 ConcurrentModificationException 异常
接着看一下next方法中的两个If语句。如果cursor的值大于集合中元素的个数(i >= size),抛出NoSuchElementException异常;如果cursor大于了数组的长度抛ConcurrentModificationException异常。
如果上述情况均满足,则正常获取下一个元素,cursor和lastRet都自增1
至此,我们终于在ArrayList的内部类Itr的next方法中找到了ConcurrentModificationException异常抛出的原因。我们继续阅读源码,发现该内部类中竟然还有remove方法,那么该remove方法与ArrayList中的remove方法有什么区别呢?
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();
}
}
remove方法
该方法内部先调用ArrayList类中的remove方法删除元素后,把最新的 modCount 的值赋值给了 expectedModCount,因此不会抛出ConcurrentModificationException 异常。可以看到,该remove方法并不会修改modCount的值,并且不会对后面的遍历造成影响。但是该方法remove不能指定元素,只能remove当前遍历过的那个元素,这也是该方法的局限性。
为了避免触发fail-fast机制,导致异常,我们可以使用Java中提供的一些采用了fail-safe机制的集合类。这样的集合容器在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。java.util.concurrent包下的容器都是fail-safe的,可以在多线程下并发使用,并发修改。同时也可以在foreach中进行add/remove 。我们拿CopyOnWriteArrayList这个fail-safe的集合类来简单分析一下。
以下代码,使用CopyOnWriteArrayList代替了ArrayList,就不会发生异常。
public class FastSafe {
public static void main(String[] args) {
List list=new CopyOnWriteArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
for (String word:list){
if(word.equals("c")){
list.remove(word);
}
}
System.out.println(list);
}
}
fail-safe集合的所有对集合的修改都是先拷贝一份副本,然后在副本集合上进行的,并不是直接对原集合进行修改。并且这些修改方法,如add/remove都是通过加锁来控制并发的。所以,CopyOnWriteArrayList中的迭代器在迭代的过程中不需要做fail-fast的并发检测。(因为fail-fast的主要目的就是识别并发,然后通过异常的方式通知用户)但是,虽然基于拷贝内容的优点是避免了ConcurrentModificationException,但同样地,迭代器并不能访问到修改后的内容。如以下代码:
public class FastSafe {
public static void main(String[] args) {
List list=new CopyOnWriteArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
Iterator iterator = list.iterator();
for (String word:list){
if(word.equals("c")){
list.remove(word);
}
}
System.out.println(list);
while (iterator.hasNext()){
System.out.println(iterator.next());
}
}
}
我们得到CopyOnWriteArrayList的Iterator之后,通过for循环直接删除原数组中的值,最后在结尾处输出Iterator,结果发现内容如下:
迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。
翻阅CopyOnWriteArrayList源码,发现add/remove等方法都已经加锁了,还要copy一份再修改干嘛?同样是线程安全的集合,这玩意和Vector有啥区别呢?
Copy-On-Write简称COW,是一种用于程序设计中的优化策略。其基本思路是,从一开始大家都在共享同一个内容,当某个人想要修改这个内容的时候,才会真正把内容Copy出去形成一个新的内容然后再改,这是一种延时懒惰策略。
CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
CopyOnWriteArrayList中add/remove等写方法是需要加锁的,目的是为了避免Copy出N个副本出来,导致并发写。但是,CopyOnWriteArrayList中的读方法是没有加锁的。
public E get(int index) {
return get(getArray(), index);
}
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,当然,这里读到的数据可能不是最新的。因为写时复制的思想是通过延时更新的策略来实现数据的最终一致性的,并非强一致性。所以CopyOnWrite容器是一种读写分离的思想,读和写不同的容器。**而Vector在读写的时候使用同一个容器,读写互斥,同时只能做一件事儿。