ArrayList 使用与底层原理 | JavaSE

ArrayList 使用与底层原理

一、学习目标

完成本章后,你应该能够:

  • 能够解释 ArrayList 的定位以及它与 List 接口之间的关系。
  • 能够使用 ArrayList 的常见构造方式和核心 API 管理业务对象。
  • 能够解释 ArrayList 为什么适合随机索引访问。
  • 能够区分 sizecapacity,理解“元素数量”和“内部数组容量”不是同一个概念。
  • 能够理解 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 知识问答

  1. ArrayList 与 List 是什么关系?
  2. ArrayList 底层主要基于什么结构?
  3. 什么是动态数组?
  4. Java 普通数组长度能否改变?
  5. ArrayList 为什么能够动态增加元素?
  6. size 表示什么?
  7. capacity 表示什么?
  8. 为什么 size 和 capacity 不能混淆?
  9. new ArrayList<>(100) 是否代表立即拥有 100 个元素?
  10. ArrayList 为什么按索引查询快?
  11. get(index) 的典型复杂度是什么?
  12. contains 为什么不能认为是 O(1)?
  13. 中间插入元素为什么可能是 O(n)?
  14. 中间删除为什么可能是 O(n)?
  15. 末尾 add 为什么通常是摊还 O(1)?
  16. 什么叫摊还时间复杂度?
  17. ArrayList 扩容的本质是什么?
  18. 当前 OpenJDK 实现中的约 1.5 倍扩容应该如何准确理解?
  19. ensureCapacity 解决什么问题?
  20. trimToSize 解决什么问题?
  21. ArrayList 为什么实现 RandomAccess?
  22. 为什么“ArrayList 增删慢”是一种过度简化?
  23. ArrayList 是否线程安全?
  24. 哪些业务场景通常适合 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()
);

回答:

  1. 第一次 size() 是多少?
  2. 为什么不是 100?
  3. 100 表示什么概念?
  4. 添加两个元素后 size() 是多少?

阅读:

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

list.add("A");
list.add("B");
list.add("C");
list.add("D");

list.add(1, "X");

要求:

  1. 插入前逻辑位置是什么?
  2. 插入后是什么?
  3. 哪些原元素的位置发生变化?
  4. 为什么数组结构需要移动元素?

阅读:

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

list.add("A");
list.add("B");
list.add("C");
list.add("D");

list.remove(1);

回答:

  1. 删除哪个元素?
  2. 删除后哪些元素需要改变位置?
  3. 为什么这种操作可能是 O(n)?

7.3 手写代码

创建:

ArrayList<String> technologies

要求:

  1. 指定初始容量 20。
  2. 添加 10 个技术名称。
  3. 使用 get 随机读取某一项。
  4. 修改一个元素。
  5. 在中间插入一个元素。
  6. 删除一个中间元素。
  7. 删除最后一个元素。
  8. 调用 ensureCapacity(100)
  9. 调用 trimToSize()
  10. 输出最终 size。

完成后口述:

哪些操作可能产生元素移动?

7.4 Debug

代码:

ArrayList<String> list =
        new ArrayList<>(10);

if (list.size() == 10) {
    System.out.println(
            "集合中已经有10个元素"
    );
}

要求:

  1. 判断逻辑是否正确。
  2. 解释 initial capacity 与 size 的区别。
  3. 修复业务判断。

某同学说:

new ArrayList<>() 的底层一定立刻执行 new Object[10]

要求:

  1. 判断这个说法在当前 OpenJDK 21 实现下是否准确。
  2. 说明默认空 ArrayList 的延迟分配思想。
  3. 解释为什么不要把具体实现细节当成永久 API 契约。

某同学说:

ArrayList 查询全部都是 O(1)。

要求:

  1. 判断说法是否正确。
  2. 分析 get(index)
  3. 分析 contains(value)
  4. 分析 indexOf(value)

7.5 综合训练

实现一个简化的“博客文章列表”。

定义:

class Article {

    private Long id;
    private String title;

    // 构造器、getter、setter
}

使用:

List<Article> articles =
        new ArrayList<>();

完成:

新增文章
在指定位置插入文章
按照索引查看文章
修改指定索引文章
删除指定索引文章
展示全部文章
获取第一篇文章
获取最后一篇文章

然后分析:

  1. 为什么这个业务可以使用 ArrayList?
  2. 哪些操作利用了 ArrayList 的优势?
  3. 哪些操作可能产生元素移动?
  4. 如果系统每天只在末尾增加文章、主要进行查询和遍历,ArrayList 是否合理?
  5. 如果系统大量从列表头部不断删除元素,还应不应该无脑使用 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 的底层原理还没有真正掌握。