非線程安全集合類(這里的集合指容器Collection,非Set)的迭代器結合了及時失敗機制,但仍然是不安全的。這種不安全表現在許多方面:
- 并發(fā)修改“通常”導致及時失敗
- 單線程修改也可能導致及時失敗的“誤報”
- 迭代器會“丟失”某些并發(fā)修改行為,讓及時失敗失效
如果不了解其不安全之處就隨意使用,就像給程序埋下了地雷,隨時可能引爆,卻不可預知。
ArrayList是一個常用的非線程安全集合,下面以基于ArrayList講解幾種代表情況。
及時失敗
及時失敗也叫快速失敗,fast-fail。
“及時失敗”的迭代器并不是一種完備的處理機制,而只是“善意地”捕獲并發(fā)錯誤,因此只能作為并發(fā)問題的預警指示器。它們采用的實現方式是,將計數器的變化與容器關聯起來:如果在迭代期間計數器被修改,那么hasNext或next將拋出ConcurrentModificationException。然而,這種檢查是在沒有同步的情況下進行的,因此可能會看到失效的計數器,而迭代器可能并沒有意識到已經發(fā)生了修改。這是一種設計上的權衡,從而降低并發(fā)修改操作的檢測代碼對程序性能帶來的影響。
然而,及時失敗機制十分簡潔(簡單&清晰),同時對集合的性能影響十分小,所以大部分非線程安全的集合類仍然使用這種機制來進行“善意”的提醒。
幾種非線程安全的代表情況
并發(fā)修改“通常”導致及時失敗
“通常”是因為及時失敗的“善意”性質,它很多時候會給我們提醒,但有時候也不會給出提醒,有時候甚至給出某種意義上的錯誤提醒。這一小節(jié)針對正常的情況,這是我們考察一個機制是否值得“采納并完善”的根本屬性。
構造下列程序:
…
private Collection users = new ArrayList(); // 所以應使用CopyOnWriteArrayList
…
users.add(new User("張三",28));
users.add(new User("李四",25));
users.add(new User("王五",31));
…
public void run() {
Iterator itrUsers = users.iterator();
while(itrUsers.hasNext()){
System.out.println("aaaa");
User user = (User)itrUsers.next();
if(“張三”.equals(user.getName())){ // 在迭代過程中修改集合
itrUsers.remove();
} else { // 正常輸出
System.out.println(user);
}
}
}
…
忽略細節(jié),假設有多個線程在同時執(zhí)行run方法,操作users集合。這時,“通?!睍е录皶r失敗。這里的異??赡軓膎ext或remove方法中拋出(當然這里是從next,因為next先執(zhí)行):
private class Itr implements Iterator<E> {
…
public boolean hasNext() {
return cursor != size;
}
public E next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
public void remove() { // 迭代器的remove方法
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet); // 集合的remove方法
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
…
}
實際檢查并拋出異常的是checkForComodification方法:
private class Itr implements Iterator<E> {
…
int expectedModCount = modCount;
…
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
…
}
modCount是當前集合的版本號,每次修改(增刪改)集合都會加 1;expectedModCount是當前迭代器的版本號,在迭代器實例化時初始化為modCount,只有remove方法正常執(zhí)行(不拋出異常)才可以修改這個值,與modCount保持同步。
因此,如果在線程A正常迭代的過程中,線程B修改了users集合,modCount就會發(fā)生變化,這時,線程B的expectedModCount能夠與modCount保持同步,線程A的expectedModCount卻發(fā)現自己與modCount不再同步,從而拋出ConcurrentModificationException異常。
扯遠些:
對于線程安全的集合類而言,我們不希望任何失敗。但對于非線程安全的類,有人認為“應該在假設線程安全的情況下使用”,所以及時失敗機制完全沒有必要;有人認為“集合類的狀態(tài)太多(所有非線程安全域的狀態(tài)數量的乘積),并發(fā)使用時應該給出錯誤提醒,否則很難排查并發(fā)問題”,所以及時失敗機制很有必要。這個問題見仁見智,個人支持后者觀點。
所以,這種及時失敗的檢查是不完備的。
單線程修改也可能導致及時失敗的“誤報”
多線程并發(fā)修改集合時,拋出ConcurrentModificationException異常作為及時失敗的提醒,往往是我們期望的結果。然而,如果在單線程遍歷迭代器的過程中修改了集合,也會拋出ConcurrentModificationException異常,看起來發(fā)生了及時失敗。這不是我們期望的結果,是一種及時失敗的誤報。
我們改用集合的remove方法移除user“張三”:
…
public void run() {
…
if(“張三”.equals(user.getName())){ // 在迭代過程中修改集合
users.remove(user); // itrUsers.remove();
} else { // 正常輸出
System.out.println(user);
}
…
}
…
假設只有一個線程執(zhí)行run方法,在”張三”被刪除之后,下一次執(zhí)行next方法時,仍舊會拋出ConcurrentModificationException異常,也就是導致了及時失敗。
這時因為集合的remove方法并沒有維護集合修改的狀態(tài)(如對modCount&expectedModCount組合的修改和檢查):
public class ArrayList<E> extends AbstractList<E>
…
public boolean remove(Object o) { // 集合的remove方法
if (o == null) {
for (int index = 0; index < size; index++)
if (elementData[index] == null) {
fastRemove(index);
return true;
}
} else {
for (int index = 0; index < size; index++)
if (o.equals(elementData[index])) {
fastRemove(index);
return true;
}
}
return false;
}
…
}
這也讓我們更容易理解及時失敗的本質——依托于對集合修改狀態(tài)的維護。這里的主要原因看起來是“集合的remove方法破壞了正常維護的集合修改狀態(tài)”,但對于使用者而言,集合在單線程環(huán)境下卻拋出了ConcurrentModificationException異常,這是由于及時失敗機制沒有區(qū)分單線程與多線程的情況,統(tǒng)一給出同樣的提醒(拋出ConcurrentModificationException異常),因而是及時失敗的誤報。
迭代器會“丟失”某些并發(fā)修改行為,讓及時失敗失效
除了誤報,及時失敗之僅限于“善意”(有提醒就是“善意”的,沒有也不是“惡意”的)還體現在其可能“丟失”某些并發(fā)修改行為。在這里,“丟失”意味著不提醒——某些線程并發(fā)修改了當前集合,但沒有拋出ConcurrentModificationException異常,及時失敗機制失效了。
主動避過及時失敗的檢查
利用hasNext方法提前結束線程,可以主動避過及時失敗的檢查,從而導致修改行為的丟失:
private class Itr implements Iterator<E> {
…
public boolean hasNext() {
return cursor != size; // 思考:如果刪除了集合的倒數第二個元素,會發(fā)生什么?
}
…
}
還是單線程的場景下,假設我們刪除了集合的倒數第二個元素。這時next方法導致cursor=oldSize-1,同時remove方法導致newSize=oldSize-1(oldSize是集合修改之前的size值,newSize集合修改之后的);所以hasNext方法會返回false,讓用戶誤以為集合迭代已經結束(實際上還有最后一個元素),從而循環(huán)終止(在我們的程序里用hasNext判斷是否結束),無法拋出ConcurrentModificationException異常,及時失敗失效了。
推廣到多線程的情景是一樣的,因為size是共享的。
及時失敗的實現是非線程安全的
很容易忽略的一點是,上述集合修改狀態(tài)的維護本身就是在沒有同步的情況下進行的,因此可能看到更多(遠比上述要多)失效的集合修改狀態(tài),使迭代器意識不到集合發(fā)生了修改,這是一種競態(tài)條件(Race Condition)。
假設線程A進入迭代器的remove方法,線程B進入迭代器的next方法,現在線程A執(zhí)行集合的remove方法:
private class Itr implements Iterator<E> {
…
public void remove() {
…
ArrayList.this.remove(lastRet);
…
}
…
}
首先,假設沒有其他線程并發(fā)修改,則兩個線程都可以通過checkForComodification()的檢查;然后線程A快速的執(zhí)行集合的remove方法;待線程A執(zhí)行完集合的remove方法,由于線程B之前已經通過了檢查,現在就無法意識到“users集合在線程A中已經發(fā)生了變化”。另外,因為幾乎完全不存在同步措施,modCount的修改也存在競態(tài)條件,其他狀態(tài)也無法保證是否有效。
總結
上面看到了非線程安全集合類的迭代器是不安全的,但在單線程的環(huán)境下,這些集合類在性能、維護難度等方面仍然具有不可替代的優(yōu)勢。那么該如何在兼具一定程度線程安全的前提下,更好的發(fā)揮內建集合類的優(yōu)勢呢?總結起來無非兩點:
- 使用非線程安全的集合時(實際上對于某些“線程安全”的集合類,其迭代器也是線程不安全的),迭代過程中需要用戶自覺維護,不修改該集合。
- 應盡可能明確線程安全的需求等級,做好一致性、活躍性、性能等方面的平衡,再針對性的使用相應的集合類。
參考:
- 傳智播客_張孝祥_Java多線程與并發(fā)庫高級應用視頻教程/19_傳智播客_張孝祥_java5同步集合類的應用.avi
本文鏈接:源碼|從源碼分析非線程安全集合類的不安全迭代器
作者:猴子007
出處:https://monkeysayhi.github.io
本文基于 知識共享署名-相同方式共享 4.0 國際許可協議發(fā)布,歡迎轉載,演繹或用于商業(yè)目的,但是必須保留本文的署名及鏈接。