一、开场:购物车装满以后,是换车还是自动加长

周末晚上,超市正在促销。小李把牛奶、面包、水果和纸巾不断放进购物车,很快就没有空位了。

普通购物车装不下时,我们只能换一辆更大的车,再把原来的商品搬过去。如果这辆购物车叫 ArrayList,它会自动完成这件事:

  1. 发现内部数组已经没有空位;
  2. 申请容量更大的新数组;
  3. 把旧数组里的元素复制过去;
  4. elementData 指向新数组;
  5. 放入新元素,旧数组等待垃圾回收。

Java ArrayList 自动扩容超市购物车主题封面

这就是 ArrayList 的核心:外表是一张可以不断添加元素的 List,内部仍然是一块长度固定的数组;所谓自动扩容,其实是创建新数组并搬家。

超市场景 ArrayList 概念
购物车 ArrayList 对象
固定格子 内部数组 elementData
已放入的商品数 size
一共拥有的格子数 capacity
放到车尾 add(E e)
换更大的车 grow(minCapacity)
搬运旧商品 Arrays.copyOf / System.arraycopy
购物清单版本 modCount

超市购物车与 ArrayList 概念映射图

本文以 Java SE 25 API 和 OpenJDK JDK 25 源码为背景。JavaDoc 承诺 ArrayList 会自动增长,并说明尾部追加具有摊销常数时间;具体扩容比例属于实现细节。

二、数组、ArrayList 与 LinkedList 怎么选

数组创建后长度不能改变,ArrayList 则帮我们管理扩容:

String[] fixed = new String[3];
fixed[0] = "牛奶";
fixed[1] = "面包";
fixed[2] = "苹果";
// fixed[3] = "纸巾"; // ArrayIndexOutOfBoundsException

List<String> cart = new ArrayList<>();
cart.add("牛奶");
cart.add("面包");
cart.add("苹果");
cart.add("纸巾");
System.out.println(cart);

数组结构简单、内存紧凑、按下标访问快,但要提前确定长度。ArrayList 仍用数组保存元素,只是把申请新数组、复制元素和维护有效数量封装了起来。

LinkedList 通过节点引用连接。它修改链接很方便,但按下标寻找节点通常需要遍历:

操作 ArrayList LinkedList
按下标读取 O(1) O(n)
尾部添加 摊销 O(1) O(1)
中间插入 定位快,搬运约 O(n) 定位约 O(n),改链接 O(1)
内存局部性 较好 节点分散、额外保存引用

所以“LinkedList 插入删除永远更快”并不准确。大多数业务要先找到位置,定位成本不能忽略。

三、add、get、set、remove 基础操作

List<String> cart = new ArrayList<>();

cart.add("牛奶");                  // 尾部添加
cart.add("面包");
cart.add(1, "苹果");               // 在下标 1 插入

String first = cart.get(0);        // 牛奶
String old = cart.set(2, "纸巾");  // 返回面包
String removed = cart.remove(1);   // 删除苹果

System.out.println(first);
System.out.println(old);
System.out.println(removed);
System.out.println(cart);          // [牛奶, 纸巾]

下标从 0 开始。假设 size 为 3:

  • getsetremove 的合法下标是 0~2
  • add(index, value) 的合法下标是 0~3,因为允许插到末尾;
  • 越界会抛出 IndexOutOfBoundsException

size() 返回有效元素数量,不是内部数组长度。购物车有 10 个格子,但只放了 3 件商品时,size 是 3,capacity 是 10。

3.1 JDK 25 中的首尾操作

ArrayList 实现的 List 继承了 SequencedCollection,JDK 25 可以直接使用 addFirstaddLastgetFirstgetLastreversed。这些 API 让首尾语义更清楚,但 addFirst 仍要把已有元素整体向后移动,底层成本不会消失。

四、elementData、size 与 capacity

ArrayList 最重要的字段可以压缩成:

public class ArrayList<E> {
    transient Object[] elementData; // 真正保存元素
    private int size;               // 有效元素个数
}

放入牛奶、面包和苹果后,内部结构大致如下:

下标          0       1       2       3       4
elementData  牛奶     面包     苹果    null    null

size = 3
capacity = elementData.length = 5

