Java 的并发修改异常怎么避免:先排查这 3 个原因

2026-11-13 07:04:55 浏览数 (66)

Java 开发里最让人困惑的运行时异常之一,就是 ConcurrentModificationException。核心结论是:它几乎总是因为你一边遍历集合、一边修改集合,而遍历用的迭代器并不允许这种结构变更。遇到这个异常,先别急着加锁或吞掉异常,而应按三个最常见原因逐一排查:遍历中直接增删、多线程共享了可变集合、以及错把 fail-fast 迭代器当成线程安全用。下面把原因和对应的修复方式讲清,并标出每种修法的取舍。

Java 并发修改异常怎么避免先排查这 3 个原因

一、异常到底是什么:单线程遍历时被改

很多人以为这个异常只发生在多线程,其实单线程也会触发。它的真实含义是:集合在被迭代器遍历的过程中,结构被改动(增、删元素),而迭代器发现"预期的修改次数"和"实际修改次数"对不上,于是抛错自保。换句话说,这是 Java 用 fail-fast 机制在提醒你"遍历和改结构撞车了"。要补 Java 基础,可以先看 Java 基础教程。

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));
for (String s : list) {
    if (s.equals("b")) {
        list.remove(s); // 遍历中直接改集合,抛 ConcurrentModificationException
    }
}

这段代码在增强 for 循环里直接调用 list.remove,而增强 for 背后用的就是迭代器,迭代器检测到结构变化就会抛异常。理解这一点很关键:异常不是 bug 的终点,而是提示你"这种改法不被允许"。排查时先检查异常堆栈,确认是单线程还是多线程触发的,再决定走哪条修复路径。集合遍历与修改分离,是避免这个异常的总原则。

二、原因一:遍历中直接增删集合

最常见的第一个原因,就是上一节那种"用集合自己的 remove 改正在遍历的集合"。修复方式有三种,取决于你是否还需要一边遍历一边删。如果只是按条件删除,最简单的做法是改用迭代器自己的 remove:调用 it.remove() 会同步更新迭代器的修改计数,不会触发异常。完整方法可查 Java 速查手册。

Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (it.next().equals("b")) {
        it.remove(); // 安全:通过迭代器自删
    }
}

第二个做法是用 removeIf,语义清晰且内部也是走迭代器删除:list.removeIf(s -> s.equals("b"))。第三个做法是先收集要删的元素到新集合,遍历结束后再批量删除,适合删除逻辑较复杂时。三条路都避开了"遍历中改结构",区别只在可读性。新手最容易犯的错,是图省事直接在循环里 list.remove,结果上线后才暴露问题。

三、原因二:多线程共享了可变集合

第二个原因才是真正的并发场景:多个线程同时读和写同一个 ArrayList,其中一个线程在遍历,另一个线程在改。这时即使你用了迭代器的 remove,也只是解决了单线程问题,跨线程仍然不安全。这类问题在 Web 接口里尤其常见,因为每次请求都可能并发访问同一个缓存集合。真正线程安全的修法,是换用并发容器。可以在 Java 在线运行示例 里验证不同容器的行为。

// 不安全:多线程下遍历与写入会抛异常或读出脏数据
List<String> list = new ArrayList<>();

// 改为并发安全容器
List<String> safe = new CopyOnWriteArrayList<>();

把可变集合替换成 CopyOnWriteArrayList,遍历时拿到的是快照,写入时复制新数组,因此遍历不会抛 ConcurrentModificationException。但要注意它的代价:每次写入都要复制底层数组,写多读少的场景会明显变慢。所以选型时要看读写比例,而不是无脑替换。线程安全不是加个锁就完事,而是要选对容器。

四、原因三:错把 fail-fast 迭代器当线程安全

第三个原因,是开发者误以为"我用了迭代器就安全了",或者以为"只读不写就没事"。实际上普通集合的迭代器是 fail-fast 的,它只在单线程内帮你发现结构冲突,对多线程完全没有保护作用。更隐蔽的是,即使你只遍历不写,只要别的线程在写,遍历就可能抛异常或看到不一致的数据。这种错误往往在高并发压测时才暴露,日常测试一切正常。对比前端生态可以看 JavaScript 基础教程。

List<String> list = Collections.synchronizedList(new ArrayList<>());
// 遍历仍需手动同步,否则仍可能抛 ConcurrentModificationException
synchronized (list) {
    for (String s : list) { /* 安全遍历 */ }
}

Collections.synchronizedList 只让单个方法是同步的,遍历这种复合操作仍要你自己在外部加锁。很多人以为包了一层就安全,结果在遍历上栽跟头。正确的认知是:线程安全是全局属性,不是某个方法带锁就够。要么用 CopyOnWriteArrayList 这类专为并发设计的容器,要么在遍历处手动同步,二者选其一并坚持到底。

五、对应的修复方式与取舍

把三条原因对应的修法放在一起看,取舍就很清楚。单线程遍历删除,优先用迭代器 remove 或 removeIf,零额外开销;多线程共享且读多写少,选 CopyOnWriteArrayList,牺牲写性能换安全;写多或需要复杂原子操作,回到显式锁或在遍历处同步。无论如何,不要图省事把异常 catch 掉假装没发生,那只会让数据不一致更难排查。

// 读多写少:CopyOnWriteArrayList
List<String> a = new CopyOnWriteArrayList<>();
// 单线程删除:removeIf
a.removeIf(s -> s.startsWith("tmp"));
// 复杂并发:ReentrantLock 保护复合操作

修复之后,用多线程压测复现一次,确认异常不再出现,才算真正解决。并发修改异常的本质是"遍历与结构变更不允许同时进行",按场景选对容器或锁,问题就迎刃而解。

并发修改异常怎么处理

并发修改异常报错与修复

总结

ConcurrentModificationException 不是随机崩溃,而是 Java 在用 fail-fast 机制提示你"遍历和改结构撞车了"。排查时先分清是单线程还是多线程:单线程遍历中直接增删,改用迭代器 remove 或 removeIf;多线程共享可变集合,换用 CopyOnWriteArrayList 这类并发容器;误把普通迭代器当线程安全,则要补上显式同步或用并发容器。

三者的共同底线是"遍历与修改分离"。不要吞掉异常、也不要无脑加锁,按读写比例选对方案,再用压测复现确认。理解迭代器、线程安全与集合遍历之间的关系,这个异常就不再是深夜排查的噩梦,而是一次就能定位清楚地小问题。

常见问题

Q:为什么单线程也会抛这个异常?

因为增强 for 循环底层用的是迭代器,你在循环里直接调 list.remove 改了集合结构,迭代器的修改计数对不上就抛错。改用 it.remove() 或 removeIf 即可避免。

Q:用了迭代器就线程安全了吗?

没有。普通集合的迭代器只是 fail-fast,只在单线程内提示结构冲突,对多线程毫无保护作用。多线程场景要换并发容器或自己加锁。

Q:CopyOnWriteArrayList 是不是最省心的选择?

只在读多写少时省心。它每次写入都复制底层数组,写频繁会明显变慢。写多的场景反而该用显式锁保护复合操作。

延伸学习