09 | ConcurrentModificationException 的三种触发方式
摘要 :
ConcurrentModificationException不一定是多线程引起的。单线程增强 for 循环里删除元素也会触发,本文把三种触发方式一次讲清,并给出正确写法。
一、问题现象
java
public class CmeTest {
public static void main(String[] args) {
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
// 方式一:增强 for 循环里删除(❌ 会抛 CME)
for (String s : list) {
if ("B".equals(s)) {
list.remove(s); // ❌ ConcurrentModificationException
}
}
}
}
运行结果:
Exception in thread "main" java.util.ConcurrentModificationException
再看多线程版本:
java
// 方式二:一个线程遍历,另一个线程修改(❌ 也会抛 CME)
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
new Thread(() -> {
for (String s : list) {
try { Thread.sleep(100); } catch (InterruptedException e) {}
}
}).start();
new Thread(() -> {
list.add("D"); // 修改结构
}).start();
二、踩坑现场
场景 1:遍历时根据条件删除元素
java
// ❌ 最常见的错误
List<User> users = userService.getUsers();
for (User user : users) {
if (user.getStatus() == 0) {
users.remove(user); // ❌ CME
}
}
场景 2:Stream 里修改原集合
java
// ❌ Stream 遍历时修改原集合
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
list.stream().forEach(s -> {
if ("B".equals(s)) {
list.remove(s); // ❌ CME
}
});
场景 3:多线程「读+写」同一集合(即使只读不写也可能)
java
// ❌ 多线程场景,即使只用迭代器遍历,另一个线程修改也会失败
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
// 线程1遍历
new Thread(() -> {
for (String s : list) { System.out.println(s); }
}).start();
// 线程2修改
new Thread(() -> list.add("D")).start();
三、原理解析
3.1 单线程 CME 的根本原因:modCount 和 expectedModCount
ArrayList 内部维护了一个 modCount(修改次数计数器):
java
// ArrayList 源码(简化)
transient int modCount = 0; // 结构修改次数
public boolean add(E e) {
modCount++; // 每次结构修改 +1
// ...
}
public boolean remove(Object o) {
modCount++; // 每次结构修改 +1
// ...
}
迭代器的 checkForComodification 检查:
java
// ArrayList 的迭代器(Itr 内部类)
int expectedModCount = modCount; // 迭代器创建时记录当时的 modCount
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
触发流程:
创建迭代器 → expectedModCount = 3(假设)
↓
调用 list.remove() → modCount 变成 4
↓
迭代器下一次 next() → checkForComodification()
↓
modCount(4) != expectedModCount(3) → 抛 CME
3.2 增强 for 循环的本质
java
// 你写的代码
for (String s : list) {
list.remove(s);
}
// 编译后等价于
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
list.remove(s); // ❌ 直接调用集合的 remove,迭代器不知道
}
关键 :增强 for 循环底层用迭代器遍历,但 list.remove(s) 是直接调用集合的方法 ,迭代器里的 expectedModCount 没有更新。
3.3 多线程场景
ArrayList 不是线程安全的,即使只有一个线程遍历,另一个线程修改 ,也会触发 CME(这是 fail-fast 机制)。
fail-fast:迭代器发现集合在遍历期间被修改,立即抛异常,而不是继续返回错误数据。
四、正确写法
4.1 单线程遍历删除:用迭代器的 remove()
java
// ✅ 正确写法
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
if ("B".equals(s)) {
it.remove(); // ✅ 用迭代器的 remove,会同步更新 expectedModCount
}
}
4.2 Java 8+:用 removeIf
java
// ✅ 最简洁的写法
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
list.removeIf("B"::equals); // ✅ 内部用迭代器实现
4.3 多线程场景:用并发集合
java
// ✅ 方案1:CopyOnWriteArrayList(读多写少场景)
List<String> list = new CopyOnWriteArrayList<>(Arrays.asList("A", "B", "C"));
// 遍历时不受修改影响(遍历的是快照)
// ✅ 方案2:加锁
List<String> list = new ArrayList<>();
synchronized (list) {
for (String s : list) { ... }
}
4.4 边遍历边收集,最后批量删除
java
// ✅ 避免遍历时删除:先收集,再批量删除
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
List<String> toRemove = new ArrayList<>();
for (String s : list) {
if ("B".equals(s)) {
toRemove.add(s);
}
}
list.removeAll(toRemove); // ✅ 遍历结束后再删除
五、最佳实践
✅ 避免 CME 的 4 条规则
- 遍历时删除元素,必须用迭代器的
remove()方法 - 优先用
removeIf()(Java 8+),代码最简洁 - 多线程场景用
CopyOnWriteArrayList或Collections.synchronizedList foreach循环里不要调用集合的add/remove
🔍 foreach 不能修改集合的通用规则
| 操作 | foreach 里 |
正确方式 |
|---|---|---|
| 删除元素 | ❌ | 迭代器 remove() / removeIf() |
| 添加元素 | ❌ | 先收集再 addAll() |
| 修改元素内容 | ✅ | 直接修改对象属性(不涉及结构变化) |
🛠️ 阿里巴巴 Java 开发手册规约
【强制】 不要在
foreach循环里进行元素的remove/add操作。remove元素请使用Iterator方式,如果是并发操作,需要对Iterator对象加锁。
六、小结
ConcurrentModificationException有三种触发方式:单线程遍历修改、多线程并发读写、Stream 里修改原集合- 单线程触发原因:
modCount != expectedModCount(fail-fast 机制) - 增强 for 循环底层是迭代器,但直接调用
list.remove()不会通知迭代器 - 正确做法:用
Iterator.remove()或removeIf() - 多线程场景:用
CopyOnWriteArrayList或对操作加锁
下一篇预告:HashMap 扩容时发生了什么?死循环已成历史但坑还在 ------ JDK 1.7 的 HashMap 多线程扩容会导致死循环,1.8 修复了但依然有坑。