fail-fast機制解讀
集合的增刪
平時經常會有一些對集合的增刪場景,尤其是在循環內進行刪除,下面我們看下這幾種場景。
普通for循環
首先,使用 普通for循環可以對集合進行增刪,但增刪後由於普通for循環時是通過下標索引訪問,因此有可能遇到某些數據讀不到的問題。進行完全遍歷時,由於集合長度已發生變化,會拋出IndexOutOfBoundsException下標越界異常。
看一個例子。
for (int i = 0; i <6 ; i++) {
System.out.println("讀取"+list.get(i));
if (3 == i) {
list.remove(i);
}
}
上述代碼輸出了
讀取0
讀取1
讀取2
讀取3
讀取5
Exception in thread "Thread-1" java.lang.IndexOutOfBoundsException: Index: 5, Size: 5
讀取了3之後直接跳到5,沒有讀取4,因爲刪除掉[3]之後,原本在[4]位置的4下標變爲[3],但循環已經跳過,所以漏了一個。此處可以通過手動控制在刪除後i-1可以避免。但最後的下標越界異常是無法避免的,因此不要在for循環內進行超過1個的集合增刪操作。
增強for循環
在《阿里巴巴JAVA開發規範》中有這樣一段話
【強制】不要在 foreach 循環裏進行元素的 remove/add 操作。remove 元素請使用 Iterator
方式,如果併發操作,需要對 Iterator 對象加鎖。
寫代碼嘗試了一下,在foreach中進行任意的增刪操作,均會拋出ConcurrentModificationException異常。我們寫一段代碼。
for (int j: list) {
System.out.println("讀取"+j);
if (3 == j) {
list.remove(j);
}
}
然後看下由class文件反編譯後的源碼。
Iterator var0 = list.iterator();
while(var0.hasNext()) {
int j = (Integer)var0.next();
System.out.println("讀取" + j);
if (3 == j) {
list.remove(j);
}
}
可以看到,foreach只是java語言的語法糖,本質上還是由Iterator來迭代的,但在刪除時,調用的是list的remove方法,正是這裏引發了修改異常。
之前在分析list源碼的時候,提到過兩個關鍵變量modCount和expectedModCount,在Itr中進行增刪時,都會進行判斷。
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
跑個題,簡單再說一下這兩個變量的作用,首先modCount是AbstractList父類的一個屬性,在集合進行結構變化時都會進行自增以記錄修改次數。
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
在AbstractList中有一個私有內部成員類Itr,通過Iterator接口返回,在使用迭代器對集合迭代時候便會返回這個對象,Itr裏面有一個屬性,exceptedModCount,在Itr進行初始化時候,expectedModCount = modCount。
private class Itr implements Iterator<E> {
int expectedModCount = modCount; // 賦值,等於modCount
..........
}
-
modCount記錄的是集合真正的修改次數。
-
expectedModCount記錄的是使用當前迭代器時集合的修改次數。
看到這裏,大家應該就清楚了,每次開始迭代時候,用exceptedModCount記錄下當前的modCount,這樣,如果集合在其他地方進行了修改,兩個值就會不一樣,直接拋出異常。
那麼它是怎麼保證在當前迭代器進行修改不會有問題的呢?
迭代器遍歷
我們再使用迭代器進行遍歷.
Iterator var0 = list.iterator();
while(var0.hasNext()) {
int j = (Integer)var0.next();
System.out.println("讀取" + j);
if (3 == j) {
var0.remove();
}
}
讀取0
讀取1
讀取2
讀取3
讀取4
讀取5
這段代碼和上面使用foreach的反編譯源碼相比,只有一個改動,那就是將list.remove(),改爲了Iterator.remove,從上面的代碼分析中我們知道了這是調用了Itr對象的remove方法,那這個方法怎麼保證不會拋出異常呢?
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount; // modCount此時已經修改,進行同步
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
可以看到,在Itr中刪除時,remove後會對exceptedModCount和modCount進行同步,從而保證了在當前迭代器內修改不會拋出異常。
引申一點,使用Iterator爲什麼沒有出現使用普通for循環時的下標越界異常呢?因爲Iterator遍歷時候是通過指針操作,在增刪時候會修改指針,避免了這個問題。
fail-fast機制
通過分析上面知道了幾種集合增刪方式,可以看到,在多線程併發讀取和修改集合時,也許並不會真正出問題,但爲了防止這種情況導致的數據不一致性,通過記錄集合的修改次數直接在這種情況出現時拋出異常,實現了fail-fast機制,確保同一時間只有一個線程修改或遍歷線程。
它只是一種錯誤檢測機制,做到了提前檢測,但不一定會發生。
用線程的說法也不準確,因爲通過上面的源碼分析我們知道,fail-fast機制核心是保證了只在當前迭代器內修改,所以在單線程環境下,如果在迭代器外發生了修改(像上面的foreach),也會拋出異常。
知識點
- 單線程環境和多線程環境都會發生ConcurrentModificationException異常,關鍵看是不是一個迭代器。
- 使用普通for循環可以少量刪除,但可能會發生數據遺漏和數組下標越界異常。
- 增強for循環只是迭代器的語法糖,由於觸發了Iterator的fail-fast機制,所以完全無法進行集合修改。
我們應該怎麼做
- 在單線程環境下,使用Iterator進行集合的遍歷修改。
- 在多線程環境下,使用concurrent中的類來替換ArrayList和HashMap。不推薦使用同步鎖,會額外造成阻塞。