
1. 从一次线上告警说起java.util.ConcurrentModificationException 异常原因到底是什么java.util.ConcurrentModificationException这个异常几乎每个写过 Java 集合遍历的人都撞见过。它的字面意思是「并发修改异常」但很多人第一次遇到它时其实是在单线程代码里——没有开任何线程只是在一个普通的for-each循环里删了一个元素程序就炸了。这就让人很困惑明明没有并发为什么叫并发修改异常先把结论摆出来这个异常的本质不是「多线程」,而是「迭代过程中集合的结构被改动了」。Java 的集合类ArrayList、HashMap、HashSet等内部维护了一个modCount字段记录集合被结构性修改的次数。当你拿到一个迭代器时迭代器会把当前的modCount拷贝一份存到自己的expectedModCount里。之后每次调用next()迭代器都会检查这两个值是否相等一旦不等就抛出ConcurrentModificationException。它能做什么判断它其实是一个「快速失败」fail-fast机制。设计者的意图是与其让你在数据已经错乱的情况下继续遍历、拿到莫名其妙的结果不如立刻报错让你尽早发现问题。所以这个异常不是 bug而是一种保护。适合谁看如果你正在写业务代码、做数据清洗、处理订单列表或者维护一个老项目只要涉及集合的增删改查这篇文章都能帮到你。我会从源码层面拆开modCount和expectedModCount的对比逻辑给出可以直接复制运行的复现代码再一步步用断点验证最后给出Iterator.remove()和并发集合两种修复路径。全程按「复现—定位—修复—验证」的顺序走你可以边看边在 IDE 里敲。需要说明的是本文聚焦的是异常原因和排查路径不涉及任何环境搭建的灰色操作。如果你在本地跑代码时需要调用大模型辅助分析堆栈可以用 TaoToken 这类聚合平台后面会给出具体配置。2. 复现 ConcurrentModificationExceptionfor-each 遍历时增删元素的典型场景要理解一个异常最好的办法就是亲手把它复现出来。这一节我给你三段可复制的代码分别对应最常见的三种触发场景。你可以在本地建一个 Maven 项目或者直接用 IDE 的 scratch file 跑。2.1 场景一for-each 循环里删除元素这是最经典的触发方式代码短到不能再短import java.util.ArrayList; import java.util.List; public class Demo1 { public static void main(String[] args) { ListString list new ArrayList(); list.add(A); list.add(B); list.add(C); for (String s : list) { if (B.equals(s)) { list.remove(s); } } System.out.println(list); } }运行结果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 Demo1.main(Demo1.java:12)注意堆栈里的ArrayList$Itr.checkForComodification这就是罪魁祸首。for-each语法糖在编译后会被展开成Iterator的调用等价于IteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); if (B.equals(s)) { list.remove(s); // 这里用的是 list.remove不是 it.remove } }问题就出在list.remove(s)上。它修改的是ArrayList自己的modCount而迭代器手里的expectedModCount还是旧值两者一对比就炸了。2.2 场景二多线程共享集合单线程能触发多线程当然也能。下面这段代码开两个线程一个遍历一个删除import java.util.ArrayList; import java.util.List; public class Demo2 { public static void main(String[] args) throws InterruptedException { ListInteger list new ArrayList(); for (int i 0; i 1000; i) { list.add(i); } Thread t1 new Thread(() - { for (Integer i : list) { System.out.println(i); } }); Thread t2 new Thread(() - { try { Thread.sleep(1); } catch (InterruptedException ignored) {} list.remove(500); }); t1.start(); t2.start(); t1.join(); t2.join(); } }这段代码不一定每次都抛异常取决于线程调度。但只要t2在t1遍历到一半时删除了元素t1的迭代器就会在下一次next()时抛异常。这种「偶发」正是多线程场景最难排查的地方。2.3 场景三被忽略的间接迭代很多人以为只有显式写for循环才会迭代其实不少方法内部也在迭代。比如toString()、hashCode()、equals()、containsAll、removeAll、retainAll以及把集合作为另一个集合的构造参数时。看这个例子import java.util.ArrayList; import java.util.List; public class Demo3 { public static void main(String[] args) { ListString list new ArrayList(); list.add(A); list.add(B); // 在遍历过程中调用 toString间接触发迭代 for (String s : list) { System.out.println(list.toString()); list.add(C); } } }list.toString()内部会遍历所有元素如果此时集合正在被修改同样会抛异常。这类间接迭代是排查时最容易漏掉的点后面讲断点配置时会专门说怎么定位。3. 用断点看穿 modCount 与 expectedModCount可复制的调试配置光看堆栈只能知道「哪里抛的」要真正理解「为什么抛」得把modCount和expectedModCount这两个值在调试器里盯住。这一节我给你一套可以直接用的断点配置。3.1 在 checkForComodification 下断点打开你的 IDEIntelliJ IDEA 或 Eclipse 都行找到 JDK 源码里的ArrayList类。如果看不到源码在项目结构里把 JDK 的 source 路径挂上即可。然后定位到checkForComodification方法final void checkForComodification() { if (modCount ! expectedModCount) throw new ConcurrentModificationException(); }在这一行if上打一个条件断点条件写modCount ! expectedModCount。这样只有真正要抛异常时才会停下来不会在每次next()时都断住。停下来之后在 Variables 面板里展开this你会看到两个字段字段含义变化时机modCount集合被结构性修改的总次数每次 add/remove/clear 都会 1expectedModCount迭代器创建时拷贝的快照只在迭代器初始化时赋值在 Demo1 里list初始有 3 个元素modCount是 3。创建迭代器时expectedModCount也拷贝成 3。当list.remove(B)执行后modCount变成 4而expectedModCount还是 3。下一次next()调用checkForComodification4 ! 3异常抛出。整个过程一目了然。3.2 用条件断点抓多线程场景多线程场景下异常是偶发的普通断点很难复现。这时候可以用条件断点配合日志。在ArrayList.add或remove方法入口打一个断点条件写Thread.currentThread().getName().equals(t2)这样只有 t2 线程修改集合时才会断住。断住后切到 t1 线程的栈帧看它的迭代器状态就能确认是不是 t1 正在遍历时被 t2 改了。如果你本地调试时想借助大模型分析堆栈可以把异常信息贴给模型。TaoToken 提供了统一的 API 入口配置方式如下这是 OpenAI 兼容格式的 JSON 配置片段路径按你实际项目放{ baseUrl: https://taotoken.net/api, apiKey: 你的_API_KEY, model: claude-sonnet-4-5, temperature: 0.3 }把这段配置放进你的 HTTP 客户端或 SDK 初始化代码里就能把堆栈发给模型做归因分析。注意baseUrl用https://taotoken.net/api不要带多余路径。API Key 在控制台的 API Keys 页面生成模型 ID 按你实际订阅的填。3.3 断点之外用日志辅助定位有些生产环境不方便挂调试器这时候可以在集合操作前后打日志把modCount反射出来对比。虽然modCount是protected字段但通过反射能读到import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; public class ModCountProbe { public static int getModCount(List? list) throws Exception { Field f ArrayList.class.getDeclaredField(modCount); f.setAccessible(true); return f.getInt(list); } public static void main(String[] args) throws Exception { ListString list new ArrayList(); list.add(A); System.out.println(modCount getModCount(list)); } }这段代码在排查老项目时特别有用能帮你在不打断点的情况下确认集合到底被改了几次。4. 修复与验证Iterator.remove 与并发集合替换的完整步骤定位到原因之后修复其实有明确的路径。这一节给你两种方案并附上验证步骤确保改完之后真的不再抛异常。4.1 方案一用 Iterator.remove 替代 list.remove如果只是单线程遍历时删除元素最轻量的改法是把for-each换成显式Iterator用迭代器自己的removeimport java.util.ArrayList; import java.util.Iterator; import java.util.List; public class Fix1 { public static void main(String[] args) { ListString list new ArrayList(); list.add(A); list.add(B); list.add(C); IteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); if (B.equals(s)) { it.remove(); // 关键用迭代器的 remove } } System.out.println(list); // 输出 [A, C] } }为什么it.remove()不会抛异常看源码就明白了public void remove() { if (lastRet 0) throw new IllegalStateException(); checkForComodification(); try { ArrayList.this.remove(lastRet); cursor lastRet; lastRet -1; expectedModCount modCount; // 关键同步更新 expectedModCount } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } }它在删除之后把expectedModCount重新赋值为modCount两者又一致了所以后续next()不会报错。这就是Iterator.remove和list.remove的本质区别。验证步骤把 Demo1 的代码替换成 Fix1运行输出[A, C]没有异常。再试着连续删除多个元素比如把条件改成删除所有偶数确认依然正常。4.2 方案二换成并发集合如果是多线程共享集合Iterator.remove也救不了你因为问题出在多个线程同时改。这时候要换并发集合。常用的有CopyOnWriteArrayList和ConcurrentHashMap。CopyOnWriteArrayList的思路是「写时复制」每次修改都复制一份新数组读操作在旧数组上进行所以遍历时不会被写操作干扰。适合读多写少的场景import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; public class Fix2 { public static void main(String[] args) throws InterruptedException { ListInteger list new CopyOnWriteArrayList(); for (int i 0; i 1000; i) { list.add(i); } Thread t1 new Thread(() - { for (Integer i : list) { // 遍历时即使有其他线程修改也不会抛 ConcurrentModificationException } System.out.println(遍历完成); }); Thread t2 new Thread(() - { try { Thread.sleep(1); } catch (InterruptedException ignored) {} list.remove(Integer.valueOf(500)); }); t1.start(); t2.start(); t1.join(); t2.join(); } }运行后输出「遍历完成」不再抛异常。注意CopyOnWriteArrayList的迭代器是「快照」式的遍历时看不到遍历开始后的修改这是它的特性不是 bug。如果是Map场景用ConcurrentHashMapimport java.util.Map; import java.util.concurrent.ConcurrentHashMap; public class Fix3 { public static void main(String[] args) { MapString, Integer map new ConcurrentHashMap(); map.put(A, 1); map.put(B, 2); for (Map.EntryString, Integer e : map.entrySet()) { if (B.equals(e.getKey())) { map.remove(e.getKey()); // ConcurrentHashMap 允许遍历时删除 } } System.out.println(map); // 输出 {A1} } }ConcurrentHashMap的迭代器是弱一致性的遍历时允许并发修改不会抛ConcurrentModificationException。4.3 验证清单改完之后按这个清单逐项验证验证项操作预期结果单线程删除跑 Fix1输出 [A, C]无异常多线程遍历删除跑 Fix2输出「遍历完成」无异常Map 遍历删除跑 Fix3输出 {A1}无异常间接迭代在遍历中调 toString用并发集合后不再抛异常如果验证时还有异常回到第 3 节的断点配置重新看modCount和expectedModCount的值。5. 本篇常见报错排查401、local proxy failed、reading choices 与 OAuth这一节集中处理排查过程中容易遇到的几类报错。有些是代码层面的有些是调用大模型辅助分析时的配置问题分开说。5.1 代码层面的报错报错一java.util.NoSuchElementException有时候你以为是ConcurrentModificationException实际抛的是NoSuchElementException。看next()源码public E next() { checkForComodification(); int i cursor; if (i size) throw new NoSuchElementException(); ... }当cursor size时抛的是NoSuchElementException说明迭代器已经走到末尾还在调next()。常见于手写while循环时忘了先判断hasNext()。修复方法是严格用while (it.hasNext())包住it.next()。报错二IllegalStateException如果你在it.next()之前就调了it.remove()会抛IllegalStateException。因为remove里第一行就是if (lastRet 0) throw new IllegalStateException()。lastRet只有在next()成功后才被赋值。修复方法是确保remove紧跟在next之后。报错三UnsupportedOperationException用Arrays.asList()或Collections.unmodifiableList()得到的集合迭代时删除会抛UnsupportedOperationException不是ConcurrentModificationException。因为这类集合是固定大小的。修复方法是包一层new ArrayList(Arrays.asList(...))。5.2 调用大模型辅助分析时的报错如果你用 TaoToken 的 API 把堆栈发给模型分析可能会遇到下面几类报错。401 Unauthorized这是最常见的。原因通常是 API Key 没填、填错或者请求头格式不对。检查两点一是 Key 是否从控制台的 API Keys 页面正确复制二是请求头里是否带了Authorization: Bearer 你的_KEY。如果用的是 SDK确认apiKey字段名没写错。local proxy failed这个报错通常出现在你本地配了 HTTP 代理但代理没启动或端口不对。检查你的环境变量HTTP_PROXY、HTTPS_PROXY是否指向了一个可用的地址。如果不需要代理直接清空这两个变量再试。注意这里说的是本地开发环境的网络配置不涉及任何跨境访问工具。reading choices 相关报错如果你用的是 OpenAI 兼容格式响应体里会有choices数组。报错信息里出现reading choices或cannot read property choices of undefined说明返回的不是预期结构。常见原因是baseUrl写错了比如多写了/v1或少写了路径。正确的baseUrl是https://taotoken.net/api模型 ID 按你订阅的填比如claude-sonnet-4-5。三件套Base URL Key Model ID缺一不可。OAuth 相关报错如果你用的是 Claude Code 这类命令行工具可能会遇到 OAuth 认证失败。这类工具通常需要配置settings.json把认证方式指向 API Key 而不是 OAuth。配置片段如下{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: 你的_API_KEY, ANTHROPIC_MODEL: claude-sonnet-4-5 } }把这段放进 Claude Code 的settings.json重启工具即可。如果还报 OAuth 错误检查是不是有旧的凭据缓存清掉再试。5.3 排查顺序建议遇到报错别慌按这个顺序走先看异常类型是ConcurrentModificationException还是别的再看堆栈第一行定位到具体方法然后看modCount和expectedModCount的值最后判断是单线程还是多线程场景。如果是调用 API 的报错先确认三件套配置再看 HTTP 状态码。6. 把异常原因变成排查习惯从 modCount 到并发集合的选型写到这里java.util.ConcurrentModificationException的来龙去脉应该清楚了。它不是一个需要「绕过」的障碍而是一个帮你发现集合被意外修改的信号。modCount和expectedModCount的对比机制本质上是在告诉你迭代器创建之后集合的结构变了继续遍历可能拿到不一致的数据。我在实际项目里踩过的坑是有一次在遍历订单列表时调用了另一个方法那个方法内部对同一个列表做了removeAll结果抛异常的位置和真正修改的位置隔了好几层调用栈排查花了很久。后来养成的习惯是只要在遍历中要修改集合先问自己三个问题是单线程还是多线程是删除还是新增能不能用Iterator.remove或并发集合这三个问题问完方案基本就定了。选型上给个简单的判断单线程遍历删除用Iterator.remove单线程遍历新增先收集到临时列表再addAll多线程读多写少用CopyOnWriteArrayList多线程读写都频繁用ConcurrentHashMap或加锁。不要为了省事直接上并发集合CopyOnWriteArrayList每次写都复制数组写多的时候性能很差。如果你在排查过程中需要把堆栈或源码片段发给大模型做归因TaoToken 的 API 入口是https://taotoken.net/api模型对话入口在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。长期做编码和 Agent 任务的话Coding Plan 在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。API Key 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 生成。最后留一个可以立刻动手的练习把你项目里所有的for-each循环扫一遍凡是循环体里有list.remove、map.put、set.add的全部标记出来逐个判断是单线程还是多线程然后按上面的方案改掉。改完跑一遍单元测试确认没有ConcurrentModificationException。这个练习做完你对这个异常的理解会比看十篇文章都深。