ArrayList 使用与底层原理 | JavaSE
ArrayList 使用与底层原理
一、学习目标
完成本章后,你应该能够:
- 能够解释 ArrayList 的定位以及它与 List 接口之间的关系。
- 能够使用 ArrayList 的常见构造方式和核心 API 管理业务对象。
- 能够解释 ArrayList 为什么适合随机索引访问。
- 能够区分
size与capacity,理解“元素数量”和“内部数组容量”不是同一个概念。 - 能够理解 ArrayList 基于数组实现,以及插入、删除时为什么可能发生元素移动。
- 能够理解 ArrayList 自动扩容的基本过程,并区分 API 规范与 OpenJDK 21 的具体实现细节。
- 能够正确理解“ArrayList 查询快、增删慢”这句教学口诀的边界,而不是机械背诵。
- 能够根据业务访问模式判断是否适合使用 ArrayList。
二、核心知识
2.1 ArrayList 是什么
ArrayList 是 Java 中最常用的 List 实现之一。
基本关系:
List<E>
↑
ArrayList<E>
代码:
List<String> technologies =
new ArrayList<>();
ArrayList 实现了 List,因此具有:
有明确顺序
通常允许重复
支持索引访问
等 List 能力。
2.2 为什么 ArrayList 如此常用
大量业务数据天然可以表达为:
一个有顺序的对象列表
例如:
学生列表
文章列表
订单列表
商品列表
评论列表
搜索结果
教程章节列表
这些数据经常需要:
按位置读取
从前到后遍历
在末尾不断追加
而这正是 ArrayList 的优势场景。
2.3 ArrayList 底层是什么
ArrayList 最核心的底层数据结构是:
数组。
可以先用一个简化模型理解:
ArrayList
│
↓
内部数组
┌─────┬─────┬─────┬─────┬─────┐
│ A │ B │ C │ │ │
└─────┴─────┴─────┴─────┴─────┘
0 1 2 3 4
当前 List 中:
size = 3
但内部数组可能能够容纳:
5
个位置。
因此:
元素数量
和:
内部数组长度
必须区分。
2.4 size 是什么
size 表示:
ArrayList 当前实际保存的元素数量。
例如:
ArrayList<String> list =
new ArrayList<>();
list.add("Java");
list.add("MySQL");
list.add("Redis");
此时:
list.size();
返回:
3
2.5 capacity 是什么
容量(Capacity)可以理解为:
ArrayList 当前内部数组能够容纳元素的空间规模。
例如概念模型:
内部数组长度 = 10
实际使用:
3 个位置
那么:
size = 3
capacity = 10
注意:
ArrayList没有向普通业务代码直接提供一个capacity()公共方法。
所以 capacity 更多是:
理解底层实现和性能时使用的概念。
2.6 size 与 capacity 的区别
非常重要:
size
= 当前实际有几个元素
capacity
= 当前内部存储空间能够容纳多少元素
例如:
┌──────┬──────┬──────┬──────┬──────┐
│Java │MySQL │Redis │ │ │
└──────┴──────┴──────┴──────┴──────┘
可以理解:
size = 3
capacity = 5
业务中:
list.size()
返回的是:
3
而不是:
5
2.7 ArrayList 为什么能够动态增加元素
数组创建后长度固定。
例如:
Object[] data =
new Object[10];
数组本身不能突然把长度变成 20。
那么 ArrayList 为什么可以:
list.add(...);
list.add(...);
list.add(...);
不断增加元素?
核心答案:
ArrayList 不是把原数组“变长”,而是在容量不足时创建一个更大的新数组,并把原数据复制过去。
概念流程:
旧数组空间不够
↓
创建更大的新数组
↓
复制旧数组元素
↓
ArrayList 改为引用新数组
↓
继续添加元素
这就是:
动态数组(Dynamic Array)
思想。
三、使用方法
3.1 创建空 ArrayList
常见:
ArrayList<String> list =
new ArrayList<>();
更推荐在不依赖 ArrayList 独有 API 时:
List<String> list =
new ArrayList<>();
体现:
面向接口编程。
3.2 指定初始容量
ArrayList 提供:
new ArrayList<>(initialCapacity)
例如:
ArrayList<String> list =
new ArrayList<>(100);
意思是:
为该 ArrayList 指定初始容量。
如果我们大致知道:
接下来可能一次加入很多元素
合理设置初始容量可以:
减少后续扩容和数组复制次数。
但是:
初始容量 100
并不等于:
size = 100
刚创建时:
list.size()
仍然是:
0
3.3 使用另一个 Collection 创建 ArrayList
还可以:
Collection<String> oldData =
List.of(
"Java",
"MySQL",
"Redis"
);
ArrayList<String> list =
new ArrayList<>(oldData);
新 ArrayList:
[Java, MySQL, Redis]
这是非常实用的:
ArrayList(Collection<? extends E> c)
构造方式。
3.4 追加元素
ArrayList<String> list =
new ArrayList<>();
list.add("Java");
list.add("MySQL");
list.add("Redis");
末尾追加:
add(E e)
是 ArrayList 最典型的操作之一。
3.5 根据索引读取
String value =
list.get(1);
ArrayList 的底层是数组,因此可以利用:
数组 + 索引
快速定位元素。
这是 ArrayList 的核心优势。
3.6 根据索引修改
list.set(1, "PostgreSQL");
本质上可以从概念上理解:
找到内部数组对应位置
↓
替换引用
通常不需要移动其他元素。
3.7 在中间插入元素
例如:
原来:
0 A
1 B
2 C
3 D
执行:
list.add(1, "X");
希望变成:
0 A
1 X
2 B
3 C
4 D
为了给:
X
腾位置,原来的:
B
C
D
需要向后移动。
可以理解:
A B C D _
↓
A _ B C D
↓
A X B C D
因此:
在 ArrayList 中间插入元素,可能涉及大量元素移动。
3.8 删除中间元素
例如:
0 A
1 B
2 C
3 D
执行:
list.remove(1);
删除:
B
结果需要变成:
0 A
1 C
2 D
原来的:
C
D
需要向前移动。
因此:
中间删除
同样可能有元素搬移成本。
3.9 末尾删除为什么不同
例如:
0 A
1 B
2 C
删除:
list.remove(2);
即删除最后一个元素。
后面已经没有元素需要向前搬。
因此:
不能简单说 ArrayList“每一次删除都很慢”。
删除成本与:
删除位置
集合规模
是否需要移动后续元素
都有关系。
3.10 ensureCapacity
ArrayList 提供:
ensureCapacity(int minCapacity)
例如:
ArrayList<String> list =
new ArrayList<>();
list.ensureCapacity(1000);
含义:
如果当前容量不足,则尽量保证 ArrayList 至少能够容纳指定数量的元素。
典型场景:
已经提前知道马上会批量加入大量数据
可以提前:
ensureCapacity(...)
减少扩容次数。
例如:
ArrayList<Integer> numbers =
new ArrayList<>();
numbers.ensureCapacity(100_000);
for (int i = 0;
i < 100_000;
i++) {
numbers.add(i);
}
3.11 trimToSize
ArrayList 还提供:
trimToSize()
作用:
尝试把 ArrayList 的容量缩减到当前 size。
例如:
ArrayList<String> list =
new ArrayList<>(1000);
list.add("Java");
list.add("MySQL");
list.trimToSize();
从概念上:
原本预留很大容量
↓
实际只有少量元素
↓
缩减内部数组容量
需要注意:
不要在普通业务代码中无意义地频繁调用
trimToSize()。
因为调整容量本身也需要成本。
它是:
特定内存优化场景下的工具。
四、原理与进阶
4.1 OpenJDK 21 中 ArrayList 的核心字段
从 OpenJDK 实现角度看,ArrayList 的核心思想可以简化为:
Object[] elementData;
int size;
其中:
elementData
负责:
真正保存元素。
而:
size
记录:
当前元素数量。
因为 Java 泛型存在类型擦除,所以 ArrayList 内部使用:
Object[]
作为主要存储数组。
4.2 ArrayList 保存的是对象引用
例如:
List<Student> students =
new ArrayList<>();
从概念模型看:
ArrayList 内部数组
┌──────┬──────┬──────┐
│ ref1 │ ref2 │ ref3 │
└──────┴──────┴──────┘
│ │ │
↓ ↓ ↓
Student Student Student
数组中主要保存:
对象引用。
不是把整个 Student 对象“塞进数组格子内部”。
这与 Java 引用类型的基本模型是一致的。
4.3 默认构造器与“默认容量 10”的准确理解
你可能见过一句非常常见的话:
“ArrayList 默认创建一个长度为 10 的数组。”
在现代 OpenJDK 实现中,这句话不够精确。
使用:
new ArrayList<>()
时,OpenJDK 21 会先使用一个共享的空数组表示空 List,而不是立即分配一个长度为 10 的元素数组。
可以理解:
new ArrayList<>()
↓
当前为空
内部暂时使用空数组
当第一次真正加入元素,需要扩容时:
默认容量目标为 10
于是再分配存储空间。
因此更加准确:
默认构造出的 ArrayList 逻辑上是空 List;OpenJDK 21 延迟到首次需要存储元素时才进行默认容量相关的实际数组扩展。
这样做可以避免:
创建了很多 ArrayList
但最终一个元素都没有
时白白分配大量数组空间。
4.4 添加元素的大致流程
例如:
list.add(element);
可以抽象为:
1. 判断内部容量是否足够
↓
2. 如果足够
↓
直接写入数组
3. 如果不足
↓
扩容内部数组
↓
复制旧元素
↓
写入新元素
4. size++
这个模型比死记源代码更重要。
4.5 ArrayList 的扩容不是修改原数组长度
再次强调:
Java 数组:
new Object[10]
创建以后:
length = 10
不会变成:
length = 15
所谓 ArrayList 扩容,本质:
旧数组
10 个位置
↓
创建新数组
更大空间
↓
复制元素
↓
elementData 指向新数组
旧数组如果以后不再被引用:
最终可以被垃圾回收
4.6 “ArrayList 每次扩容 1.5 倍”应该怎么理解
很多教程会直接说:
ArrayList 每次扩容为原来的 1.5 倍。
这可以作为:
理解常见 OpenJDK 实现扩容偏好的近似记忆。
在当前 OpenJDK 实现中,增长逻辑使用:
oldCapacity >> 1
作为 preferred growth。
也就是大约:
旧容量的一半
加到旧容量:
oldCapacity
+
oldCapacity / 2
≈
1.5 × oldCapacity
例如概念上:
10
↓
15
↓
22
...
但是必须注意:
不能把“严格每次一定乘以 1.5”当成 Java API 规范保证。
实际扩容还需要考虑:
本次最少需要多少容量
整数边界
最大数组规模
特殊初始状态
所以更加专业的表述是:
OpenJDK 当前实现通常以约 1.5 倍作为首选增长策略,但具体增长策略属于实现细节,不是
ArrayList公共 API 对所有 Java 实现的永久契约。
4.7 为什么 ArrayList 按索引查询快
数组能够通过:
数组起始位置 + 索引
快速定位元素。
因此:
list.get(index)
在 ArrayList 中属于典型:
O(1)
随机访问。
例如:
list.get(0);
list.get(5000);
list.get(9999);
从数据结构算法模型上看:
都可以直接按照索引定位。
不需要像链表一样从一个节点沿指针不断寻找。
4.8 “查询快”不能理解成所有查询都 O(1)
这是一个极其重要的边界。
下面:
list.get(500);
属于:
按索引查询。
它非常快。
但是:
list.contains("Java");
或者:
list.indexOf("Java");
通常需要:
从前向后检查元素
最坏情况下需要检查很多元素。
因此:
ArrayList 查询快
更准确地说是:
ArrayList 的按索引随机访问速度快。
不能理解成:
任何形式的查询都是 O(1)
4.9 ArrayList 的典型时间复杂度
可以建立下面的学习模型:
| 操作 | 典型复杂度 | 原因 |
| ------------------- | ---------: | ---------------- |
| get(index) | O(1) | 数组随机访问 |
| set(index,e) | O(1) | 直接定位并替换 |
| size() | O(1) | 直接读取 size |
| 末尾 add(e) | 摊还 O(1) | 偶尔需要扩容复制 |
| 中间 add(index,e) | O(n) | 可能移动元素 |
| remove(index) | O(n) | 可能移动后续元素 |
| contains(o) | O(n) | 通常顺序查找 |
| indexOf(o) | O(n) | 顺序寻找匹配元素 |
这里最值得理解的是:
末尾 add
为什么不是简单:
O(1)
而是:
摊还 O(1)
因为:
大多数 add
→ 直接写入
少数 add
→ 触发扩容 + 数组复制
把大量 add 操作整体平均来看:
平均成本仍然很低
所以称:
摊还常数时间(Amortized Constant Time)。
4.10 为什么中间插入通常是 O(n)
假设:
10 万个元素
现在:
list.add(0, newElement);
为了在最前面插入:
原来的大量元素
都需要向后移动。
因此最坏情况下移动元素数量与:
n
成比例。
所以:
O(n)
4.11 为什么 ArrayList 并不是“增删都慢”
教材常见口诀:
ArrayList:
查询快
增删慢
这句话适合帮助初学者形成第一印象,但必须升级。
末尾添加
list.add(e);
通常:
摊还 O(1)
非常常用,也非常高效。
中间插入
list.add(index, e);
可能移动大量元素:
O(n)
删除末尾
删除最后一个:
通常不需要移动后续元素
成本较低。
删除开头
list.remove(0);
会导致:
大量后续元素向前移动
成本明显更高。
所以更准确:
ArrayList 对随机索引访问和末尾追加非常擅长;在靠前或中间位置频繁插入、删除时,元素移动可能带来较大成本。
4.12 数组连续存储还带来缓存友好性
ArrayList 的内部数据主要放在数组中。
数组具有较好的:
内存局部性(Memory Locality)。
当程序连续遍历:
for (Element e : list) {
...
}
CPU 缓存往往能够较高效地读取相邻数组区域。
所以现实性能并不能只通过:
“链表插入删除 O(1)”
这种单一复杂度口诀判断。
数据结构实际性能还涉及:
元素查找成本
缓存局部性
对象分配
内存占用
数据规模
操作位置
这也是下一章比较 ArrayList 与 LinkedList 时非常重要的基础。
4.13 RandomAccess 标记接口
ArrayList 实现了:
RandomAccess
这是一个:
标记接口(Marker Interface)。
它没有要求实现一堆新的普通业务方法。
主要用于表达:
这个 List 实现支持高效随机访问。
因此:
ArrayList
属于典型随机访问 List。
而:
LinkedList
并不属于这种数据结构模型。
4.14 ArrayList 不是线程安全集合
ArrayList 本身:
不是同步集合。
如果多个线程同时访问同一个 ArrayList,并且至少有线程进行结构性修改,就需要额外考虑线程安全。
这个问题将在:
08 · 多线程与并发
系统学习。
本章只需要记住:
ArrayList
≠ 自动线程安全
五、实践应用
5.1 查询结果列表
例如数据库查询得到:
List<Article> articles =
new ArrayList<>();
通常处理模式:
查询一次
↓
得到很多文章
↓
顺序遍历
↓
展示
ArrayList 很合适。
5.2 教程目录
例如:
List<Chapter> chapters =
new ArrayList<>();
业务经常:
顺序保存
按照索引或顺序读取
遍历展示
ArrayList 是自然选择。
5.3 REST API 返回列表
后续 Spring 开发中经常看到:
List<User>
List<Article>
List<Order>
大量底层实现实际会使用 ArrayList。
因此 ArrayList 不只是 JavaSE 练习类:
它会贯穿后续整个 Java 后端开发。
5.4 批量数据导入
假设已经知道:
马上会读入 100000 条数据
可以考虑:
ArrayList<Data> data =
new ArrayList<>(100_000);
或者:
ArrayList<Data> data =
new ArrayList<>();
data.ensureCapacity(100_000);
避免不必要的多轮扩容。
5.5 什么时候 ArrayList 通常是很好的默认 List
如果业务主要是:
大量读取
顺序遍历
按索引访问
末尾追加
中间增删并不频繁
ArrayList 通常是非常合理的选择。
这也是工程开发中:
ArrayList 往往比 LinkedList 更常见
的重要原因之一。
六、常见问题
6.1 ArrayList 底层是不是链表?
不是。
ArrayList:
动态数组
LinkedList:
双向链表
两者都实现 List,但底层完全不同。
6.2 ArrayList 创建以后数组长度是不是永远不变?
单个数组对象长度固定。
但是 ArrayList 可以:
创建一个更大的新数组
↓
复制数据
↓
替换内部数组引用
因此 ArrayList 表现出:
动态增长
能力。
6.3 new ArrayList<>() 会立即创建长度 10 的数组吗?
在当前 OpenJDK 21 实现中:
不应这样简单描述。
默认空 ArrayList 最初使用共享空数组。
首次真正需要加入元素时,才进行默认容量相关的扩展。
所以:
默认容量 10
不能机械理解成:
执行 new 的瞬间一定分配 Object[10]
6.4 size 和 capacity 是不是一回事?
不是。
size
→ 有几个真实元素
capacity
→ 内部数组当前能够容纳多少元素
例如:
size = 3
capacity = 10
完全正常。
6.5 ArrayList 每次是不是严格扩容 1.5 倍?
不要把它当 API 契约。
当前 OpenJDK 实现采用:
约 1.5 倍
作为 preferred growth。
但实际容量还会受到:
本次最小需要容量
数组最大边界
当前容量
实现版本
等影响。
因此:
“1.5 倍”适合理解当前主流实现,不适合作为永久 Java 规范保证。
6.6 ArrayList 查询一定是 O(1) 吗?
不是。
按索引:
get(index)
是 O(1)。
但是:
contains(value)
indexOf(value)
通常需要顺序查找:
O(n)
所以应该说:
ArrayList 的随机索引访问快。
6.7 ArrayList 增删一定很慢吗?
也不是。
例如:
list.add(e);
末尾追加通常:
摊还 O(1)
非常高效。
真正需要重点关注的是:
中间插入
前部插入
中间删除
前部删除
这些操作可能需要移动大量元素。
6.8 为什么 ArrayList 删除元素后要移动数据?
因为数组具有连续索引结构。
例如:
A B C D
删除:
B
如果不移动:
A _ C D
那么 List 连续位置语义就被破坏。
所以通常需要:
C D
向前移动:
A C D
6.9 ArrayList 适合做队列吗?
技术上可以写:
list.add(e);
list.remove(0);
但如果不断:
从尾部加入
从头部删除
每次:
remove(0)
都可能移动大量元素。
因此:
有更适合队列的数据结构和 API。
后续会进一步学习:
LinkedList
Deque
等结构。
6.10 为什么不能只看 Big-O 判断 ArrayList 与 LinkedList?
因为真实性能还涉及:
查找目标位置的成本
元素移动成本
对象分配
CPU cache
内存局部性
内存开销
业务访问模式
因此:
链表插入 O(1)
并不能推出:
LinkedList 所有增删场景都比 ArrayList 快
下一章会专门比较。
七、练习与验收
7.1 知识问答
- ArrayList 与 List 是什么关系?
- ArrayList 底层主要基于什么结构?
- 什么是动态数组?
- Java 普通数组长度能否改变?
- ArrayList 为什么能够动态增加元素?
size表示什么?capacity表示什么?- 为什么 size 和 capacity 不能混淆?
new ArrayList<>(100)是否代表立即拥有 100 个元素?- ArrayList 为什么按索引查询快?
get(index)的典型复杂度是什么?contains为什么不能认为是 O(1)?- 中间插入元素为什么可能是 O(n)?
- 中间删除为什么可能是 O(n)?
- 末尾
add为什么通常是摊还 O(1)? - 什么叫摊还时间复杂度?
- ArrayList 扩容的本质是什么?
- 当前 OpenJDK 实现中的约 1.5 倍扩容应该如何准确理解?
ensureCapacity解决什么问题?trimToSize解决什么问题?- ArrayList 为什么实现 RandomAccess?
- 为什么“ArrayList 增删慢”是一种过度简化?
- ArrayList 是否线程安全?
- 哪些业务场景通常适合 ArrayList?
7.2 代码阅读
阅读:
ArrayList<String> list =
new ArrayList<>(100);
System.out.println(
list.size()
);
list.add("A");
list.add("B");
System.out.println(
list.size()
);
回答:
- 第一次
size()是多少? - 为什么不是 100?
100表示什么概念?- 添加两个元素后
size()是多少?
阅读:
List<String> list =
new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
list.add("D");
list.add(1, "X");
要求:
- 插入前逻辑位置是什么?
- 插入后是什么?
- 哪些原元素的位置发生变化?
- 为什么数组结构需要移动元素?
阅读:
List<String> list =
new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
list.add("D");
list.remove(1);
回答:
- 删除哪个元素?
- 删除后哪些元素需要改变位置?
- 为什么这种操作可能是 O(n)?
7.3 手写代码
创建:
ArrayList<String> technologies
要求:
- 指定初始容量 20。
- 添加 10 个技术名称。
- 使用
get随机读取某一项。 - 修改一个元素。
- 在中间插入一个元素。
- 删除一个中间元素。
- 删除最后一个元素。
- 调用
ensureCapacity(100)。 - 调用
trimToSize()。 - 输出最终 size。
完成后口述:
哪些操作可能产生元素移动?
7.4 Debug
代码:
ArrayList<String> list =
new ArrayList<>(10);
if (list.size() == 10) {
System.out.println(
"集合中已经有10个元素"
);
}
要求:
- 判断逻辑是否正确。
- 解释 initial capacity 与 size 的区别。
- 修复业务判断。
某同学说:
new ArrayList<>()的底层一定立刻执行new Object[10]。
要求:
- 判断这个说法在当前 OpenJDK 21 实现下是否准确。
- 说明默认空 ArrayList 的延迟分配思想。
- 解释为什么不要把具体实现细节当成永久 API 契约。
某同学说:
ArrayList 查询全部都是 O(1)。
要求:
- 判断说法是否正确。
- 分析
get(index)。 - 分析
contains(value)。 - 分析
indexOf(value)。
7.5 综合训练
实现一个简化的“博客文章列表”。
定义:
class Article {
private Long id;
private String title;
// 构造器、getter、setter
}
使用:
List<Article> articles =
new ArrayList<>();
完成:
新增文章
在指定位置插入文章
按照索引查看文章
修改指定索引文章
删除指定索引文章
展示全部文章
获取第一篇文章
获取最后一篇文章
然后分析:
- 为什么这个业务可以使用 ArrayList?
- 哪些操作利用了 ArrayList 的优势?
- 哪些操作可能产生元素移动?
- 如果系统每天只在末尾增加文章、主要进行查询和遍历,ArrayList 是否合理?
- 如果系统大量从列表头部不断删除元素,还应不应该无脑使用 ArrayList?
7.6 本章验收
不查看资料,完成:
- 能够画出 ArrayList 的简化数组模型。
- 能够准确区分 size 与 capacity。
- 能够解释 ArrayList 为什么能“动态扩容”。
- 能够说明数组本身实际上没有改变长度。
- 能够解释 OpenJDK 21 默认空 ArrayList 的延迟分配思想。
- 能够解释约 1.5 倍扩容为什么属于实现细节而非 API 永久契约。
- 能够说出
get/set的典型复杂度。 - 能够说明末尾 add 为什么是摊还 O(1)。
- 能够解释中间插入和删除为什么可能是 O(n)。
- 能够解释为什么
contains/indexOf不是 O(1)。 - 能够说明 ArrayList 的典型适用场景。
- 能够反驳“ArrayList 所有增删都慢”“ArrayList 所有查询都快”这种绝对化结论。
如果只会背:
ArrayList:
查询快
增删慢
但说不出:
到底什么查询快?
什么增删慢?
为什么?
说明 ArrayList 的底层原理还没有真正掌握。