集合-List-Arraylist-remove

6 阅读3分钟

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 的元素现在位于索引 isize 减 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 被漏删。

两种典型的“索引错乱”后果

  1. 漏删元素(如上例):因为元素前移,本该在下一轮检查的新 i 位置元素被跳过了。
  2. 索引越界异常 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); }
迭代器 removeItr.remove() 删除元素后会同步调整光标 cursorcursor--),保证 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 内部迭代器 Itrremove() 方法中,有一行关键代码:

public void remove() {
    // ...
    ArrayList.this.remove(lastRet);
    cursor = lastRet;    // 将下一次 next() 的起始索引回退到被删除位置
    lastRet = -1;
    // ...
}

正是因为 cursor 的回退,迭代器才能正确应对“元素整体前移”的情况,从而避免漏删。而普通 for 循环中的 i++无感知的机械递增,必然出错。

总结

ArrayList.remove 导致索引错乱的本质是:底层数组的结构性变化(元素前移)与上层遍历索引的线性递增逻辑发生了脱节。理解这一点不仅有助于写出正确的删除代码,也能加深对 modCount 和 fail-fast 机制设计必要性的理解。