Skip to content

迭代器与集合遍历陷阱 ​

S1-019建议学进阶约 45 分钟JDK 21编程语言基础 · 集合

1. 本节定位 ​

for-each 看起来是最简单的语法,但它有两个隐藏问题:

  1. 它是语法糖——编译后其实是一个迭代器,理解这一点才能解释异常;
  2. 遍历中修改会抛异常——而且"删倒数第二个元素时不抛"这个巧合,让 bug 更难发现。

本节把 ConcurrentModificationException 讲透,并给出三种正确的删除写法。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 for-each 编译后等价于什么;
  • 解释 ConcurrentModificationException 的真实成因(不是"并发");
  • 熟练使用三种正确的删除方式,并知道各自适用场景;
  • 解释"为什么删倒数第二个元素不抛异常",并说清它的危险性;
  • 会用 ListIterator 双向遍历与修改元素。

3. 前置知识检查 ​

  • 知道遍历集合的三种方式吗?(《List、Set、Map》)
  • 会用 Iterator 吗?(不了解也没关系,本节从零讲)

4. 为什么需要这个知识 ​

看一段"最自然"的代码:

java
List<String> items = new ArrayList<>(List.of("a", "b", "c"));
for (String item : items) {
    if ("a".equals(item)) {
        items.remove(item);        // ConcurrentModificationException
    }
}

几乎所有人第一次写"遍历中删除"都会这么写,然后得到一个名字里带"Concurrent"(并发)的异常——但代码明明是单线程的。这个名字会让人误以为是并发问题,从而找错方向。

理解它,你才能:正确修复、正确解释、以及在面试时答对"为什么删倒数第二个元素不报错"这类问题。

5. 核心概念 ​

5.1 迭代器与 for-each 的关系 ​

java
// 你写的
for (String item : items) {
    System.out.println(item);
}

// 编译后(等价)
for (Iterator<String> it = items.iterator(); it.hasNext(); ) {
    String item = it.next();
    System.out.println(item);
}

for-each 只是迭代器的语法糖。这个认知解释了后面所有现象——异常的抛出者是迭代器,不是 for-each 本身。

Iterator 接口只有三个方法:

方法作用
hasNext()是否还有下一个
next()取出下一个元素
remove()删除刚取出的那个元素

5.2 ConcurrentModificationException 的真实成因 ​

它和"并发"没有关系,是"遍历期间集合被修改":

text
① 创建迭代器时,记录当前"修改次数"(modCount)
② 每次调用 next() 时,检查 modCount 是否还是创建时的值
③ 如果在遍历过程中集合被 add/remove(modCount 变了)
   → 抛 ConcurrentModificationException

这个机制叫 fail-fast(快速失败):集合宁可立刻报错,也不返回不可预期的结果。ArrayList、HashMap、HashSet 都是 fail-fast。

为什么会"偶发"

这正是坑所在:并不是"一修改就抛"。

  • 如果修改发生在最后一次 next() 之后(循环马上结束),就不会再调用 next(),也就没机会检查 → 不抛异常;
  • 如果修改发生在中间,后续还有 next() → 抛异常。

所以同一个 bug 可能"有时候报错、有时候不报",取决于你要删的是第几个元素。

5.3 删倒数第二个元素为什么"侥幸通过" ​

java
List<String> items = new ArrayList<>(List.of("a", "b", "c"));
for (String item : items) {
    if ("b".equals(item)) {          // b 是倒数第二个
        items.remove(item);
    }
}
System.out.println(items);            // [a, c] —— 没有抛异常!

过程分析:

text
迭代器内部有一个游标 cursor:
  取 "a" → cursor = 1
  取 "b" → cursor = 2,然后 remove("b"),此时 size 从 3 变成 2
  判断 hasNext():cursor(2) != size(2) → false → 循环结束
  ↑ 关键:没有再调用 next(),所以没有触发检查

删第一个元素会立刻抛异常(cursor = 1,size = 2,还会继续 next());删倒数第二个则"恰好"跳过检查。

危险在哪:这段代码在数据量变化时(比如列表变长)就会突然开始抛异常。"能跑通"不等于"写对了"。

5.4 三种正确的删除方式 ​

java
// 方式一:removeIf(推荐,最简洁)
items.removeIf("b"::equals);

