「Java 进阶之路」系列 Day25
写在前面
"遍历集合的时候不能直接调用集合的remove方法",这条经验大家都知道,但问一句"为什么会报错、报的又是什么异常",很多人只记得一个笼统的印象。这篇把Iterator和fail-fast机制背后的modCount计数器讲清楚,顺便说说这套机制到底能不能完全依赖。
一、是什么:fail-fast 是一种"尽早报错"的检测机制
ArrayList、HashMap这些集合内部都维护着一个modCount字段,记录这个集合被"结构性修改"(增加或删除元素,单纯改值不算)过多少次。Iterator在创建的那一刻,会把当时的modCount记下来,存成自己的expectedModCount;之后每调用一次next(),都会检查一下"当前的modCount是不是还等于我当初记下来的那个值",不相等就说明集合在遍历过程中被别的地方动过手脚,直接抛出ConcurrentModificationException------这就是fail-fast(快速失败)的含义:与其让程序继续在一个被意外修改过的集合上跑下去、产生更难排查的诡异结果,不如第一时间报错,让问题尽早暴露。
二、为什么会报错:一次典型的踩坑复现
java
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
for (Integer i : list) {
if (i == 3) {
list.remove(i); // 直接调用集合自身的remove方法
}
}
// 抛出ConcurrentModificationException
问题就出在list.remove(i)这一步 :for-each语法糖底层就是用Iterator实现的,但list.remove(i)调用的是ArrayList自己的remove方法,不是Iterator的remove方法。ArrayList的remove执行完之后,modCount自增了,但Iterator手里那份expectedModCount完全不知情、还停留在旧值------下一次Iterator调用next()推进遍历时,一检查发现两者对不上,立刻抛出异常。
正确的做法是用Iterator自己的remove()方法:
java
Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
Integer i = it.next();
if (i == 3) {
it.remove(); // 用Iterator自己的remove方法
}
}
Iterator.remove()内部在删除元素的同时,会同步把expectedModCount也更新成最新的modCount ,两者始终保持一致,自然不会触发检测。这也是为什么"遍历删除要用Iterator的remove"这条经验背后真正的原理------不是Iterator有什么特殊权限,而是它顺手帮你把这两个计数器的值重新对齐了。
三、怎么用:两个容易被混淆的相关陷阱
陷阱一:用普通下标 for 循环删除元素,不报错但结果不对
java
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
for (int i = 0; i < list.size(); i++) {
if (list.get(i) == 3) {
list.remove(i); // 不会抛异常,但结果可能不符合预期
}
}
这种写法不会 抛ConcurrentModificationException(因为压根没用Iterator),但如果删除的元素后面还有连续需要删除的元素,会因为"删除后所有元素前移、下标却继续往后走"而漏检查一个元素------这和fail-fast是两个完全不同的问题,只是经常被放在一起讨论,写代码时要分清楚:用Iterator遍历删除,报的是fail-fast异常;用下标遍历删除,出的是逻辑漏删的bug,两者的成因完全不一样。
陷阱二:fail-fast 只是"尽力检测",不是100%保证
JDK官方文档里对这套机制有一句关键说明:modCount的检查本身不是同步的 ,多线程场景下即使检测机制生效了,也不能保证一定能捕获到并发修改;反过来,某些边界情况下即使真的发生了并发修改,也可能因为时机凑巧而没有触发检测、蒙混过关。fail-fast只是一种尽最大努力去暴露问题的调试辅助手段,不能把它当成一种可以依赖的并发控制机制去设计代码 ------真正需要多线程安全遍历的场景,应该用Day08讲过的CopyOnWriteArrayList(遍历用的是创建时的数组快照,天生不会抛这个异常,属于"fail-safe"设计)或者ConcurrentHashMap这类专门为并发设计的容器。
四、面试追问
Q1:什么是 fail-fast 机制,它是怎么实现的?
fail-fast是集合类在遍历过程中检测到被意外结构性修改时,第一时间抛出ConcurrentModificationException的一种保护机制。实现原理是集合内部维护一个modCount字段记录被修改的次数,Iterator创建时记下当时的modCount作为expectedModCount,之后每次调用next()都会比较两者是否相等,不相等就说明遍历期间集合结构被改动过,直接抛出异常。
Q2:为什么在 for-each 循环里直接调用集合的 remove 方法会抛异常,用 Iterator 的 remove 方法就不会?
for-each底层是用Iterator实现遍历的,但直接调用集合自身的remove方法,只会让集合内部的modCount自增,Iterator手里记录的expectedModCount并不知情,两者变得不一致,下次调用next()检测到不一致就会抛异常。Iterator自己的remove()方法在删除元素的同时会同步更新expectedModCount,让两者始终保持一致,所以不会触发这个检测。
Q3:用普通下标 for 循环遍历删除元素,会抛 ConcurrentModificationException 吗?
不会,因为这种写法完全没有用到Iterator,fail-fast检测机制根本没有介入。但这种写法有另一个问题:删除元素后集合会整体前移,而下标却继续往后走,如果有连续多个需要删除的元素,会因为下标错位漏掉其中一部分该删除的元素,这是一个逻辑正确性问题,和fail-fast异常是两回事。
Q4:fail-fast 机制能不能保证百分之百检测到并发修改?
不能。JDK官方文档明确说明modCount的检查本身不是同步的,多线程环境下既可能因为时机原因没有检测到真实发生的并发修改,也不能保证在所有场景下都表现一致。fail-fast只是一种尽力而为的调试辅助手段,不能当作可靠的并发控制机制来设计代码逻辑。
Q5:如果需要在多线程环境下安全地遍历一个集合,应该怎么做?
不应该依赖普通集合的fail-fast机制,而应该使用专门为并发场景设计的容器,比如CopyOnWriteArrayList------它的迭代器遍历的是创建时刻的数组快照,遍历期间即使有其他线程在修改原集合也不会抛异常(fail-safe设计);或者使用ConcurrentHashMap这类内部有精细同步机制的并发容器,具体选择取决于业务场景的读写比例和一致性要求。
下一篇预告
Day26 讲泛型的本质------类型擦除到底擦掉了什么,为什么List<String>和List<Integer>在运行时其实是同一个Class对象,这是模块三集合框架的收官篇。