跳至主要內容

程序员小富大约 4 分钟

大家好,我是小富。

《十万个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。 现在你知道为什么了。


我是小富,下期见。

上次编辑于: