本篇參考 《碼出高效 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 理論中 一致性和可用性的矛盾之處。