大家好,我是小富。
《十万个why》系列持续更新中
先看两段代码,猜猜哪个会报错:
// 代码 A:删除 "b"
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));
for (String item : list) {
if ("b".equals(item)) {
list.remove(item);
}
}
// 代码 B:删除 "c"
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));
for (String item : list) {
if ("c".equals(item)) {
list.remove(item);
}
}
如果你觉得两个都会抛 ConcurrentModificationException,那只对了一半。
实际结果:代码 A 不报错,代码 B 报错。
同样的列表、同样的 foreach、同样的 remove,删 "b" 没事,删 "c" 炸了。这大概是 Java 里最诡异的行为之一了。
foreach 的真面目
foreach 循环是个语法糖,编译器会把它翻译成 Iterator 迭代:
// 编译前
for (String item : list) {
// ...
}
// 编译后
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String item = it.next();
// ...
}
关键在于 hasNext() 和 next() 这两个方法的实现。
ArrayList Iterator 的内部状态
private class Itr implements Iterator<E> {
int cursor; // 下一个要返回的元素索引
int lastRet = -1; // 上一次返回的元素索引
int expectedModCount = modCount; // 创建迭代器时记录的修改次数
public boolean hasNext() {
return cursor != size; // 只要游标没到末尾,就还有下一个
}
public E next() {
checkForComodification(); // 检查是否被修改
// ...
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
每次调用 list.remove() 时,modCount 会 +1,但迭代器的 expectedModCount 不会同步更新。当 next() 方法发现两者不一致时,就抛异常。
为什么删 "b" 不报错
"b" 是列表的倒数第二个元素(索引 1),来看执行过程:
初始:list = ["a", "b", "c"],size = 3
第 1 轮:
hasNext():cursor(0) != size(3) → true
next():checkMod ✓,返回 "a",cursor = 1
"a" != "b",不删除
第 2 轮:
hasNext():cursor(1) != size(3) → true
next():checkMod ✓,返回 "b",cursor = 2
"b" == "b",执行 list.remove("b")
→ list 变成 ["a", "c"],size = 2,modCount++
第 3 轮:
hasNext():cursor(2) != size(2) → false ← 提前结束!
循环退出,没进 next(),没触发 checkForComodification
删掉 "b" 之后 size 从 3 变成了 2,而此时 cursor 恰好也是 2。hasNext() 判断 cursor != size 为 false,循环直接结束了。根本没有执行到 next() 里的检查代码。
结果:没报错,但 "c" 被跳过了,从来没有被遍历到。
为什么删 "c" 报错
"c" 是列表的最后一个元素(索引 2):
初始:list = ["a", "b", "c"],size = 3
第 1 轮:
hasNext():cursor(0) != size(3) → true
next():返回 "a",cursor = 1
第 2 轮:
hasNext():cursor(1) != size(3) → true
next():返回 "b",cursor = 2
第 3 轮:
hasNext():cursor(2) != size(3) → true
next():返回 "c",cursor = 3
"c" == "c",执行 list.remove("c")
→ list 变成 ["a", "b"],size = 2,modCount++
第 4 轮:
hasNext():cursor(3) != size(2) → true ← 还会继续!
next():checkForComodification() → modCount != expectedModCount → 💥 报错
删掉 "c" 后 size 变成 2,但 cursor 已经是 3 了。hasNext() 判断 3 != 2 为 true,进入 next(),这时才触发了修改检查,抛出异常。
不报错才是最危险的
很多人觉得报错是坏事,但在这个场景下,不报错才是真正的灾难。
报错了,你知道代码有问题,去修就行。不报错呢?程序"正常"运行,但默默跳过了最后一个元素。
想象一个业务场景:
// 清理过期订单
List<Order> orders = getOrders();
for (Order order : orders) {
if (order.isExpired()) {
orders.remove(order);
notifyUser(order); // 通知用户订单已取消
}
}
如果恰好有一个过期订单排在倒数第二个位置,删除后最后一个订单被跳过了。如果最后一个也是过期的,它就逃过了清理。这种 bug 在测试环境可能永远复现不了(取决于数据顺序),只在线上某些特定数据排列下才出现。
正确的删除方式
方式一:Iterator.remove()(最正统)
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String item = it.next();
if ("b".equals(item)) {
it.remove(); // 迭代器自己的 remove,会同步 expectedModCount
}
}
Iterator.remove() 内部会执行 expectedModCount = modCount,保持两者同步。
方式二:removeIf()(Java 8+,最简洁)
list.removeIf(item -> "b".equals(item));
内部实现也是用迭代器安全删除,一行搞定。
方式三:倒序 for 循环
for (int i = list.size() - 1; i >= 0; i--) {
if ("b".equals(list.get(i))) {
list.remove(i);
}
}
倒序删除不会影响未遍历元素的索引。正序 for 循环删除会因为索引移位而跳过元素。
方式四:新建列表收集结果
List<String> result = list.stream()
.filter(item -> !"b".equals(item))
.collect(Collectors.toList());
不修改原列表,直接生成新列表。最函数式的写法。
总结
| 现象 | 原因 |
|---|---|
| 删倒数第二个不报错 | 删除后 cursor == size,hasNext() 返回 false,循环提前结束 |
| 删最后一个报错 | 删除后 cursor > size,hasNext() 返回 true,next() 触发修改检查 |
| 删其他位置元素报错 | 同上,下一次 next() 就会检测到 modCount 不一致 |
| 不报错但跳过元素 | 最后一个元素从未被遍历到,静默丢失 |
这个问题的本质:hasNext() 只比较 cursor 和 size,不检查 modCount。只有 next() 才检查。当 remove 恰好让 cursor == size 时,循环在执行到 next() 之前就结束了,绕过了安全检查。
阿里巴巴 Java 开发手册里写得很明确:不要在 foreach 循环里进行元素的 remove/add 操作,使用 Iterator 或者 removeIf。 现在你知道为什么了。
我是小富,下期见。
