引言
在 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());五、 Fox 的面试满分总结
如果面试官问到这,你可以分三步拿走 Offer:
- 谈原理:说明 foreach 是语法糖,解释 fail-fast 机制中 modCount 的校验逻辑。
- 谈风险:重点强调“倒数第二个元素”的异常规避带来的数据遗漏风险,这体现了你的线上排查经验。
- 谈工程化:提到《阿里手册》的规约是为了代码的可预测性和团队协作的稳定性,并给出 removeIf 或 Stream 的最佳实践。
架构师语录: > 技术选型没有绝对的对错,但优秀的工程师永远会选择那个“预期最明确、隐患最少”的方案。