集合中常见的 fail-fast 机制

本篇参考 《码出高效 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 理论中 一致性和可用性的矛盾之处。
发布了47 篇原创文章 · 获赞 312 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_43647359/article/details/105175098