本篇参考 《码出高效 Java 开发手册》
fail-fast
机制是集合世界中比较常见的错误检测机制,通常出现在遍历集合元素的过程中,下面通过校园生活中的一个例子来体会fail-fast
机制。- 上课前,班长开始点名,刚点到一半,这时教师外面两两三三进来几位同学,同学们起哄:点错了!班长重新点名,点到中途,又出去几位同学,同学们又起哄说:点错了!班长又需要重新点名了,这就是
fail-fast
机制。它是一种对集合遍历操作时的错误检测机制,在遍历中途出现意料之外的修改时,通过unchecked
异常暴力的反馈出来,这种机制经常出现在多线程环境下,当前线程会维护一个计数比较器,叫做expectedModCount
,记录已经修改过的次数,在进入遍历前,会把实时修改次数modCount
赋值给expectedModCount
,如果这两个数据不相等,则抛出异常。java.util 下的所有集合类都是fail-fast
,而 concurrent 包中的集合类都是fail-safe
。与fail-fast
不同,fail-safe
对于刚才点名被频繁打断的情形,相当于班长直接拿出手机快速拍照,然后跟据照片点名,不在关心同学们的进进出出。 - 下面我们通过
ArrayList.subList()
方法进一步阐述fail-fast
这种机制,在某种情况下,需要从一个主列表 master 中获取子列表 branch,master 集合元素个数的增加或删除,均会导致子列表的遍历、增加、删除进而产生fail-fast
异常,代码分析如下:
@Test
public void test01() {
List masterList = new ArrayList();
masterList.add("one");
masterList.add("two");
masterList.add("three");
masterList.add("four");
masterList.add("five");
List branchList = masterList.subList(0, 3);
System.out.println(branchList);
// 下方三行代码,如果不注释掉,则会导致 branchList 操作出现异常 (`第一处`)
masterList.remove(0);
masterList.add("ten");
masterList.clear();
// 下方四行全部执行成功
branchList.clear();
branchList.add("six");
branchList.add("seven");
branchList.remove(0);
// 正常遍历结束 :只有一个元素:seven
for (Object t : branchList) {
System.out.println(t);
}
// 子列表修改导致主列表也被动修改,输出:[seven, four, five]
System.out.println(masterList);
}
- 第一处说明,如果不注释掉,masterList 的任何有关于元素个数的修改操作都会导致 branchList 的 ”增删改查“ 抛出
ConcurrentModificationException
异常,在实际调研中,大部分程序员知道 subList 子列表无法序列化,也知道它的修改会导致主列表的修改,但是并不知道主列表个数的改动会让子列表如此敏感,频频抛出异常,在实际代码中,这样的故障案例属于常见的类型, subList 方法返回的是内部类 SubList 的对象,SubList 类是 ArrayList 的内部类,SubList 的定义如下,并没有实现序列化接口,无法网络传输: private static class SubList<E> extends AbstractList<E> implements RandomAccess {...}
- 在 foreach 遍历元素时,使用删除方式测试
fail-fast
机制,查看如下代码:
@Test
public void test02() {
List<String> list = new ArrayList<String>();
list.add("one");
list.add("two");
list.add("three");
for (String s : list) {
if ("two".equals(s)) {
list.remove(s);
}
}
System.out.println(list);
}
- 编译正确,执行成功!输出 [one,three] ,说好的
ConcurrentModificationException
异常呢?这只是一种巧合,在集合遍历时维护一个初始值为0的游标cursor
,从头到尾的进行扫描,当cursor
等于 size 时,退出遍历,执行remove
这个元素后,所有元素往前拷贝,size = size -1
即为2,这时 cursor 也等于2。在执行hasNext()
时,结果为 false,退出循环体,并没有机会执行到next()
的第一行代码checkForComodification()
,此方法用来判断 expectedModCount 和 modCount 是否相等,如果不相等,则会抛出 ConcurrentModificationException 异常。 - 这个案列应该引起对删除元素时的 fail-fast 警觉,我们可以使用 Iterator 机制进行遍历时的删除,如果时多线程并发,还需要在 Iterator 遍历时加锁,如下源码:
Iterator<String> iterator = list.iterator();
while(iterator.hasNext()){
synchronized(对象) {
String item = iterator.next();
if (删除元素的条件) {
iterator.remove();
}
}
}
- 或者使用并发容器
CopyOnWriteArrayList
代替 ArrayList。顺便介绍一个 COW 奶牛家族,即 Copy-On-Write。它是并发的一种新思路,实行读写分离,如果要是写操作,则复制一个新集合,在新集合中对元素进行添加或删除,待一切都修改完成后,再将原集合的引用指向新集合,这样做的好处是可与高并发的对 COW 进行读和遍历操作,而不需要加锁,因为当前集合不会添加任何元素,使用 COW 时应该注意两点:尽量设置合理的容量初始值,它扩容的代价比较大;
使用批量添加或删除方法,如 addAll 或 removeAll 操作,在高并发请求下,可以攒一下要添加或者删除的元素,避免增加一个元素复制整个集合。
- COW 是
fail-safe
机制的,在并发包中的集合都是由这种机制实现的,fail-safe 是在安全的副本(或者说没有修改操作的正本)上进行遍历,集合修改与副本的遍历是没有任何关系的,但是缺点也很明显,就是读取不到最新的数据
。这也是 CAP 理论中 一致性和可用性的矛盾之处。