ArrayList 的 elementData、size 与 capacity 结构图

需要注意:

  1. size 只统计有效区间 [0, size)
  2. capacity 是数组长度,公开 API 没有直接读取方法;
  3. 后面的 null 是预留槽位,不属于 List;
  4. ArrayList 允许主动添加 null,不能靠 null 判断 size;
  5. 删除后源码会清空不再使用的尾部槽位,避免继续引用旧对象。

五、为什么 new ArrayList<>() 没有立刻创建长度 10 的数组

OpenJDK 25 使用两个长度都是 0、但身份不同的空数组:

private static final Object[] EMPTY_ELEMENTDATA = {};
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        elementData = EMPTY_ELEMENTDATA;
    } else {
        throw new IllegalArgumentException();
    }
}

new ArrayList<>() 刚执行完时,内部数组长度仍然为 0,第一次添加才创建默认长度为 10 的数组。这样,如果程序创建了很多 ArrayList,但其中一部分从未放入元素,就不用提前为每个空 List 分配 10 个槽位。

JavaDoc 把无参构造器描述为构造一个初始容量为 10 的空列表,这是对外可理解的容量语义;当前 OpenJDK 实现采用延迟分配。阅读 API 和源码时,要区分“公开行为”和“内部时机”。

六、add 与 grow:商品怎样放进购物车

尾部添加和扩容的主干可以精简为:

public boolean add(E element) {
    modCount++;
    if (size == elementData.length) {
        grow(size + 1);
    }
    elementData[size++] = element;
    return true;
}

private Object[] grow(int minCapacity) {
    int oldCapacity = elementData.length;

    if (oldCapacity > 0
            || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        int newCapacity = ArraysSupport.newLength(
                oldCapacity,
                minCapacity - oldCapacity,
                oldCapacity >> 1);
        return elementData =
                Arrays.copyOf(elementData, newCapacity);
    }

    return elementData =
            new Object[Math.max(10, minCapacity)];
}

minCapacity 是本次操作至少需要的容量,oldCapacity >> 1 是期望增长量。ArraysSupport.newLength 还要处理一次添加大量元素、整数上限和超大数组等边界,并不只是简单计算 1.5 倍。

七、10 → 15 → 22:约 1.5 倍扩容

当前 JDK 25 常规路径的期望容量可以这样理解:

int preferredGrowth = oldCapacity >> 1;
int newCapacity = oldCapacity + preferredGrowth;

// 10 + 5 = 15
// 15 + 7 = 22
// 22 + 11 = 33

右移一位相当于非负整数除以 2并向下取整,所以常说“扩容为原来的约 1.5 倍”。

每次只增加一个槽位会频繁复制;一次扩大很多倍又可能浪费内存。约 1.5 倍是在复制次数和空闲空间之间做折中。

但必须强调:

Java SE API 没有规定 ArrayList 必须按 1.5 倍扩容。业务代码只能依赖容量会自动增长和相关复杂度语义,不能依赖某个版本的具体容量序列。

ArrayList 从扩容到复制再到旧数组回收的流程图

扩容不是在旧数组后面直接接几格。Java 数组长度创建后不能改变,真正过程是:

旧数组 capacity=10
      ↓ 创建新数组
新数组 capacity=15
      ↓ 复制已有元素
elementData 指向新数组
      ↓
旧数组等待 GC

扩容那一次需要复制元素,成本为 O(n);但不是每次追加都扩容,因此连续添加 n 个元素的总成本仍为 O(n),单次追加表现为摊销 O(1)

八、中间插入与删除为什么比较贵

[A, B, C, D] 的下标 1 插入 X,需要把 B、C、D 向后搬;删除中间元素则要把后面的内容向前补位:

public void add(int index, E element) {
    rangeCheckForAdd(index);
    modCount++;
    if (size == elementData.length) {
        grow(size + 1);
    }
    System.arraycopy(
            elementData, index,
            elementData, index + 1,
            size - index);
    elementData[index] = element;
    size++;
}

private void shiftTailOverGap(Object[] data, int index) {
    int moved = size - index - 1;
    if (moved > 0) {
        System.arraycopy(
                data, index + 1,
                data, index,
                moved);
    }
    data[--size] = null;
}