// 方式二:显式迭代器的 remove
Iterator<String> iterator = items.iterator();
while (iterator.hasNext()) {
    if ("b".equals(iterator.next())) {
        iterator.remove();
    }
}

// 方式三:倒序下标循环(ArrayList 友好)
for (int i = items.size() - 1; i >= 0; i--) {
    if ("b".equals(items.get(i))) {
        items.remove(i);
    }
}
方式代码量适用场景
removeIf最少首选,条件简单时
迭代器中需要在删除时做额外逻辑,或遍历 Set
倒序循环中需要按索引处理,或对 ArrayList 做批量删除

为什么倒序不报错

普通的下标循环 for (int i = 0; i < size; i++) 在删除元素后会跳过下一个元素(因为后面的元素前移了);倒序遍历则不受影响。

而且下标循环不使用迭代器,所以不会触发 fail-fast 检查——这是"绕过"而不是"修复",只在 ArrayList 上语义清晰。

5.5 ListIterator:更强大的迭代器 ​

ListIterator 比 Iterator 多出四个能力:双向遍历、set 修改当前元素、add 插入元素、通过 nextIndex / previousIndex 拿到下标。

java
ListIterator<String> it = list.listIterator();
while (it.hasNext()) {
    String value = it.next();
    it.set(value.toUpperCase());        // 修改当前元素(安全)
}

注意:只有 List 有 ListIterator,Set 没有(因为无序,谈不上位置)。

5.6 并发场景怎么办 ​

上面的方式都是单线程下的正确写法。多线程并发修改时,即使使用迭代器的 remove 也不安全。

场景方案
多线程读多写少CopyOnWriteArrayList(fail-safe,遍历时看到的是快照)
多线程读写均衡Collections.synchronizedList(...) + 外部同步遍历
高并发ConcurrentHashMap 等并发集合(《JUC 工具类与并发容器》)

注意:CopyOnWriteArrayList 遍历时不会抛异常,但也读不到遍历期间的新增元素——这是"快照"语义,不是 bug。

6. 递进示例 ​

6.1 最小示例:迭代器遍历 ​

java
Iterator<String> iterator = items.iterator();
while (iterator.hasNext()) {
    System.out.println(iterator.next());
}

6.2 业务示例:批量清理 ​

java
// 需求:删除所有以 "temp" 开头的元素
items.removeIf(item -> item.startsWith("temp"));

removeIf 是首选:一行、语义清晰、内部实现高效(ArrayList 里做一次搬移而不是多次)。

6.3 反例:三种错误写法 ​

java
// 错误一:for-each 里直接 remove(取决于删除位置,可能抛异常)
for (String item : items) {
    items.remove(item);
}

// 错误二:普通下标循环里删除(漏掉元素)
for (int i = 0; i < items.size(); i++) {
    if (需要删除) {
        items.remove(i);      // i 后面的元素前移,i 会跳过下一个
    }
}

// 错误三:用另一个集合收集后再删(能跑,但多一次遍历与一份内存)
List<String> toRemove = new ArrayList<>();
for (String item : items) {
    if (需要删除) {
        toRemove.add(item);
    }
}
items.removeAll(toRemove);

错误二最容易产生"漏删"的静默 bug——不报错,只是结果不对。

7. 完整可运行代码 ​

文件:labs/collections/src/main/java/cn/kb/learn/collection/IteratorLesson.java

java
package cn.kb.learn.collection;

import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;

