
并发修改异常到底是怎么冒出来的?从底层机制说到真实场景
要解决问题,得先知道它为啥会出现。很多人以为这个异常只在多线程的时候才会有,其实单线程里也经常踩坑。我先给你说个我自己的糗事:前年做一个电商项目的订单筛选功能,需要遍历订单列表,把状态为“已取消”的订单删掉。当时图省事,直接用for-each循环遍历ArrayList,然后调用list.remove(order),结果一跑就报错ConcurrentModificationException。我当时还纳闷,明明是单线程,怎么会并发修改呢?后来翻源码才发现,问题出在Java集合的“快速失败(fail-fast)”机制上。
你知道迭代器(Iterator)是怎么工作的吗?当你用iterator()方法获取迭代器时,它会偷偷记录一个“预期修改次数”(expectedModCount),这个值和集合本身的“实际修改次数”(modCount)是相等的。每次你调用集合的add、remove这些会改变集合结构的方法时,modCount就会+1。而迭代器每次遍历下一个元素前,都会检查expectedModCount和modCount是不是一样,如果不一样,就直接扔出ConcurrentModificationException。我当时用for-each循环其实就是用了迭代器,结果直接调用list.remove()改了modCount,但迭代器的expectedModCount没更新,自然就报错了。
单线程的坑说完了,多线程的情况更常见。去年我们做一个用户行为分析系统,多个线程同时往一个ArrayList里写数据,另一个线程负责遍历统计。一开始没注意,结果系统一跑起来就报错,日志里全是这个异常。后来排查发现,写线程调用add()方法改了modCount,而读线程的迭代器还拿着旧的expectedModCount,一对比就炸了。Oracle的Java官方文档里就明确说了,ConcurrentModificationException通常是迭代器检测到集合在迭代过程中被修改时抛出的(链接:https://docs.oracle.com/javase/8/docs/api/java/util/ConcurrentModificationException.html,rel=”nofollow”),而且特别强调“这只是一个快速失败的机制,不是保证”,也就是说它能帮你发现问题,但不能完全避免,所以咱们写代码时得主动规避。
这里有个关键点你得记住:modCount和expectedModCount的“版本不一致”是异常的直接原因,但根本原因是“在迭代过程中,通过非迭代器的方式修改了集合结构”。不管是单线程用集合的remove(),还是多线程一个改一个遍历,本质上都是这个问题。
解决并发修改异常的6个实用方案,附代码对比和场景选择
知道了原因,解决起来就有方向了。我整理了6个实际项目中常用的方案,每个方案都有代码示例和适用场景,你可以根据自己的情况选。
方案1:用迭代器自带的remove()方法(单线程首选)
既然直接用集合的remove()会改modCount,那用迭代器自己的remove()方法不就行了?我后来把那个订单筛选功能的代码改成用Iterator遍历,调用iterator.remove(),果然不报错了。你看这段代码:
Iterator iterator = orderList.iterator();
while (iterator.hasNext()) {
Order order = iterator.next();
if ("已取消".equals(order.getStatus())) {
iterator.remove(); // 用迭代器的remove方法
}
}
为啥这个方法行?因为迭代器的remove()会同时更新expectedModCount和modCount,让它们保持一致。我 你在单线程场景下优先试试这个方法,亲测比直接用集合的remove方法靠谱多了。不过要注意,迭代器的remove()只能在next()之后调用,不然会报错,这个细节别踩坑。
方案2:用CopyOnWriteArrayList代替ArrayList(多线程读多写少)
如果是多线程场景,尤其是读操作特别多、写操作很少的时候,CopyOnWriteArrayList是个好选择。它的原理是“写时复制”,每次修改集合都会创建一个新的数组副本,迭代器遍历的是旧副本,所以永远不会触发并发修改异常。去年我们把那个用户行为分析系统的ArrayList换成CopyOnWriteArrayList后,异常直接消失了。代码也简单,直接替换集合类型就行:
List userActions = new CopyOnWriteArrayList();
// 多线程add和遍历都不会报错
不过这个方案有个缺点:写操作会复制整个数组,内存开销比较大。如果你的场景是写操作频繁,比如每秒几千次add,那别用它,会把内存撑爆。我之前在一个高频写的场景试过,结果JVM频繁GC,后来换成加锁方案才解决。
方案3:加锁同步(多线程写频繁时用)
如果写操作比较多,加锁是更稳妥的选择。可以用synchronized关键字,或者ReentrantLock,保证同一时间只有一个线程能修改或遍历集合。比如这样:
List safeList = new ArrayList();
synchronized (safeList) { // 锁住集合
for (String item safeList) {
// 遍历操作
}
}
// 修改时也需要锁住
synchronized (safeList) {
safeList.add("newItem");
}
加锁的好处是适用性广,啥场景都能用,但缺点是可能影响性能,尤其是线程多的时候容易阻塞。我 你如果用synchronized,尽量缩小同步代码块的范围,别把整个方法都锁了,只锁集合操作的部分就行。
方案4:用Iterator遍历+临时集合存要删的元素(复杂筛选场景)
有时候你需要遍历的时候不仅删除,还要根据多个条件筛选,这时候可以先用迭代器遍历,把要删的元素放到一个临时集合里,遍历完了再统一删除。比如:
List orderList = new ArrayList();
List toRemove = new ArrayList();
// 第一步:遍历找出要删的元素
for (Order order orderList) {
if ("已取消".equals(order.getStatus()) && order.getCreateTime().before(LocalDate.now().minusDays(30))) {
toRemove.add(order);
}
}
// 第二步:统一删除
orderList.removeAll(toRemove);
这个方法我在处理复杂筛选时经常用,好处是不会触发异常,而且逻辑清晰。不过要注意,如果集合里有重复元素,removeAll会把所有匹配的都删掉,这点要小心。
其他实用方案:针对Map和特殊场景
如果是Map集合(比如HashMap),多线程下可以用ConcurrentHashMap代替,它内部做了并发控制,遍历不会报错。 Java 8以后的Stream API也能帮上忙,你可以用filter()方法过滤出不需要删除的元素,然后收集成新集合,比如:
List filteredList = orderList.stream()
.filter(order -> !"已取消".equals(order.getStatus()))
.collect(Collectors.toList());
这种方式适合不需要修改原集合,而是生成新集合的场景,代码简洁,而且是函数式编程风格,现在很多项目都在用。
为了让你更清楚怎么选,我整理了一个对比表:
方案 | 适用场景 | 优点 | 缺点 |
---|---|---|---|
迭代器remove() | 单线程、简单删除 | 简单高效、无额外开销 | 只能删除当前元素 |
CopyOnWriteArrayList | 多线程、读多写少 | 无锁、读操作快 | 写操作内存开销大 |
加锁同步 | 多线程、写频繁 | 通用、安全 | 可能阻塞、影响性能 |
临时集合+removeAll | 复杂筛选、多条件删除 | 逻辑清晰、灵活 | 需要额外内存存临时集合 |
你可以根据自己的场景对号入座,比如单线程简单删除就用方案1,多线程读多写少用方案2,写频繁用方案3。记住,没有最好的方案,只有最合适的方案。我之前在一个项目里,因为没选对方案,用CopyOnWriteArrayList处理高频写操作,结果线上出了OOM,后来换成ConcurrentHashMap+加锁才解决,血的教训啊。
如果你在项目里遇到了并发修改异常,不妨试试上面这些方法,记得根据实际场景选最合适的方案。试完了可以回来告诉我哪种方法对你最有效,或者你还有其他好办法,也欢迎一起交流!
你是不是也遇到过这种情况:明明是单线程处理集合,就想遍历的时候删个元素,结果程序“啪”一下就报错ConcurrentModificationException,当时就懵了——单线程哪来的“并发”修改?我之前带实习生的时候,他就写过一段类似的代码:用for-each循环遍历ArrayList,然后在循环里直接调list.remove()删元素,跑起来就报错,他还委屈地问我:“哥,我就一个线程在跑啊,怎么会并发呢?”
其实这跟“并发”是不是多线程没关系,问题出在Java集合的“快速失败(fail-fast)”机制上。你可以把迭代器(就是for-each循环底层用的那个Iterator)想象成个“记账员”,它开始工作前会跟集合“对账本”:集合有个“实际修改次数”(modCount),迭代器会偷偷记一个“预期修改次数”(expectedModCount),俩数儿一开始是一样的。每次你调用集合的add、remove这些会变结构的方法,集合的modCount就会+1,相当于“账本更新了”。但迭代器这个“记账员”很较真,每次往下遍历元素前,都会再跟集合对一遍账——要是它记的expectedModCount和集合的modCount对不上了,就直接掀桌子:“你改了账本没告诉我!”然后就扔出这个异常。
那为啥用迭代器自己的remove()方法就没事呢?因为迭代器的remove()是个“贴心记账员”,它删元素的时候,不光会让集合的modCount+1,还会偷偷把自己记的expectedModCount也改成新的modCount,相当于“咱俩账本一起更新,下次对账还能对上”。但你直接调集合的remove()就不一样了——这相当于你直接改了集合的账本,没跟迭代器打招呼,它下次对账一看,“欸?我记的数儿怎么跟你不一样了?”可不就报错了嘛。就像你跟朋友约好各记一笔账,你偷偷改了自己的账本,朋友对账时发现对不上,肯定得跟你急啊。
所以单线程里踩这个坑,大多是因为用了for-each(也就是迭代器)遍历,却直接调了集合的remove()方法。下次再遇到,试试用迭代器的remove(),或者先把要删的元素记在临时集合里,遍历完了再统一删,保准不报错。
单线程下遍历集合时删除元素,为什么会触发并发修改异常?
单线程下出现该异常的核心原因是“快速失败(fail-fast)”机制。当使用迭代器(如for-each循环底层的Iterator)遍历集合时,迭代器会记录“预期修改次数”(expectedModCount),与集合的“实际修改次数”(modCount)绑定。若直接调用集合的remove()方法(非迭代器的remove()),会更新modCount但不更新迭代器的expectedModCount,导致下次迭代时两者不一致,触发ConcurrentModificationException。例如用for-each遍历ArrayList并调用list.remove()就会出现这种情况。
多线程操作集合时,哪种并发集合性能最好?
没有绝对“最好”的集合,需根据场景选择:读多写少场景优先选CopyOnWriteArrayList,它通过“写时复制”机制避免迭代冲突,读操作无锁高效,但写操作因复制数组内存开销大;写频繁场景可选ConcurrentHashMap(针对Map)或加锁的普通集合,前者内部分段锁控制并发,后者通过synchronized或ReentrantLock保证安全,适合写操作频繁但并发量不极端的场景;若需有序集合,可考虑ConcurrentSkipListSet/Map,支持并发读写且维持排序。
迭代器的remove()方法和集合的remove()方法有什么区别?
主要区别在于对“修改次数”的处理:迭代器的remove()会在删除元素后,同步更新自身的expectedModCount与集合的modCount,避免后续迭代时触发异常;而集合的remove()仅更新modCount,不通知迭代器,若迭代器已创建(如for-each循环中),会导致expectedModCount与modCount不一致,触发ConcurrentModificationException。 单线程遍历删除时,应优先使用迭代器的remove()而非集合的remove()。
ConcurrentModificationException能100%避免吗?
不能。Java官方文档明确说明,该异常是“快速失败”机制的产物,仅为检测并发修改的“尽力而为”机制,而非强保证。极端情况下(如多线程修改后恰好modCount又变回预期值),可能不触发异常但导致数据错乱。 需从代码逻辑上主动规避,如使用并发安全集合、加锁同步或迭代器安全操作,而非依赖异常检测。
使用CopyOnWriteArrayList时需要注意什么?
需注意两点:一是内存开销,每次写操作(add/remove)会复制整个数组,数据量大时可能导致OOM,不适合高频写场景;二是数据一致性,迭代器遍历的是修改前的数组副本,若写操作后立即遍历,可能读取到旧数据。例如多线程添加元素后,迭代器可能看不到新添加的元素,需根据业务对数据实时性的要求选择是否使用。