不知道大家在翻阅集合源码的时候是否注意到了这个问题,ArrayList、LinkedList、Vector、HashMap、TreeMap、HashSet等集合实现类的类注释中都有下面这两段话(见下面的图片)。在这两段英文中,官方给出了一个名词:fail-fast
那么fail-fast机制是什么?它是干什么的?它是怎样实现的呢?我们接下来呢探究一下
目录
一、什么是 fail-fast机制
先翻译一下官方给的解释:
概括起来主要有以下几点:
- fail-fast 机制,即快速失败机制,是java集合(Collection)中的一种错误检测机制。
- 只要任何一个实现了Iterable接口的容器集合实例都存在这种机制,而与是否是单线程还是多线程环境无关。当多个线程对同一个集合的内容进行操作时,就有可能出现在一个线程正在迭代集合的过程中,该集合因为别的线程对其的操作使得结构发生变化,这就会产生fail-fast事件,抛出 ConcurrentModificationException异常。单线程环境一边使用iterator遍历一边修改集合也会出现快速失败
我们先来看一个例子体会一下fail-fast机制
ArrayList<Integer>arrayList=new ArrayList<>();
//LinkedList<Integer>linkedList=new LinkedList<>();
//Vector<Integer>vector=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<String> 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<String> 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);
}
}
}
}
}
二、 引入fail-fast机制的目的
在mysql数据库中我们知道,在读数据的时候不允许同时修改、添加、删除,修改、添加、删除的同时不允许读。否则将会导致脏数据,数据前后不一致。对集合进行遍历、添加、删除也是一样的道理,但是很遗憾的是集合并不能想数据库那样有一系列的方法去保证数据的一致性。而只能通过异常的方式提醒用户,遍历的同时去删除、添加数据有可能会导致脏数据。既然知道了fail-fast机制实质上是一种通过抛出异常的消息提醒,那么这个异常是怎么抛出的呢?下面我们来看一看fail-fast机制的实现原理。
三、fail-fast机制的实现原理
我们以最常用的ArrayList的iterator迭代器为例,一步一步翻阅源码,探究这个异常是怎么产生的。
public Iterator<E> iterator() {
return new Itr();
}
该方法本质上返回的是一个Itr实例化对象。我们下面再来看看Itr内部类
private class Itr implements Iterator<E> {
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;
}
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();
}
至此,我们终于在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();
}
}
四、fail-safe机制
为了避免触发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<String> 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<String> list=new CopyOnWriteArrayList<>();
list.add("a");
list.add("b");
list.add("c");
list.add("d");
Iterator<String> 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在读写的时候使用同一个容器,读写互斥,同时只能做一件事儿。