public class IteratorLesson {
    public static void main(String[] args) {
        // 1. 显式使用迭代器
        List<String> items = new ArrayList<>(List.of("a", "b", "c", "d"));
        Iterator<String> iterator = items.iterator();
        StringBuilder visited = new StringBuilder();
        while (iterator.hasNext()) {
            visited.append(iterator.next()).append(' ');
        }
        System.out.println("显式迭代器遍历: " + visited.toString().trim());

        // 2. for-each 是迭代器的语法糖
        StringBuilder forEach = new StringBuilder();
        for (String item : items) {
            forEach.append(item).append(' ');
        }
        System.out.println("for-each 遍历: " + forEach.toString().trim());

        // 3. 遍历中删除的三种正确方式
        List<String> byRemoveIf = new ArrayList<>(List.of("a", "b", "c"));
        byRemoveIf.removeIf("b"::equals);
        System.out.println("removeIf: " + byRemoveIf);

        List<String> byIterator = new ArrayList<>(List.of("a", "b", "c"));
        Iterator<String> removing = byIterator.iterator();
        while (removing.hasNext()) {
            if ("b".equals(removing.next())) {
                removing.remove();
            }
        }
        System.out.println("iterator.remove: " + byIterator);

        List<String> byReverse = new ArrayList<>(List.of("a", "b", "c"));
        for (int i = byReverse.size() - 1; i >= 0; i--) {
            if ("b".equals(byReverse.get(i))) {
                byReverse.remove(i);
            }
        }
        System.out.println("倒序下标循环: " + byReverse);

        // 4. 错误方式:for-each 中直接删除
        List<String> broken = new ArrayList<>(List.of("a", "b", "c"));
        try {
            for (String item : broken) {
                if ("a".equals(item)) {
                    broken.remove(item);
                }
            }
        } catch (ConcurrentModificationException exception) {
            System.out.println("for-each 中直接删除: ConcurrentModificationException");
        }

        // 5. 删倒数第二个元素:不抛异常,但同样是错的
        List<String> lucky = new ArrayList<>(List.of("a", "b", "c"));
        for (String item : lucky) {
            if ("b".equals(item)) {
                lucky.remove(item);
            }
        }
        System.out.println("删倒数第二个(侥幸通过): " + lucky);

        // 6. ListIterator:可双向遍历、可修改元素
        List<String> editable = new ArrayList<>(List.of("a", "b", "c"));
        ListIterator<String> listIterator = editable.listIterator();
        StringBuilder changed = new StringBuilder();
        while (listIterator.hasNext()) {
            int index = listIterator.nextIndex();
            String value = listIterator.next();
            listIterator.set(value.toUpperCase());
            changed.append("下标 ").append(index).append(" -> ").append(value.toUpperCase()).append(" | ");
        }
        System.out.println("ListIterator 修改过程: " + changed.toString().trim());
        System.out.println("修改后: " + editable);
    }
}

运行命令:

powershell
cd labs
./mvnw -pl collections compile
java -cp collections/target/classes cn.kb.learn.collection.IteratorLesson

预期输出:

text
显式迭代器遍历: a b c d
for-each 遍历: a b c d
removeIf: [a, c]
iterator.remove: [a, c]
倒序下标循环: [a, c]
for-each 中直接删除: ConcurrentModificationException
删倒数第二个(侥幸通过): [a, c]
ListIterator 修改过程: 下标 0 -> A | 下标 1 -> B | 下标 2 -> C |
修改后: [A, B, C]

第 6 行与第 7 行的对比是本节的核心:

  • 删 "a"(第一个)→ 抛 ConcurrentModificationException;
  • 删 "b"(倒数第二个)→ 静默通过。

两者都是错误写法,但只有前者会提醒你。这就是为什么这个 bug 在生产环境里往往"突然出现"——当数据量或删除位置变化时,它就暴露了。

8. Debug 观察点 ​

  1. 在 broken.remove(item)(删第一个)处断点,F7 进入 ArrayList.remove → 观察 modCount++;继续按 F9,观察 Itr.next() 里的 checkForComodification() 抛异常;
  2. 对"删倒数第二个"的版本做同样操作,观察循环在 hasNext() 返回 false 后正常结束——没有机会调用 next();
  3. 在 iterator.remove() 处 F7 进入,观察它同时更新了 expectedModCount——这是它与"直接调 list.remove"的关键区别;
  4. 展开 ArrayList 的迭代器对象,看 cursor 与 expectedModCount 两个字段;对比直接 remove 后 modCount 与 expectedModCount 不再相等。

9. 常见错误 ​

错误 1:for-each 里直接 remove ​

见 6.3 节。修复:removeIf / 迭代器 / 倒序循环。

错误 2:普通下标循环删除导致漏删 ​

java
List<Integer> list = new ArrayList<>(List.of(1, 2, 2, 3));
for (int i = 0; i < list.size(); i++) {
    if (list.get(i) == 2) {
        list.remove(i);       // 删除后 i 自增,跳过了下一个 2
    }
}
System.out.println(list);      // [1, 2, 3] —— 还有一个 2 没删掉

修复:倒序遍历,或删除后不递增下标(i--)。

错误 3:在遍历 Map 时修改 ​

java
for (Map.Entry<String, Integer> entry : map.entrySet()) {
    if (entry.getValue() == 0) {
        map.remove(entry.getKey());      // ConcurrentModificationException
    }
}

