在学习 Java 的集合时,你可能听老师说过:“对集合的元素进行增加或移除时,使用集合的迭代器而不能使用使用 foreach。”那么你知道是为什么吗?你可能会说:“使用 foreach 会报错!” 那又是什么导致出现报错的呢?这篇文章就来研究一下这个人人都知道,但又说不清楚的问题。
1. 神奇的倒数第二个元素
首先我们来对比一下这两种方法:
// 目标:在 List 中找出值为"A"的元素并将它删除。 |
将以上的代码编译运行后,发现两种方法都正常运行并且操作成功。说好的 foreach 会报错呢?之后我反复的测试得出了一个奇怪的结论:在 foreach 中对 List 的倒数第二个元素进行删除时并不会报错,其它位置的元素均会报错。
2. 错误从哪来?
首先我们要知道 foreach 的底层其实也是通过迭代器实现的。通过 javap 我们可以看到 foreach 的字节码指令,有兴趣的可以参考:java foreach内部实现。
所以,每一次 foreach 迭代的时候都会有两步操作:
iterator.hasNext(); // 判断是否有下一个元素 |
要知道为什么删除倒数第二个元素不会报错,我们就应该先知道错误从哪里来:
// 报错信息 |
从报错信息很明显看出,错误是从 next()
方法来的,而 next()
方法是 List 的一个内部类的方法:
private class Itr implements Iterator<E> { |
从源码中可以看出错误最后是从 checkForComodification()
方法抛出的。这里有两个关键的变量 modCount修改次数
和 expectedModCount预计修改次数
,当两个值不相等时会抛出错误。并且从调试知道,错误是在删除操作之后出现的,也就是说最有可能的是 remove()
方法导致这两个变量不相等。
3. 这两个变量为什么不相等?
我先来说明一下这两个变量的意义是什么:
modCount
是指这个 list 对象从 new 出来到现在被修改的次数,当调用 List 的add()
或者remove()
方法的时候,这个modCount
都会自动增加。expectedModCount
是指 Iterator 现在期望这个 list 被修改的次数是多少。
但是根据上面的 remove()
方法的源码,有一条 expectedModCount=modCount;
的操作,怎么会不相等呢?没错, expectedModCount=modCount;
的作用就是令这两个变量保持一致的。但是,这是迭代器里面的 remove()
方法而不是 list.remove()
。
// 这才是 foreach 调用的 remove 方法 |
从这里我们就知道了。为什么迭代器不会报错,而 foreach 会报错?因为在迭代器中在对元素进行 add()或remove()
时会同步 expectedModCount和modCount
变量,而 foreach 不会。导致在进行 checkForComodification()
校验时抛出异常。
4. 触发报错的全过程
现在我们来梳理一下触发报错的全过程:
- 因为 foreach 底层是迭代器,所以首先执行
iterator.hasNext()
,判断是否有下一个元素。 - 存在下一个元素执行
iterator.next()
。 - 在
iterator.next()
会调用checkForComodification()
来检查 list 的修改次数。 - 检查正常
iterator.next()
正常往下执行,这时发现元素的值为”B”,执行List.remove()
将元素删除。 - 本次循环结束,开始下一次循环。
- 执行
iterator.hasNext()
发现还有下一个元素。 - 执行
iterator.next()
中的checkForComodification()
方法,发现修改次数不一致,抛出异常。
5. 重新讨论倒数第二个元素
我们发现错误是在删除元素的下一次循环出现的,而且是在 iterator.next()
方法中出现的。
可以大胆的猜测,在对倒数第二个元素进行删除后的下一次循环中 iterator.hasNext()
方法返回的是 falsh
,导致 iterator.next()
不执行,所以不抛出异常。在验证这个想法时,我先看了一下 iterator.hasNext()
方法的源码:
// iterator.hasNext 源码 |
代码十分简单,主要时对比了一下 cursor和size
变量是否一致。
cursor
是指下一个元素的索引值。size
是指 list 的元素个数。注意,元素个数 - 1 = 最后一个元素索引。
这时我们假设,现在倒数第二个元素已经被删除而且已经进入下一次的循环:
- 因为倒数第二个元素的下一个元素就是最后一个元素,所以
cursor=size-1
。 - 而 list 原来的元素个数是
size
,但是倒数第二个元素被删除,导致size=size-1
。 - 执行
iterator.hasNext()
根据上述的计算得出cursor=size
,导致迭代器认为不存在下一个元素,迭代结束。
所以我们的猜测是说得通的,并且代码的运行结果确实是这样。到这里,我总算知道了为什么不能使用 foreach 对元素进行增删的原因是什么了。也搞明白删除倒数第二个元素导致的奇怪现象了。在这里,我只是讨论 ArrayList 这一个集合,但是我希望这篇文章能让大家达到触类旁通的效果。