在 ArrayList 中调用 remove(int index) 方法删除元素后,底层会发生元素整体前移,而循环遍历时的索引并未感知到这种位移,从而导致漏删或越界的“索引错乱”现象。
源码层面的根本原因
ArrayList.remove(int index) 的核心逻辑如下(基于 JDK 8):
public E remove(int index) {
rangeCheck(index); // 检查索引是否越界
modCount++;
E oldValue = elementData(index); // 获取被删元素
int numMoved = size - index - 1; // 需要移动的元素个数
if (numMoved > 0)
// 将 index+1 开始的所有元素整体向前复制一位,覆盖 index 位置
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null; // 将末尾置为 null,帮助 GC,并减小 size
return oldValue;
}
关键动作:调用 System.arraycopy 将删除位置之后的所有元素整体向左平移一个位置,原来在索引 i+1 的元素现在位于索引 i,size 减 1。
循环删除引发索引错乱的演示
假设有一个 ArrayList 内容为 [A, B, C, D],我们要删除所有值为 "B" 和 "C" 的元素(为了清晰展示漏删现象,这里以“删除多个连续元素”为例)。
错误写法:
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
for (int i = 0; i < list.size(); i++) {
if ("B".equals(list.get(i)) || "C".equals(list.get(i))) {
list.remove(i);
}
}
System.out.println(list); // 期望 [A, D],实际输出 [A, C, D] ❌
执行过程详解:
| 循环轮次 | i 的值 | list 内容 | 操作与后果 |
|---|---|---|---|
| 第 1 次 | i = 0 | [A, B, C, D] | list.get(0) = A,不满足条件,进入下一轮。 |
| 第 2 次 | i = 1 | [A, B, C, D] | list.get(1) = B,满足条件,执行 remove(1)。数组前移:C 移到索引 1,D 移到索引 2。size 变为 3。列表变为 [A, C, D]。 |
| 第 3 次 | i = 2 | [A, C, D] | 关键错误:循环变量 i++ 后变为 2,直接检查索引 2 的元素 D。原本位于索引 1 的 C 被跳过了,没有被检查到。 |
| 第 4 次 | i = 3 | [A, C, D] | i 不小于 size=3,循环结束。最终 C 被漏删。 |
两种典型的“索引错乱”后果
- 漏删元素(如上例):因为元素前移,本该在下一轮检查的新
i位置元素被跳过了。 - 索引越界异常
IndexOutOfBoundsException:如果删除的是靠后的元素,且循环条件使用的是预先缓存的size(例如int n = list.size(); for (int i=0; i<n; i++)),当列表缩小后,i可能仍然会增长到大于list.size(),导致list.get(i)越界。
正确删除姿势与原理对比
| 方式 | 原理 | 代码示例 |
|---|---|---|
| 倒序删除 | 从后向前遍历,删除元素只影响已遍历过的后续索引,不影响未遍历的前部索引。 | for (int i = list.size() - 1; i >= 0; i--) { if (cond) list.remove(i); } |
迭代器 remove | Itr.remove() 删除元素后会同步调整光标 cursor(cursor--),保证 next 调用不会遗漏元素。 | Iterator<String> it = list.iterator(); while(it.hasNext()) { if (cond) it.remove(); } |
removeIf(Java 8+) | 内部使用迭代器,并针对 ArrayList 做了批量删除优化(BitSet + 批量前移),效率最高。 | list.removeIf(s -> "B".equals(s) || "C".equals(s)); |
源码角度的完美解释
在 ArrayList 内部迭代器 Itr 的 remove() 方法中,有一行关键代码:
public void remove() {
// ...
ArrayList.this.remove(lastRet);
cursor = lastRet; // 将下一次 next() 的起始索引回退到被删除位置
lastRet = -1;
// ...
}
正是因为 cursor 的回退,迭代器才能正确应对“元素整体前移”的情况,从而避免漏删。而普通 for 循环中的 i++ 是无感知的机械递增,必然出错。
总结
ArrayList.remove 导致索引错乱的本质是:底层数组的结构性变化(元素前移)与上层遍历索引的线性递增逻辑发生了脱节。理解这一点不仅有助于写出正确的删除代码,也能加深对 modCount 和 fail-fast 机制设计必要性的理解。