一句話先說結論:這個例外的名字有誤導性。它不要求多執行緒,單執行緒裡一邊遍歷一邊改集合就會拋。根子是 fail-fast 迭代器拿
modCount和expectedModCount對不上。修法就兩條:用iterator.remove(),或者 Java 8 的removeIf()。
背景
ConcurrentModificationException 是 Java 集合框架裡最常見的運行時例外之一。名字裡帶「併發」,但它跟多執行緒沒有必然關係。
ArrayList、HashMap、HashSet 這些標準集合都用 fail-fast 迭代器。迭代器一旦發現集合被「結構性修改」,立刻拋例外,而不是等你拿到一個不確定的遍歷結果。
「結構性修改」指增刪元素、改變容量這類操作,替換已有元素不算。
現象
一個單執行緒程式,遍歷時刪掉匹配元素,直接報錯。
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));
for (String s : list) {
if (s.equals("b")) {
list.remove(s);
}
}
報錯原文:
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)
at java.base/java.util.ArrayList$Itr.next(ArrayList.java:967)
at com.example.Main.process(Main.java:15)
at com.example.Main.main(Main.java:9)
HashMap 也一樣,遍歷 entrySet 時刪 key:
java.util.ConcurrentModificationException
at java.base/java.util.HashMap$HashIterator.nextNode(HashMap.java:1597)
at java.base/java.util.HashMap$EntryIterator.next(HashMap.java:1730)
at com.example.Main.cleanup(Main.java:24)
at com.example.Main.main(Main.java:8)
注意堆疊落在 next(),不是你的業務程式碼那行。很多人第一反應去 process() 找問題,實際根子在更早的遍歷寫法上。
根因分析
每個集合內部維護一個 modCount 計數器,每次結構性修改就加 1。
迭代器創建時,把當時的 modCount 記作 expectedModCount。之後每次 next() 都校驗一次,兩個值不一致就拋例外。
ArrayList 的 Itr 大致是這麼寫的:
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
for (String s : list) 本質是隱式拿到迭代器,再循環調 next()。你在循環體裡調 list.remove(),modCount 變了,迭代器的 expectedModCount 沒變,下一次 next() 就炸了。
官方文檔還補了一句,容易被忽略:fail-fast 是「盡力而為」,不是硬保證。改了集合有時不拋例外,也可能發生。別把「沒拋例外」當成「安全」。
解決方案
首選:removeIf(Java 8+)
一行搞定,任何 Collection 都能用。
list.removeIf(s -> s.equals("b"));
它內部處理好併發校驗,天生安全。
手動循環用 Iterator.remove
不用 for-each,顯式拿迭代器,刪的時候調迭代器自己的 remove()。
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
if (s.equals("b")) {
it.remove();
}
}
iterator.remove() 刪完會把 expectedModCount 同步成新值,所以不炸。反過來 list.remove() 就不行。
遍歷副本
先複製一份再遍歷副本、刪原集合,能繞開但沒必要。
for (String s : new ArrayList<>(list)) {
if (s.equals("b")) {
list.remove(s);
}
}
不推薦,額外開銷大,還容易把「刪哪個集合」搞混。
多執行緒場景換併發容器
真到了多個執行緒同時讀寫,fail-fast 集合本來就不合適,換成執行緒安全容器。
List<String> list = new CopyOnWriteArrayList<>();
Map<String, Integer> map = new ConcurrentHashMap<>();
CopyOnWriteArrayList 的迭代器是快照式的,不拋這個例外。ConcurrentHashMap 的迭代器是弱一致的,也不會拋。
思考過程
社區最常見的誤判:看到 ConcurrentModificationException,先加 synchronized。結果單執行緒程式照樣拋。
這個誤區來自名字。官方文檔開頭就寫了,這個例外不一定表示有別的執行緒在改。翻譯過來就是,單執行緒違反物件契約也會拋。
另一個容易漏的點:stream().forEach(x -> list.remove(x)) 也是錯的。stream 是給轉換用的,不是給改集合用的。正確做法是 filter().collect()。
還有 Map 的坑。遍歷 keySet() 時調 map.remove(key) 會炸,遍歷 entrySet() 時調 it.remove() 不炸,兩者別混。
小結
ConcurrentModificationException是 fail-fast 機制,不認執行緒,只認「遍歷時改結構」。- 根子是
modCount和expectedModCount對不上。 - 首選
removeIf(),手動循環用iterator.remove()。 - 真併發場景上
CopyOnWriteArrayList或ConcurrentHashMap。 - 別把它當可靠的併發檢測手段,它可能漏報。
References(參考來源)
- ConcurrentModificationException — Oracle Java 8 APIblog
- Why is a ConcurrentModificationException thrown and how to debug it — Stack Overflow 討論鏡像blog
- Java - ConcurrentModificationException 例外原因和解決方法 — 51CTOblog
- Fix: Java ConcurrentModificationException — FixDevsblog
- Why ConcurrentModificationException Occurs with LinkedHashMap — JavaThinkingblog
