引言

在 Java 后端面试中,有一道题堪称基础题里的“深水炸弹”:

面试官:平时开发中,你们在 foreach 循环里执行过 remove 操作吗?《阿里巴巴 Java 开发手册》为什么强制禁止这种行为?

据不完全统计,90% 的开发者只知道会抛ConcurrentModificationException。但如果你只答到这一层,面试官心里大概率会给你贴上“基础不牢”的标签。

今天,咱们就把这背后的底层逻辑、诡异的“不报错”现象、以及工业级的解决方案彻底拆透。

一、 权威红线:阿里手册的“强制”级禁令

在《阿里巴巴 Java 开发手册》的「集合处理」章节中,第 14 条明确规定:

【强制】 不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator 方式,如果并发操作,需要对 Iterator 对象加锁。

Fox 划重点: 注意,这在阿里内部是 P3C 代码扫描插件会直接阻断的严重问题。这不是建议,而是不可触碰的红线。

二、 诡异现场:为什么有些场景“不报错”?

很多兄弟不服气:我本地试过,有时候能正常跑啊!这正是这道题最“阴”的地方。

1. 必死现场:删除第一个元素

List<String> list = new ArrayList<>(Arrays.asList("Java", "MySQL", "Redis"));
for (String item : list) {
    if ("Java".equals(item)) {
        list.remove(item);
    }
}

结果: 意料之中,秒切 ConcurrentModificationException (CME) 异常。

2. 灵异现场:删除倒数第二个元素

我们将目标换成倒数第二个元素:

List<String> list = new ArrayList<>(Arrays.asList("Java", "MySQL", "Spring", "Redis"));
for (String item : list) {
    if ("Spring".equals(item)) {
        list.remove(item); // 删的是 Spring
    }
}

结果:居然不报错! 但别高兴太早,你会发现:最后一个元素 Redis 被莫名其妙地跳过了! 这种“悄无声息”的数据遗漏,在对账、清算等业务场景下,就是一场灾难。

三、 深度拆解:从语法糖到 fail-fast 机制

1. 揭开 foreach 的“马甲”

你写的 foreach,在编译后其实长这样(解语法糖):

Iterator iterator = list.iterator();
while (iterator.hasNext()) {
    String item = (String)iterator.next();
    if (condition) {
        list.remove(item); // 用的集合的 remove,而非迭代器的!
    }
}

矛盾点: 遍历归 Iterator 管,删除归 List 管。

2. modCount 与 expectedModCount 的“内斗”

ArrayList 内部维护了一个 modCount(修改次数)。每次 add/remove 都会 +1。

  • Iterator 创建时,会记录 expectedModCount = modCount。
  • 每次调用 next() 时,都会执行 checkForComodification()。
  • 一旦 modCount != expectedModCount,直接抛出 CME 异常。

源码验证:

验证 1:每次 add/remove 都会让 modCount +1

modCount 这个变量是从 AbstractList 继承过来的。我们随便找一个 ArrayList 的 add 或 remove 方法来看看。

源码出处:ArrayList.java 的 remove(int index) 方法

public E remove(int index) {
    rangeCheck(index);


    // 关键点在这里!每次发生结构性修改,modCount 都会自增
    modCount++; 


    E oldValue = elementData(index);


    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index, numMoved);
    elementData[--size] = null; // clear to let GC do its work


    return oldValue;
}

源码分析:只要你调用了 List 自身的 add、remove 或者 clear 等会改变集合结构的(大小变化)方法,modCount++ 就一定会执行。

验证2:每次 next() 都会执行 checkForComodification(),不等就抛异常

在 foreach 循环中,底层获取下一个元素调用的就是迭代器的 next() 方法。

源码出处:内部类 Itr 的 next() 方法

@SuppressWarnings("unchecked")
public E next() {
    // 关键点:进入 next() 的第一件事,就是雷打不动的校验!
    checkForComodification(); 


    int i = cursor;
    if (i >= size)
        throw new NoSuchElementException();
    Object[] elementData = ArrayList.this.elementData;
    if (i >= elementData.length)
        throw new ConcurrentModificationException();
    cursor = i + 1;
    return (E) elementData[lastRet = i];
}
final void checkForComodification() {
    // 关键点:如果当前集合的实际修改次数,和迭代器预期的修改次数不一致,秒抛异常!
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

源码分析:只要你在迭代的过程中,通过 list.remove() 修改了集合,导致集合的 modCount 变成了比如 5,而迭代器里的 expectedModCount 还是 4,下一次循环走到 next() 时,这个 if 条件必定成立,直接送你一个 CME 异常。

3. 为什么倒数第二个不报错?

这是一个数学巧合:

  • 当你删掉倒数第二个元素时,list.size() 减小了 1。
  • 此时 Iterator 的游标 cursor 恰好等于了当前的 size。
  • Iterator 内部执行 hasNext() 进行校验,其底层条件是 cursor != size。此时刚好相等,条件不成立,hasNext() 返回 false,循环提前结束。
  • 因为没机会执行下一次 next(),所以没触发校验逻辑。 异常没报,但数据漏了。

源码验证

咱们来看一眼 JDK 8 中 ArrayList 的内部类 Itr(也就是 Iterator 的实现)的源码:

private class Itr implements Iterator<E> {
    int cursor;       // index of next element to return
    int lastRet = -1; // index of last element returned; -1 if no such
    int expectedModCount = modCount;


    // Itr 初始化时,cursor 默认为 0


    public boolean hasNext() {
        // 这就是底层的判断条件!
        return cursor != size;
    }


    // ... 其他方法
}

源码分析:hasNext() 方法的逻辑非常简单,就是判断游标 cursor(下一次要遍历的元素索引)是否不等于集合当前的 size。

  • 如果不等于,说明后面还有元素,返回 true。
  • 如果相等,说明已经遍历到了末尾,返回 false,循环结束。

四、 架构师的选择:工业级正确姿势

既然 foreach 有坑,生产环境下该怎么写?Fox 给你总结了三套方案:

方案 1:官方正解(Iterator)

Iterator<String> it = list.iterator();
while (it.hasNext()) {
    // 这里替换成你的业务判断逻辑,比如删除包含 "Spring" 的元素
    if ("Spring".equals(item)) {
        it.remove(); // 核心:调用的是 iterator 的 remove,它会自动同步 modCount
    }
}

方案 2:优雅首选(removeIf)

JDK 8+ 之后,这是最推荐的写法。一行代码,底层自动帮你处理了所有的迭代器细节。

list.removeIf(item -> "Spring".equals(item));

方案 3:函数式防御(Stream Filter)

如果你不希望修改原有的 list(满足无状态设计),用流式过滤生成新集合。

List<String> newList = list.stream()
    .filter(item -> !"Spring".equals(item))
    .collect(Collectors.toList());

五、 面试满分总结

如果面试官问到这,你可以分三步拿走 Offer:

  1. 谈原理:说明 foreach 是语法糖,解释 fail-fast 机制中 modCount 的校验逻辑。
  2. 谈风险:重点强调“倒数第二个元素”的异常规避带来的数据遗漏风险,这体现了你的线上排查经验。
  3. 谈工程化:提到《阿里手册》的规约是为了代码的可预测性和团队协作的稳定性,并给出 removeIf 或 Stream 的最佳实践。

架构师语录: > 技术选型没有绝对的对错,但优秀的工程师永远会选择那个“预期最明确、隐患最少”的方案。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