插入或删除的位置越靠前,需要搬动的元素越多。删除后把尾部设为 null 也很重要,否则内部数组可能继续强引用已经删除的对象。

九、modCount 与 fail-fast:购物清单版本变了

迭代器创建时会记住 expectedModCount。如果遍历期间 ArrayList 被其他路径结构性修改,modCount 发生变化,迭代器会尽快抛出 ConcurrentModificationException

List<String> cart = new ArrayList<>(
        List.of("牛奶", "过期面包", "苹果"));

try {
    for (String item : cart) {
        if (item.startsWith("过期")) {
            cart.remove(item); // 错误
        }
    }
} catch (ConcurrentModificationException e) {
    System.out.println("检测到结构性修改");
}

Iterator<String> iterator = cart.iterator();
while (iterator.hasNext()) {
    if (iterator.next().startsWith("过期")) {
        iterator.remove();     // 正确
    }
}

fail-fast 只是尽力而为的错误检测,不是线程安全协议,也不保证所有并发修改都一定抛异常。不能依赖这个异常维持业务正确性。

十、三个高频陷阱

10.1 remove(1) 删除下标还是数字 1

10.2 subList 是视图还是副本

10.3 Arrays.asList 和 List.of 能否增删

把三个问题放在一起看:

List<Integer> numbers =
        new ArrayList<>(List.of(1, 2, 3, 2));
numbers.remove(1);                  // 删除下标 1
numbers.remove(Integer.valueOf(1)); // 删除对象 1

List<String> cart =
        new ArrayList<>(List.of("A", "B", "C", "D"));
List<String> view = cart.subList(1, 3);
view.clear();
System.out.println(cart);           // [A, D]

List<String> independent =
        new ArrayList<>(cart.subList(0, cart.size()));

List<String> fixed = Arrays.asList("牛奶", "面包");
List<String> immutable = List.of("牛奶", "面包");
// fixed.add("苹果");       // UnsupportedOperationException
// immutable.set(0, "苹果"); // UnsupportedOperationException

remove(1) 优先匹配 remove(int index);要删除值 1,需要包装成 Integer

subList 返回原列表的区间视图,修改会反映到原列表;需要独立集合时应再 new ArrayList<>(subList)。创建视图后,如果绕开视图直接结构性修改原列表,再继续使用旧视图,可能抛出 ConcurrentModificationException

Arrays.asList 返回由数组支持的定长 List,List.of 返回不可修改 List,它们都不是普通可增删 ArrayList。

十一、容量优化与并发边界

已知大致数据量时,可以指定初始容量或使用 ensureCapacity

可以直接写 new ArrayList<>(expectedSize),也可以先创建列表再调用 ensureCapacity(expectedSize)。提前规划能减少扩容和复制,但容量设置得过大也会浪费内存。trimToSize() 可以把容量缩到当前 size,却同样需要创建新数组并复制元素,不适合在热路径里反复调用。

ArrayList 不是线程安全容器。并发修改时应在外部用锁保护完整操作,或根据场景评估 Collections.synchronizedListCopyOnWriteArrayList。即便使用 synchronizedList,遍历期间也要按照文档要求在同一把锁上同步。

十二、完整实验:观察扩容、搬运与遍历

下面的程序一次验证扩容序列、元素搬运、fail-fast、安全删除、remove 重载、容量规划和首尾操作:

import java.lang.reflect.Field;
import java.util.*;

public class ArrayListShoppingCartDemo {
    private static final Field DATA = elementDataField();

    private static Field elementDataField() {
        try {
            Field field =
                    ArrayList.class.getDeclaredField("elementData");
            field.setAccessible(true);
            return field;
        } catch (ReflectiveOperationException e) {
            throw new IllegalStateException(e);
        }
    }

