【Java踩坑笔记】09_ConcurrentModificationException的三种触发方式

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 条规则

  1. 遍历时删除元素,必须用迭代器的 remove() 方法
  2. 优先用 removeIf()(Java 8+),代码最简洁
  3. 多线程场景用 CopyOnWriteArrayListCollections.synchronizedList
  4. 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 修复了但依然有坑。