一文搞懂 Java ArrayList:把它想成一辆会自动扩容的超市购物车
文章目录
-
- 一、开场:购物车装满以后,是换车还是自动加长
- 二、数组、ArrayList 与 LinkedList 怎么选
- 三、add、get、set、remove 基础操作
- 四、elementData、size 与 capacity
- 五、为什么 new ArrayList<>() 没有立刻创建长度 10 的数组
- 六、add 与 grow:商品怎样放进购物车
- 七、10 → 15 → 22:约 1.5 倍扩容
- 八、中间插入与删除为什么比较贵
- 九、modCount 与 fail-fast:购物清单版本变了
- 十、三个高频陷阱
- 十一、容量优化与并发边界
- 十二、完整实验:观察扩容、搬运与遍历
- 十三、总结:车有容量,货有数量,装满再搬家
- 参考资料
一、开场:购物车装满以后,是换车还是自动加长
周末晚上,超市正在促销。小李把牛奶、面包、水果和纸巾不断放进购物车,很快就没有空位了。
普通购物车装不下时,我们只能换一辆更大的车,再把原来的商品搬过去。如果这辆购物车叫 ArrayList,它会自动完成这件事:
- 发现内部数组已经没有空位;
- 申请容量更大的新数组;
- 把旧数组里的元素复制过去;
- 让
elementData指向新数组; - 放入新元素,旧数组等待垃圾回收。

这就是 ArrayList 的核心:外表是一张可以不断添加元素的 List,内部仍然是一块长度固定的数组;所谓自动扩容,其实是创建新数组并搬家。
| 超市场景 | ArrayList 概念 |
|---|---|
| 购物车 | ArrayList 对象 |
| 固定格子 | 内部数组 elementData |
| 已放入的商品数 | size |
| 一共拥有的格子数 | capacity |
| 放到车尾 | add(E e) |
| 换更大的车 | grow(minCapacity) |
| 搬运旧商品 | Arrays.copyOf / System.arraycopy |
| 购物清单版本 | modCount |

本文以 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:
get、set、remove的合法下标是0~2;add(index, value)的合法下标是0~3,因为允许插到末尾;- 越界会抛出
IndexOutOfBoundsException。
size() 返回有效元素数量,不是内部数组长度。购物车有 10 个格子,但只放了 3 件商品时,size 是 3,capacity 是 10。
3.1 JDK 25 中的首尾操作
ArrayList 实现的 List 继承了 SequencedCollection,JDK 25 可以直接使用 addFirst、addLast、getFirst、getLast 和 reversed。这些 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

需要注意:
size只统计有效区间[0, size);- capacity 是数组长度,公开 API 没有直接读取方法;
- 后面的
null是预留槽位,不属于 List; - ArrayList 允许主动添加
null,不能靠 null 判断 size; - 删除后源码会清空不再使用的尾部槽位,避免继续引用旧对象。
五、为什么 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 倍扩容。业务代码只能依赖容量会自动增长和相关复杂度语义,不能依赖某个版本的具体容量序列。

扩容不是在旧数组后面直接接几格。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.synchronizedList、CopyOnWriteArrayList。即便使用 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。
十三、总结:车有容量,货有数量,装满再搬家
- ArrayList 的底层核心是
Object[] elementData; size是有效元素数量,capacity 是内部数组长度;- 默认构造器使用空数组占位,第一次添加时才落实默认容量;
- 当前 JDK 25 常规路径约按 1.5 倍扩容;
- 具体扩容比例属于源码实现,不是 Java SE 契约;
- 扩容需要创建新数组并复制旧元素;
- 尾部添加是摊销
O(1),中间插入和删除通常需要搬运; - fail-fast 用来尽早发现错误修改,不保证线程安全;
remove重载、subList视图和不可修改 List 是高频坑;- 已知数据量时合理规划容量,可以减少复制。
一句话记忆:
数组定长,List 管账;size 看货,capacity 看车;装满扩容,复制搬家。
参考资料
- Oracle Java SE 25:ArrayList API
- Oracle Java SE 25:List API
- OpenJDK JDK 25:ArrayList.java 源码
- OpenJDK JDK 25:ArraysSupport.java 源码
本文完整示例使用 JDK 25 编译运行。源码部分为了突出主线进行了删减;实际开发请以目标 JDK 的公开 API、对应版本源码和真实压测结果为准。
- 个人小游戏


更多推荐



所有评论(0)