    public static void main(String[] args) throws Exception {
        ArrayList<String> list = new ArrayList<>();
        int previous = -1;
        for (int i = 0; i <= 23; i++) {
            if (i > 0) {
                list.add("商品-" + i);
            }
            int current = capacity(list);
            if (current != previous) {
                System.out.printf(
                        "[扩容] size=%d, capacity=%d%n",
                        list.size(), current);
                previous = current;
            }
        }

        var cart = new ArrayList<>(List.of("A", "B", "C", "D"));
        cart.add(1, "X");
        cart.remove(3);
        System.out.println("[搬运] " + cart);

        var wrong = new ArrayList<>(List.of("牛奶", "面包"));
        var stale = wrong.iterator();
        wrong.add("纸巾");
        try {
            stale.next();
        } catch (ConcurrentModificationException e) {
            System.out.println("[fail-fast] 检测到版本变化");
        }

        var safe = new ArrayList<>(
                List.of("牛奶", "过期面包", "苹果"));
        var iterator = safe.iterator();
        while (iterator.hasNext()) {
            if (iterator.next().startsWith("过期")) {
                iterator.remove();
            }
        }
        System.out.println("[安全删除] " + safe);

        var numbers = new ArrayList<>(List.of(1, 2, 3, 2));
        numbers.remove(1);
        numbers.remove(Integer.valueOf(1));
        System.out.println("[remove 重载] " + numbers);

        var defaultList = new ArrayList<Integer>();
        var plannedList = new ArrayList<Integer>();
        plannedList.ensureCapacity(100);
        System.out.printf(
                "[容量规划] 默认扩容=%d 次,提前规划=%d 次%n",
                countGrowths(defaultList, 100),
                countGrowths(plannedList, 100));

        var sequenced = new ArrayList<String>();
        sequenced.addFirst("面包");
        sequenced.addFirst("牛奶");
        sequenced.addLast("苹果");
        System.out.printf(
                "[首尾] first=%s, last=%s, reversed=%s%n",
                sequenced.getFirst(),
                sequenced.getLast(),
                sequenced.reversed());
    }

    private static int countGrowths(
            ArrayList<Integer> list, int count) throws Exception {
        int times = 0, previous = capacity(list);
        for (int i = 0; i < count; i++) {
            list.add(i);
            int current = capacity(list);
            if (current != previous) {
                times++;
                previous = current;
            }
        }
        return times;
    }

    private static int capacity(ArrayList<?> list) throws Exception {
        return ((Object[]) DATA.get(list)).length;
    }
}

反射读取了 JDK 私有字段,因此运行时需要显式开放包:

javac ArrayListShoppingCartDemo.java
java --add-opens java.base/java.util=ALL-UNNAMED ArrayListShoppingCartDemo

关键输出包括:

[扩容] size=0, capacity=0
[扩容] size=1, capacity=10
[扩容] size=11, capacity=15
[扩容] size=16, capacity=22
[扩容] size=23, capacity=33
[搬运] [A, X, B, D]
[fail-fast] 检测到版本变化
[安全删除] [牛奶, 苹果]
[remove 重载] [3, 2]
[容量规划] 默认扩容=7 次,提前规划=0 次
[首尾] first=牛奶, last=苹果, reversed=[苹果, 面包, 牛奶]

反射与 --add-opens 只用于观察实验,不应成为业务代码读取容量的方式。生产代码应依赖公开 API。

十三、总结:车有容量,货有数量,装满再搬家

  1. ArrayList 的底层核心是 Object[] elementData
  2. size 是有效元素数量,capacity 是内部数组长度;
  3. 默认构造器使用空数组占位,第一次添加时才落实默认容量;
  4. 当前 JDK 25 常规路径约按 1.5 倍扩容;
  5. 具体扩容比例属于源码实现,不是 Java SE 契约;
  6. 扩容需要创建新数组并复制旧元素;
  7. 尾部添加是摊销 O(1),中间插入和删除通常需要搬运;
  8. fail-fast 用来尽早发现错误修改,不保证线程安全;
  9. remove 重载、subList 视图和不可修改 List 是高频坑;
  10. 已知数据量时合理规划容量,可以减少复制。

一句话记忆:

数组定长,List 管账;size 看货,capacity 看车;装满扩容,复制搬家。

参考资料

本文完整示例使用 JDK 25 编译运行。源码部分为了突出主线进行了删减;实际开发请以目标 JDK 的公开 API、对应版本源码和真实压测结果为准。


  • 个人小游戏

个人小游戏

在这里插入图片描述

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