修复:

java
map.entrySet().removeIf(entry -> entry.getValue() == 0);      // 推荐
// 或
map.entrySet().stream().filter(...).toList().forEach(map::remove);   // 先收集再删

错误 4:以为 CopyOnWriteArrayList 能读到最新数据 ​

java
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
for (String item : list) {
    list.add("new");           // 不抛异常,但本次遍历看不到 "new"
}

这不是 bug,是"快照"语义(《JUC 工具类与并发容器》 会展开)。

10. 动手实验 ​

目标:亲手验证 fail-fast 的触发条件。

步骤:

  1. 运行第 7 节代码,核对输出(重点看第 6、7 行);
  2. 把"删第一个"的版本改成删最后一个元素,观察是否抛异常,并解释原因;
  3. 写代码对比 list.removeIf(...) 与"另一个集合收集后 removeAll"两种方式在 10 万条数据上的耗时;
  4. 用 ListIterator 实现"把所有偶数元素替换成 0";
  5. 用 Map + entrySet().removeIf(...) 删除特定条件的条目,验证不抛异常。

验收方式:能解释第 2 步的结果(提示:删最后一个元素后 hasNext() 也返回 false)。

扩展任务:写一个方法 List<String> removeAllSafely(List<String> list, Predicate<String> condition),要求不修改传入的集合而是返回新列表,说明这种"不改参数"的风格为什么更安全(回顾 《方法》 的"值传递"与 《封装》 的"最小副作用")。

11. 练习题 ​

基础题

  1. 判断:ConcurrentModificationException 只在多线程环境下出现。(对 / 错)
  2. 填空:遍历中删除元素的首选写法是 ______;for-each 编译后等价于 ______。
  3. 简答:为什么 iterator.remove() 不会抛异常,而 list.remove() 会?

代码阅读题

java
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
for (Integer value : list) {
    if (value % 2 == 0) {
        list.remove(value);
    }
}
System.out.println(list);
  1. 这段代码会抛异常吗?如果会,是在删除哪个元素时抛的?为什么?

编程题

  1. 实现方法 List<String> filterOut(List<String> source, Predicate<String> condition):
    • 返回"排除满足条件的元素"之后的新列表;
    • 不修改传入的列表;
    • 用迭代器实现一次,再用 removeIf 实现一次,对比代码。

综合任务

  1. 设计一个"购物车清理"逻辑:
    • List<CartItem>(sku、quantity、price);
    • 规则:数量为 0 或价格异常的条目要移除;库存不足的要标记为"待补货"而不是移除;
    • 要求:不使用 removeIf(假设后续要在删除时记录日志),用迭代器实现;
    • 说明:为什么不能在 for-each 里直接 remove?
分级提示

提示 1(方向):第 4 题的删除发生在第二个元素上,后面还有元素,所以会抛。

提示 2(关键点):第 5 题的迭代器版本要"新建一个列表放保留的元素",或者对副本用迭代器删除——不要修改入参。

提示 3(接近答案):第 6 题用迭代器遍历时,可以在 iterator.remove() 前后加日志,因为 remove 是通过迭代器执行的,不会破坏遍历。

12. 参考答案 ​

基础题
  1. 错。它是单线程下"遍历期间修改集合"就会抛的异常。名字里的 "Concurrent" 指的是"遍历与修改同时进行",不是"多线程"。
  2. removeIf、for (Iterator it = c.iterator(); it.hasNext(); )(迭代器循环)。
  3. 因为 iterator.remove() 在删除元素后会同步更新迭代器内部的 expectedModCount,使它与集合的 modCount 保持一致;而直接调 list.remove() 只增加 modCount,迭代器下次 next() 时检查发现不一致,于是抛异常。
代码阅读题(第 4 题)

会抛异常,在删除元素 2 时抛出。

过程:

  1. 取 1(奇数,不删);
  2. 取 2(偶数)→ list.remove(2),此时 modCount 增加;
  3. 循环继续,迭代器调用 next() 取下一个元素 → checkForComodification() 发现不一致 → 抛 ConcurrentModificationException。

如果只删最后一个偶数(比如列表是 [1, 2]),删除后 hasNext() 返回 false,不会再调用 next(),就不会抛异常——这再次说明"报不报错取决于位置"。

编程题(第 5 题)
java
package cn.kb.learn.collection;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.function.Predicate;

