1. 一个看似简单却暗藏玄机的操作在Java开发里遍历List并修改其中的元素这几乎是每个开发者都做过无数次的操作。乍一看这有什么难的不就是写个循环然后list.set(i, newValue)吗或者用增强for循环直接修改对象的属性。我刚开始也是这么想的直到有一次在线上环境一个看似功能正常的列表更新逻辑在特定并发和数据量下导致了诡异的ConcurrentModificationException甚至在某些情况下修改看似成功了但遍历的结果却和预期大相径庭。那次排查花了大半天最终定位到问题就出在这个“遍历并修改”的环节。自那以后我才真正意识到这个操作背后涉及到的集合内部状态、迭代器协议和并发安全远不是一句“能改”或“不能改”那么简单。今天我们就来彻底拆解这个“坑”。无论你是刚入门的新手还是有一定经验的开发者理解这个问题的本质都能让你在未来的编码中避免很多不必要的调试时间。我们不仅会讲清楚为什么会有坑更会深入探讨在不同场景下单线程、多线程、不同遍历方式的安全修改策略以及其背后的设计哲学。记住在集合的世界里“遍历”和“修改”是两个需要谨慎协调的动作。2. 迭代器的“快照”幻觉与ConcurrentModificationException的根源很多人第一次踩坑大概率是遇到了著名的ConcurrentModificationException。这个异常的名字很有迷惑性它字面意思是“并发修改异常”但即使在单线程环境下只要你用错了方式它照样会抛出来。要理解它我们必须深入到ArrayList最常用的List实现的迭代器实现机制中去。ArrayList内部维护了一个modCount字段翻译过来叫“修改次数计数器”。任何会结构化修改ArrayList的操作比如add、remove注意set方法不算它只是替换元素不改变数组大小都会使这个modCount加1。当你通过list.iterator()获取一个迭代器时这个迭代器对象会在创建的那一刻将当前集合的modCount值记录到自己的一个叫expectedModCount的字段中。你可以把这个expectedModCount理解为迭代器对自己所遍历的集合“原始状态”的一个快照版本号。接下来在迭代器进行next()、remove()等操作前它都会执行一个检查checkForComodification()。这个方法的逻辑简单而致命比较当前的modCount和迭代器自己记录的expectedModCount是否相等。如果不相等就立刻抛出ConcurrentModificationException。那么什么操作会导致不相等呢在迭代器遍历过程中直接调用集合自身的add或remove方法。这是最经典的错误场景。例如ListString list new ArrayList(Arrays.asList(A, B, C)); for (String s : list) { // 这里隐含使用了迭代器 if (B.equals(s)) { list.remove(s); // 错误直接调用list的remove改变了modCount } }增强for循环for-each在底层就是使用了迭代器。当执行到list.remove(“B”)时集合的modCount增加了但迭代器内部的expectedModCount还是旧值。在下一轮循环调用next()时检查失败异常抛出。使用多个迭代器其中一个迭代器修改了集合。即使是在单线程内你创建了两个迭代器iter1和iter2。iter1遍历时调用了list.add(...)这会导致modCount变化。当iter2继续自己的遍历时同样会触发异常因为它记录的expectedModCount已经过时了。这里的关键认知是ConcurrentModificationException的设计初衷是一种“快速失败”的机制。它不是为了处理多线程竞争而是为了在单线程环境下尽早发现程序逻辑上的错误——即在迭代器约定的“遍历会话”期间使用了非迭代器自身提供的方式去修改集合的结构。它告诉你“嘿你一边让我迭代器按既定路线导游一边又偷偷把景点地图给改了这活我没法干了”注意set(index, element)方法不会导致modCount增加因为它不改变数组的结构容量和大小。所以在迭代过程中如果你只是修改某个索引位置元素的内容属性而不是增删元素从modCount机制上讲是安全的不会直接引发ConcurrentModificationException。但这并不意味着它就完全安全后面我们会看到其他陷阱。3. 不同遍历方式下的“安全”与“雷区”知道了基本原理我们来看看日常开发中几种常见的遍历方式分析它们各自的行为和风险点。我们以一个简单的ArrayListInteger为例目标是将所有等于1的元素改为100。3.1 普通for循环与索引遍历看似最“可控”ListInteger list new ArrayList(Arrays.asList(1, 2, 1, 3, 1)); for (int i 0; i list.size(); i) { if (list.get(i) 1) { list.set(i, 100); // 直接通过索引修改 } } // 结果 [100, 2, 100, 3, 100]为什么这种方式可行且安全因为它完全绕开了迭代器。list.size()和list.get(i)、list.set(i)都是直接通过数组索引访问底层Object[] elementData不涉及modCount检查。你就像拿着地图和坐标直接去操作数组没有“导游”迭代器的约束。但是这里有三个巨大的“坑”需要警惕在遍历中删除元素这是普通for循环最容易出错的地方。ListInteger list new ArrayList(Arrays.asList(1, 2, 1, 3, 1)); for (int i 0; i list.size(); i) { if (list.get(i) 1) { list.remove(i); // 危险操作 } } // 你以为的结果 [2, 3] // 实际可能的结果 [2, 1, 3] 或 [2, 3] (取决于运气)当你remove(i)后i位置的元素被移除其后所有元素会向前移动一位索引减1。但你的循环变量i在本次循环结束后依然会i。这会导致你跳过了原本位于i1位置的那个元素因为它移动到了i位置而循环已经检查过这个i了。在上面的例子中连续的1会导致第二个1被遗漏。正确的做法是删除后让索引回退if (list.get(i) 1) { list.remove(i); i--; // 关键回退索引以检查移动到当前位置的新元素 }在遍历中增加元素同样需要小心索引。for (int i 0; i list.size(); i) { list.add(i, 0); // 在当前位置插入0 i; // 必须跳过刚插入的元素否则会无限循环 }这会改变list.size()的返回值如果循环条件写死了一个初始的size可能会导致IndexOutOfBoundsException或逻辑错误。性能问题对于LinkedListlist.get(i)是一个O(n)的操作因为需要从头遍历链表到第i个节点。在这种场景下使用普通for循环会导致算法时间复杂度从O(n)恶化到O(n²)性能极差。3.2 迭代器(Iterator)遍历官方推荐的“契约式”修改Iterator接口提供了remove()方法这是在迭代过程中安全删除元素的唯一标准方式。ListInteger list new ArrayList(Arrays.asList(1, 2, 1, 3, 1)); IteratorInteger iterator list.iterator(); while (iterator.hasNext()) { Integer value iterator.next(); if (value 1) { iterator.remove(); // 使用迭代器自身的remove方法 } } // 结果 [2, 3]为什么iterator.remove()是安全的因为它在内部实现中不仅执行了删除集合元素的操作还会同步更新它自己持有的expectedModCount使其与集合最新的modCount保持一致。这样后续的迭代操作就不会触发ConcurrentModificationException。它遵守了“谁遍历谁修改”的契约。但是迭代器的局限性也很明显它没有set()方法。Iterator接口只提供了remove()没有提供修改当前元素的方法。这意味着你无法通过迭代器直接将一个元素的值改成另一个。你只能通过迭代器获取到当前元素的引用对于对象然后修改这个对象的内部状态如果它是可变对象。它不能添加元素。Iterator接口没有add方法。3.3 增强for循环(for-each)语法糖下的“只读”陷阱增强for循环是迭代器遍历的语法糖代码更简洁。但正因为它是语法糖它完全隐藏了迭代器对象导致你无法调用iterator.remove()。for (Integer num : list) { if (num 1) { // list.remove(num); // 抛出 ConcurrentModificationException // list.set(...); // 无法直接操作索引且set需要索引 // 只能修改num引用的对象内部状态如果Integer是不可变的则无效 num 100; // 这行代码毫无意义它只是改变了局部变量num的引用不会影响列表。 } }重要结论增强for循环本质上是一种“只读”或“仅观察”的遍历模式。如果你想在遍历时进行结构性修改增删或者需要通过索引进行值替换请直接使用显式的Iterator或普通for循环。3.4ListIterator功能更强大的双向迭代器ListIterator是Iterator的子接口专为List设计。它解决了普通Iterator的两个主要短板提供了set(E e)方法用于替换通过next()或previous()返回的最后一个元素。提供了add(E e)方法在当前位置插入一个新元素。ListInteger list new ArrayList(Arrays.asList(1, 2, 3)); ListIteratorInteger listIterator list.listIterator(); while (listIterator.hasNext()) { Integer value listIterator.next(); if (value 2) { listIterator.set(200); // 安全地修改当前元素 listIterator.add(250); // 安全地在当前元素后插入新元素 } } // 结果 [1, 200, 250, 3]ListIterator的set和add方法同样会维护expectedModCount的一致性因此是安全的。当你需要在遍历中同时进行修改、添加操作时ListIterator是最佳选择。4. 对象引用与元素修改的深层陷阱即使你避开了ConcurrentModificationException使用list.set(index, newValue)或通过迭代器获取引用修改对象属性依然可能遇到逻辑上的“坑”。这个坑源于对Java对象引用和集合存储机制的理解偏差。场景你有一个ListPerson遍历时想修改某个Person的姓名。class Person { String name; // 构造器、getter、setter省略 } ListPerson personList new ArrayList(); personList.add(new Person(Alice)); personList.add(new Person(Bob)); // 方法一通过索引和set安全且有效 for (int i 0; i personList.size(); i) { if (“Alice”.equals(personList.get(i).getName())) { personList.set(i, new Person(“Alicia”)); // 创建新对象替换 } } // 方法二通过迭代器获取引用修改有效但需注意 ListIteratorPerson it personList.listIterator(); while (it.hasNext()) { Person p it.next(); if (“Bob”.equals(p.getName())) { p.setName(“Robert”); // 直接修改原对象状态 // it.set(p); // 这行代码是多余的因为p就是集合中存储的那个引用 } } // 方法三增强for循环修改对象状态有效 for (Person p : personList) { p.setName(p.getName().toUpperCase()); // 可以修改对象内部状态 } // 但你不能 personList.remove(p) 或 personList.set(index, newPerson)关键点辨析list.set(index, newObj)用一个新的对象引用替换掉集合中指定位置存储的旧引用。这是“换了一个全新的零件”。obj.setXxx(newValue)通过已有的引用修改该引用所指对象内部的数据。这是“修改了原有零件的属性”。集合里存储的引用并没有变变的是引用指向的那个对象。陷阱在于“不变性”和“共享引用”如果Person是不可变类如String,Integer那么你根本无法使用方法二和方法三来“修改”列表内容。你只能使用方法一set进行替换。如果这个Person对象被多个集合或代码块共享你在遍历一个集合时修改了它的状态会立即影响所有持有该对象引用的地方。这可能是你期望的也可能是一个隐蔽的Bug。例如你可能只是想修改列表A中的Bob却不小心把缓存或列表B中的同一个Bob对象也改了。实操心得在遍历修改时想清楚你的意图是“替换列表中的元素”还是“修改元素对象的内部状态”。对于值类型或小型不可变对象多用set替换对于可变对象修改状态时要警惕副作用。在设计领域对象时慎重考虑其可变性可以有效减少这类问题。5. 并发场景下的“原子性”与“可见性”地狱单线程下的规则在多线程环境下会完全失效。ConcurrentModificationException在真正的并发修改下只是可能出现的众多问题中最“友好”的一个——它至少以异常的形式立刻告诉你出错了。更可怕的是那些没有异常但数据却错乱的情况。假设有两个线程同时操作同一个ArrayList线程A在遍历列表。线程B在向列表添加或删除元素。即使线程A使用了迭代器线程B的修改也会导致modCount变化。在线程A下一次调用next()时大概率会抛出ConcurrentModificationException。但问题在于异常的发生点是不确定的程序状态可能已经部分被破坏。更底层的问题是ArrayList本身不是线程安全的。它的内部操作如size、elementData赋值在多线程下不具备原子性可能导致内部数组状态不一致进而引发各种光怪陆离的问题比如遍历时拿到null值。ArrayIndexOutOfBoundsException。元素丢失或重复。解决方案是使用并发容器CopyOnWriteArrayList这是解决“遍历远多于修改”场景的利器。它的原理是任何修改操作add, set, remove都会先复制底层数组在副本上修改修改完成后再将副本的引用赋回给主变量。它的迭代器是基于创建那一刻的数组快照因此在迭代过程中进行集合修改是绝对安全的不会抛异常。但代价是修改开销大且迭代器无法感知创建后的修改。ListString list new CopyOnWriteArrayList(Arrays.asList(a, b, c)); for (String s : list) { // 迭代器持有的是 [“a”, “b”, “c”] 的快照 if (“b”.equals(s)) { list.add(“d”); // 安全但本次迭代看不到“d” } } // 迭代结束后list 变为 [“a”, “b”, “c”, “d”]Collections.synchronizedList它只是用一个同步锁包装了原始的List。遍历时你需要手动对整个遍历过程加锁否则在iterator()调用之后、遍历结束之前其他线程的修改依然会导致异常。ListString syncList Collections.synchronizedList(new ArrayList()); // 必须手动同步 synchronized (syncList) { IteratorString it syncList.iterator(); while (it.hasNext()) { String s it.next(); // ... 操作 } }Concurrent包中的其他队列或集合如ConcurrentLinkedQueue根据具体业务场景选择。踩坑实录我曾在一个消息处理模块中使用ArrayList存储待处理消息一个线程遍历处理另一个线程接收新消息并加入列表。在低负载时一切正常上线后流量一大时不时就爆出ConcurrentModificationException导致消息丢失。排查后将其改为CopyOnWriteArrayList虽然写入性能有轻微损耗但换来了遍历的绝对安全性和系统的整体稳定。这个案例的教训是在涉及多线程和集合遍历修改的场景不要抱有任何侥幸心理直接选用设计上就支持此场景的并发容器。6. 性能考量与最佳实践选择指南了解了各种机制和陷阱后我们该如何选择下面这个表格总结了不同场景下的推荐做法遍历/修改需求单线程场景多线程场景关键理由与注意事项仅遍历不修改for-each或IteratorCopyOnWriteArrayListfor-each代码最简洁。多线程下需保证迭代器安全。遍历并修改元素值替换普通for循环list.set()或ListIterator.set()CopyOnWriteArrayList(遍历快照修改用set)或同步块 普通for循环for循环直接索引最快。ListIterator更符合迭代器契约。多线程下CopyOnWriteArrayList的set也是复制数组。遍历并删除元素Iterator.remove()或反向普通for循环CopyOnWriteArrayList(遍历快照删除用remove)或同步块Iterator.remove()正向for循环删除必须处理索引回退易错。Iterator.remove()最安全直观。反向遍历删除可避免索引错乱。遍历并添加元素ListIterator.add()CopyOnWriteArrayList(遍历快照添加用add)或同步块ListIterator.add()只有ListIterator能在遍历中安全添加。复杂操作混合增删改ListIterator(首选)或新建结果集合考虑使用快照或任务队列模式避免在遍历原集合时直接修改逻辑复杂时ListIterator能提供统一的安全操作接口。多线程下建议将“遍历”和“修改”解耦。通用最佳实践建议优先使用for-each进行只读遍历代码清晰不易出错。需要删除时首选Iterator.remove()这是最标准、最安全的方式意图明确。需要修改/替换元素时考虑普通for循环set性能通常更好逻辑直接。需要在遍历中插入元素时必须用ListIterator没有其他安全的单线程方式。多线程环境无脑考虑CopyOnWriteArrayList读多写少或并发队列不要试图在普通的ArrayList或同步包装器上自己处理并发复杂度极高且易错。考虑“函数式”或“流式”操作在Java 8中对于复杂的过滤、转换操作可以先将集合转为Stream利用filter、map、collect等操作生成一个新的集合而不是在遍历中修改原集合。这更符合不可变思想能避免很多副作用。// 将列表中所有1替换为100并收集到新列表 ListInteger newList list.stream() .map(num - num 1 ? 100 : num) .collect(Collectors.toList()); // 或者使用replaceAll方法原地修改 list.replaceAll(num - num 1 ? 100 : num);7. 从源码角度再探ArrayList.Itr的remove()实现为了加深理解我们看一眼ArrayList内部迭代器Itr的remove()方法源码基于OpenJDKprivate class Itr implements IteratorE { int cursor; // 下一个要返回的元素的索引 int lastRet -1; // 最近一次返回的元素的索引-1表示没有 int expectedModCount modCount; // 初始化时记录修改计数 Itr() {} public boolean hasNext() { return cursor ! size; } SuppressWarnings(“unchecked”) public E next() { checkForComodification(); // 每次next前检查 // ... 获取元素并移动cursor和lastRet } public void remove() { if (lastRet 0) throw new IllegalStateException(); // 必须在next之后调用 checkForComodification(); // 删除前检查 try { ArrayList.this.remove(lastRet); // 调用外部类的remove方法 cursor lastRet; // 调整游标因为后面的元素前移了 lastRet -1; // 重置lastRet保证不能连续remove expectedModCount modCount; // 关键同步修改计数 } catch (IndexOutOfBoundsException ex) { throw new ConcurrentModificationException(); } } final void checkForComodification() { if (modCount ! expectedModCount) throw new ConcurrentModificationException(); } }核心就在remove()方法的expectedModCount modCount;这一行。在调用外部类remove方法会增加modCount后迭代器立即将自己的expectedModCount更新为新的modCount值从而保证了自身状态与集合同步后续操作就不会再触发异常。而如果你用的是list.remove(lastRet)这行同步代码不会被执行expectedModCount就落后了导致下次next()时检查失败。这个小小的赋值语句就是迭代器安全删除机制的基石。理解到这一层你就能真正明白为什么必须用iterator.remove()而不是list.remove()。