集合中常見的 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 理論中 一致性和可用性的矛盾之處。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章