在学习 Java 的集合时,你可能听老师说过:“对集合的元素进行增加或移除时,使用集合的迭代器而不能使用使用 foreach。”那么你知道是为什么吗?你可能会说:“使用 foreach 会报错!” 那又是什么导致出现报错的呢?这篇文章就来研究一下这个人人都知道,但又说不清楚的问题。


1. 神奇的倒数第二个元素

首先我们来对比一下这两种方法:

// 目标:在 List 中找出值为"A"的元素并将它删除。
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");

// 使用迭代器
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String item = iterator.next();
if ("A".equals(item)) {
iterator.remove();
}
}

// 使用 foreach
for (String item : list) {
if ("A".equals(item)) {
list.remove(item);
}
}

将以上的代码编译运行后,发现两种方法都正常运行并且操作成功。说好的 foreach 会报错呢?之后我反复的测试得出了一个奇怪的结论:在 foreach 中对 List 的倒数第二个元素进行删除时并不会报错,其它位置的元素均会报错。

2. 错误从哪来?

首先我们要知道 foreach 的底层其实也是通过迭代器实现的。通过 javap 我们可以看到 foreach 的字节码指令,有兴趣的可以参考:java foreach内部实现

所以,每一次 foreach 迭代的时候都会有两步操作:

iterator.hasNext();		// 判断是否有下一个元素
item = iterator.next(); // 下一个元素是什么,并赋值给 item 变量

要知道为什么删除倒数第二个元素不会报错,我们就应该先知道错误从哪里来:

// 报错信息
java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
at java.util.ArrayList$Itr.next(ArrayList.java:859)

从报错信息很明显看出,错误是从 next()方法来的,而 next()方法是 List 的一个内部类的方法:

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;

public boolean hasNext() {
return cursor != size;
}

@SuppressWarnings("unchecked")
public E 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];
}

public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();

try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}

final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}

从源码中可以看出错误最后是从 checkForComodification()方法抛出的。这里有两个关键的变量 modCount修改次数expectedModCount预计修改次数,当两个值不相等时会抛出错误。并且从调试知道,错误是在删除操作之后出现的,也就是说最有可能的是 remove()方法导致这两个变量不相等。

3. 这两个变量为什么不相等?

我先来说明一下这两个变量的意义是什么:

  1. modCount是指这个 list 对象从 new 出来到现在被修改的次数,当调用 List 的 add()或者 remove()方法的时候,这个 modCount都会自动增加。
  2. expectedModCount是指 Iterator 现在期望这个 list 被修改的次数是多少。

但是根据上面的 remove()方法的源码,有一条 expectedModCount=modCount;的操作,怎么会不相等呢?没错, expectedModCount=modCount;的作用就是令这两个变量保持一致的。但是,这是迭代器里面的 remove()方法而不是 list.remove()

// 这才是 foreach 调用的 remove 方法
// List 中的 remove 方法源码
public boolean remove(Object o) {
if (o == null) {
for (int index = 0; index < size; index++)
if (elementData[index] == null) {
fastRemove(index);
return true;
}
} else {
for (int index = 0; index < size; index++)
if (o.equals(elementData[index])) {
fastRemove(index);
return true;
}
}
return false;
}

从这里我们就知道了。为什么迭代器不会报错,而 foreach 会报错?因为在迭代器中在对元素进行 add()或remove()时会同步 expectedModCount和modCount变量,而 foreach 不会。导致在进行 checkForComodification()校验时抛出异常。

4. 触发报错的全过程

现在我们来梳理一下触发报错的全过程:

  1. 因为 foreach 底层是迭代器,所以首先执行 iterator.hasNext(),判断是否有下一个元素。
  2. 存在下一个元素执行 iterator.next()
  3. iterator.next()会调用 checkForComodification()来检查 list 的修改次数。
  4. 检查正常 iterator.next()正常往下执行,这时发现元素的值为”B”,执行 List.remove()将元素删除。
  5. 本次循环结束,开始下一次循环。
  6. 执行 iterator.hasNext()发现还有下一个元素。
  7. 执行 iterator.next()中的 checkForComodification()方法,发现修改次数不一致,抛出异常

5. 重新讨论倒数第二个元素

我们发现错误是在删除元素的下一次循环出现的,而且是在 iterator.next()方法中出现的。

可以大胆的猜测,在对倒数第二个元素进行删除后的下一次循环中 iterator.hasNext()方法返回的是 falsh,导致 iterator.next()不执行,所以不抛出异常。在验证这个想法时,我先看了一下 iterator.hasNext()方法的源码:

// iterator.hasNext 源码
public boolean hasNext() {
return cursor != size;
}

代码十分简单,主要时对比了一下 cursor和size变量是否一致。

  1. cursor是指下一个元素的索引值。
  2. size是指 list 的元素个数。注意,元素个数 - 1 = 最后一个元素索引

这时我们假设,现在倒数第二个元素已经被删除而且已经进入下一次的循环:

  1. 因为倒数第二个元素的下一个元素就是最后一个元素,所以 cursor=size-1
  2. 而 list 原来的元素个数是 size,但是倒数第二个元素被删除,导致 size=size-1
  3. 执行 iterator.hasNext()根据上述的计算得出 cursor=size,导致迭代器认为不存在下一个元素,迭代结束

所以我们的猜测是说得通的,并且代码的运行结果确实是这样。到这里,我总算知道了为什么不能使用 foreach 对元素进行增删的原因是什么了。也搞明白删除倒数第二个元素导致的奇怪现象了。在这里,我只是讨论 ArrayList 这一个集合,但是我希望这篇文章能让大家达到触类旁通的效果。

评论