public class ListFilter {
    /** 迭代器版本:不修改入参 */
    static List<String> filterOutByIterator(List<String> source, Predicate<String> condition) {
        List<String> result = new ArrayList<>();
        if (source == null) {
            return result;
        }
        Iterator<String> iterator = source.iterator();
        while (iterator.hasNext()) {
            String item = iterator.next();
            if (!condition.test(item)) {
                result.add(item);
            }
        }
        return result;
    }

    /** removeIf 版本:对副本操作,同样不修改入参 */
    static List<String> filterOutByRemoveIf(List<String> source, Predicate<String> condition) {
        List<String> copy = new ArrayList<>(source == null ? List.of() : source);
        copy.removeIf(condition);
        return copy;
    }

    public static void main(String[] args) {
        List<String> source = List.of("apple", "banana", "cherry");
        System.out.println(filterOutByIterator(source, item -> item.startsWith("b")));
        System.out.println(filterOutByRemoveIf(source, item -> item.startsWith("b")));
        System.out.println("原列表未被修改: " + source);
    }
}

代码对比:removeIf 版本只有两行(复制 + 删除),迭代器版本要写五行的循环。但 removeIf 版本多了一次全量复制——数据量极大时,迭代器版本(一次遍历、按需添加)更省内存。

预期输出:

text
[apple, cherry]
[apple, cherry]
原列表未被修改: [apple, banana, cherry]
综合任务(第 6 题)
java
package cn.kb.learn.collection;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class CartCleaner {
    record CartItem(String sku, int quantity, long priceInCents) {
        CartItem withQuantity(int newQuantity) {
            return new CartItem(sku, newQuantity, priceInCents);
        }
    }

    static List<String> clean(List<CartItem> items, java.util.Map<String, Integer> stock, List<String> logs) {
        List<String> pendingRestock = new ArrayList<>();
        Iterator<CartItem> iterator = items.iterator();

        while (iterator.hasNext()) {
            CartItem item = iterator.next();

            if (item.quantity() <= 0 || item.priceInCents() < 0) {
                logs.add("移除无效条目: " + item.sku());
                iterator.remove();                 // 通过迭代器删除,安全
                continue;
            }

            int available = stock.getOrDefault(item.sku(), 0);
            if (available < item.quantity()) {
                pendingRestock.add(item.sku());
                logs.add("库存不足,标记待补货: " + item.sku());
            }
        }
        return pendingRestock;
    }

    public static void main(String[] args) {
        List<CartItem> items = new ArrayList<>(List.of(
                new CartItem("A", 2, 1000),
                new CartItem("B", 0, 500),
                new CartItem("C", 5, 2000)));
        java.util.Map<String, Integer> stock = java.util.Map.of("A", 10, "C", 1);

        List<String> logs = new ArrayList<>();
        List<String> pendingRestock = clean(items, stock, logs);

        System.out.println("剩余条目: " + items);
        System.out.println("待补货: " + pendingRestock);
        logs.forEach(log -> System.out.println("  日志: " + log));
    }
}

为什么不能在 for-each 里直接 remove:for-each 隐藏了迭代器,你拿到的是元素本身,没有删除的合法途径。要么用 removeIf(不需要在删除时做额外动作),要么显式使用迭代器(可以在 remove 前后记录日志、做业务判断)。

这个例子里"删除 + 记录日志"同时发生,所以必须用显式迭代器——removeIf 做不到"删除时执行副作用"。

预期输出:

text
剩余条目: [CartItem[sku=A, quantity=2, priceInCents=1000], CartItem[sku=C, quantity=5, priceInCents=2000]]
待补货: [C]
  日志: 移除无效条目: B
  日志: 库存不足,标记待补货: C

13. 掌握标准 ​

  • [ ] 能说出 for-each 编译后等价于迭代器循环;
  • [ ] 能解释 ConcurrentModificationException 的成因与 fail-fast 机制;
  • [ ] 熟练使用三种正确的删除方式,并知道各自适用场景;
  • [ ] 能解释"删倒数第二个不抛异常"的原因,并知道它是隐患;
  • [ ] 会用 ListIterator 做双向遍历与元素修改。

14. 扩展资源 ​

15. 下一步学习 ​

集合模块完成(S1-015 ~ S1-019)。《泛型基础》:集合为什么能"记住"元素类型,以及 <T> 到底做了什么。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引