Java ArrayList 详解:从动态数组到扩容机制与常见陷阱
Java ArrayList 详解:从动态数组到扩容机制与常见陷阱
前言
在 Java 开发中,ArrayList 是最常用的集合类之一。查询结果、商品列表、评论列表、菜单选项等业务数据,经常都会使用它来保存。
普通数组就像一排固定数量的座位:创建时有多少位置,后续就只能使用多少位置。ArrayList 则像一节可以不断扩建的车厢:底层仍然使用数组,但空间不足时,会自动创建更大的数组,并把原有元素搬过去。
本文从基础使用开始,逐步讲解 ArrayList 的底层结构、扩容机制、增删改查原理、遍历删除陷阱、线程安全问题以及常见使用误区。
本文源码分析主要以 JDK 8 为基础。较新的 JDK 对部分内部方法进行了重构,但 ArrayList 的核心思想并没有改变:
1 | 底层是数组 |
一、认识 ArrayList
1.1 ArrayList 是什么
ArrayList<E> 是 List<E> 接口的一个实现类,底层通过数组保存元素。
1 | List<String> names = new ArrayList<>(); |
这段代码中:
1 | List<String> names = new ArrayList<>(); |
可以拆解为:
| 内容 | 说明 |
|---|---|
List |
列表接口,表示一组有顺序的数据 |
ArrayList |
基于数组实现的列表类 |
String |
集合中存放的元素类型 |
<> |
泛型,用于限制元素类型 |
例如:
1 | List<String> names = new ArrayList<>(); |
输出结果:
1 | [张三, 李四, 王五] |
1.2 ArrayList 的主要特点
ArrayList 具有以下特点:
| 特点 | 说明 |
|---|---|
| 有序 | 元素按照添加顺序保存 |
| 可重复 | 可以保存相同元素 |
允许 null |
可以添加空值 |
| 支持下标访问 | 可以通过 get(index) 获取指定位置元素 |
| 自动扩容 | 底层数组空间不足时会自动扩大 |
| 非线程安全 | 多线程同时修改时需要额外处理 |
示例:
1 | List<String> list = new ArrayList<>(); |
输出结果:
1 | [Java, Java, null] |
1.3 ArrayList 与普通数组的区别
普通数组创建后,长度不能改变:
1 | String[] names = new String[3]; |
而 ArrayList 可以继续添加元素:
1 | List<String> names = new ArrayList<>(); |
输出结果:
1 | [张三, 李四, 王五, 赵六] |
可以这样理解:
- 普通数组:固定座位的大巴车,座位满了就不能继续上人。
ArrayList:可以增加车厢的列车,座位满了就扩建空间。
1.4 ArrayList 的适用场景
ArrayList 适合以下业务场景:
| 场景 | 原因 |
|---|---|
| 保存查询结果 | 通常按照顺序遍历和展示 |
| 保存商品列表 | 读取和遍历操作较多 |
| 保存文章评论 | 需要按顺序显示 |
| 保存下拉框选项 | 数据量较小,读取方便 |
| 保存接口返回数据 | 便于遍历、过滤和转换 |
例如:
1 | List<String> courses = new ArrayList<>(); |
如果业务中经常需要在列表头部插入或删除大量数据,ArrayList 就不一定合适,因为它需要频繁移动后续元素。
二、创建方式与底层结构
2.1 ArrayList 的创建方式
创建 ArrayList 时,通常推荐使用接口接收实现类:
1 | List<String> names = new ArrayList<>(); |
而不是:
1 | ArrayList<String> names = new ArrayList<>(); |
这样写的好处是:后续如果需要更换集合实现,业务代码修改更少。
1 | List<String> names = new ArrayList<>(); |
ArrayList 常见的创建方式有三种。
1. 无参构造
1 | List<String> list = new ArrayList<>(); |
适合暂时无法确定元素数量的场景。
2. 指定初始容量
1 | List<String> list = new ArrayList<>(100); |
适合提前知道大概数据量的场景。
例如,预计需要保存 1000 条商品数据:
1 | List<String> products = new ArrayList<>(1000); |
提前指定容量,可以减少扩容和数组复制的次数。
3. 根据已有集合创建
1 | List<String> oldList = Arrays.asList("Java", "MySQL", "Redis"); |
输出结果:
1 | [Java, MySQL, Redis, Spring Boot] |
2.2 ArrayList 的核心成员变量
以 JDK 8 的源码思想为例,ArrayList 中最重要的成员变量可以简化理解为:
1 | public class ArrayList<E> { |
其中最重要的是两个变量:
1 | Object[] elementData; |
例如:
1 | List<String> list = new ArrayList<>(); |
底层可以理解为:
1 | elementData = ["Java", "MySQL", "Redis", null, null, null, null, null, null, null] |
2.3 size 与 capacity 的区别
size 和 capacity 是两个非常容易混淆的概念。
| 概念 | 含义 |
|---|---|
size |
当前实际保存了多少个元素 |
capacity |
底层数组目前最多能够容纳多少个元素 |
例如:
1 | 底层数组容量 capacity = 10 |
可以把它理解为一间教室:
- 教室一共有 10 个座位:capacity = 10
- 当前坐了 3 名学生:size = 3
2.4 为什么底层是 Object 数组
ArrayList 可以保存不同类型的数据:
1 | List<String> names = new ArrayList<>(); |
但是在底层,ArrayList 使用的是:
1 | Object[] elementData; |
泛型主要在编译阶段负责限制元素类型。
1 | List<String> list = new ArrayList<>(); |
2.5 ArrayList 保存的是对象引用
假设有一个用户对象:
1 | User user = new User("张三"); |
输出结果:
1 | 李四 |
原因是,ArrayList 保存的是对象引用,而不是对象副本。
1 | user ───────┐ |
变量 user 与 users.get(0) 指向的是同一个对象,因此修改对象属性后,从列表中读取到的内容也会发生变化。
三、添加元素与扩容机制
3.1 add(E e) 添加元素
最常见的添加方式如下:
1 | List<String> list = new ArrayList<>(); |
添加元素的核心流程可以概括为:
1 | 调用 add(element) |
可以简化理解为:
1 | elementData[size] = element; |
当然,在真正写入元素之前,ArrayList 会先检查容量是否足够。
3.2 无参构造与懒初始化
执行下面代码时:
1 | List<String> list = new ArrayList<>(); |
列表虽然已经创建,但底层数组此时通常还是空数组。
1 | 刚创建时: |
当第一次添加元素时:
1 | list.add("Java"); |
底层数组才真正扩展为默认容量 10。
1 | 第一次添加后: |
数组状态可以理解为:
1 | ["Java", null, null, null, null, null, null, null, null, null] |
这是一种懒初始化思想:
1 | 仓库刚注册时,不急着立即摆满货架; |
3.3 第 11 个元素为什么会触发扩容
假设使用无参构造创建列表,并不断添加元素:
1 | List<Integer> list = new ArrayList<>(); |
容量变化过程可以理解为:
1 | 添加第 1 个元素:容量扩展为 10 |
以 JDK 8 的源码为例,扩容核心计算方式如下:
1 | int newCapacity = oldCapacity + (oldCapacity >> 1); |
其中:
1 | oldCapacity >> 1 |
对于正整数容量,可以简单理解为:
1 | oldCapacity / 2 |
因此:
1 | newCapacity = oldCapacity + oldCapacity / 2; |
当旧容量为 10 时:
1 | newCapacity = 10 + 10 / 2 = 15; |
容量变化大致如下:
1 | 0 → 10 → 15 → 22 → 33 → 49 ... |
3.4 为什么不能每次只扩大一个位置
假设每添加一个元素,只增加一个数组位置:
1 | 添加第 1 个元素:创建长度为 1 的数组 |
这样会导致频繁创建新数组和复制旧数据,性能很差。
按比例扩容则能够减少复制次数:
1 | 原容量不足 |
这是一种时间与空间之间的平衡:
| 扩容方式 | 优点 | 缺点 |
|---|---|---|
| 每次只增加一个位置 | 空间浪费少 | 扩容和复制过于频繁 |
| 一次扩大很多空间 | 添加效率高 | 可能浪费较多空间 |
| 按比例扩容 | 时间与空间较均衡 | 会预留部分空闲位置 |
3.5 Arrays.copyOf() 的作用
数组本身不能直接改变长度。
当旧数组空间不足时,ArrayList 会创建一个更大的新数组,然后将旧元素复制过去。
可以通过下面的代码理解:
1 | Integer[] oldArray = {1, 2, 3}; |
输出结果:
1 | [1, 2, 3, null, null] |
ArrayList 扩容的本质可以简化为:
1 | elementData = Arrays.copyOf(elementData, newCapacity); |
可以把这个过程理解为搬仓库:
1 | 旧仓库已经装满 |
3.6 ensureCapacity() 与 trimToSize()
如果已经知道大概需要保存多少数据,可以提前预留容量:
1 | ArrayList<String> list = new ArrayList<>(); |
这种方式适合:
- 批量读取数据库数据;
- 解析大量文件内容;
- 批量组装接口返回结果;
- 构建大量临时对象列表。
如果列表曾经保存过大量数据,后来删除了很多元素,底层数组容量不会自动缩小。
1 | ArrayList<String> list = new ArrayList<>(1000); |
调用 trimToSize() 后,底层数组容量会缩减到当前 size。
不过,不建议频繁调用该方法。因为如果后续又继续添加元素,列表可能马上再次扩容。
四、增删改查与元素移动
4.1 get() 与 set():下标访问为什么快
根据下标读取元素:
1 | List<String> list = new ArrayList<>(); |
输出结果:
1 | MySQL |
底层可以直接根据下标访问数组:
1 | elementData[index] |
因此,get(index) 的时间复杂度通常为:
1 | O(1) |
可以把数组理解为带编号的储物柜:
1 | 0 号柜:Java |
想读取 1 号柜的数据,直接找到对应柜子即可,不需要从头开始寻找。
修改元素同样简单:
1 | List<String> list = new ArrayList<>(); |
输出结果:
1 | 旧值:MySQL |
set() 的核心逻辑可以理解为:
1 | E oldValue = elementData[index]; |
需要注意:
1 | set() 只是替换元素,不会改变列表长度。 |
4.2 indexOf()、lastIndexOf() 与 contains()
根据元素内容查找位置:
1 | List<String> list = new ArrayList<>(); |
输出结果:
1 | 0 |
几个方法的作用如下:
| 方法 | 作用 |
|---|---|
indexOf(value) |
从前往后查找第一次出现的位置 |
lastIndexOf(value) |
从后往前查找最后一次出现的位置 |
contains(value) |
判断列表中是否存在指定元素 |
虽然 get(index) 很快,但 contains() 和 indexOf() 通常需要从头比较元素。
例如:
1 | 查找 Redis: |
因此,根据值查找元素的时间复杂度通常为:
1 | O(n) |
4.3 add(index, element):指定位置插入元素
除了在列表末尾添加元素,还可以在指定位置插入元素:
1 | List<String> list = new ArrayList<>(); |
输出结果:
1 | [Java, MySQL, Redis] |
执行过程如下:
1 | 插入前: |
底层移动元素时,会使用类似下面的数组复制操作:
1 | System.arraycopy( |
其含义是:
1 | 从插入位置开始, |
因此:
| 操作位置 | 移动成本 |
|---|---|
| 尾部添加 | 通常不需要移动元素 |
| 中间插入 | 需要移动一部分元素 |
| 头部插入 | 可能需要移动全部已有元素 |
如果列表中有 10000 个元素:
1 | list.add(0, "Java"); |
可能需要将原有 10000 个元素全部向后移动。
4.4 remove():删除元素为什么可能较慢
根据下标删除元素:
1 | List<String> list = new ArrayList<>(); |
输出结果:
1 | 删除元素:MySQL |
删除过程可以理解为:
1 | 删除前: |
删除完成后,将末尾无效位置设置为 null,可以解除集合对原对象的引用,便于垃圾回收。
根据值删除元素:
1 | List<String> list = new ArrayList<>(); |
输出结果:
1 | true |
需要注意:
1 | remove(Object o) 只会删除第一次出现的目标元素。 |
根据值删除通常包含两个过程:
1 | 先查找目标元素 |
因此,时间复杂度通常为:
1 | O(n) |
4.5 Integer 列表中的 remove() 陷阱
这是使用 ArrayList<Integer> 时非常常见的错误。
1 | List<Integer> numbers = new ArrayList<>(); |
输出结果:
1 | [10, 30] |
这里删除的不是数字 1,而是:
1 | 下标为 1 的元素,也就是 20。 |
原因是 ArrayList 中存在两个重载方法:
1 | remove(int index) |
当传入普通整数时:
1 | numbers.remove(1); |
Java 会优先将其理解为:
1 | 删除下标为 1 的元素 |
如果希望删除数值 20,应该写成:
1 | numbers.remove(Integer.valueOf(20)); |
完整示例:
1 | List<Integer> numbers = new ArrayList<>(); |
输出结果:
1 | [10, 30] |
五、遍历、排序与安全删除
5.1 ArrayList 的常见遍历方式
1. 普通 for 循环
1 | List<String> list = Arrays.asList("Java", "MySQL", "Redis"); |
适合需要使用下标的场景。
2. 增强 for 循环
1 | List<String> list = Arrays.asList("Java", "MySQL", "Redis"); |
适合只读取元素、不需要操作下标的场景。
3. Iterator 迭代器
1 | List<String> list = new ArrayList<>(); |
适合遍历过程中需要安全删除元素的场景。
5.2 遍历过程中直接删除的问题
下面代码可能会抛出 ConcurrentModificationException:
1 | List<String> list = new ArrayList<>(); |
增强 for 循环底层实际依赖迭代器。
可以把迭代器理解为一个拿着名单点名的人:
1 | 迭代器已经拿到原始名单 |
这种机制称为:
1 | fail-fast:快速失败机制 |
它的作用不是保证线程安全,而是尽快发现遍历过程中不合理的结构性修改。
5.3 正确删除方式
1. 使用 Iterator.remove()
1 | List<String> list = new ArrayList<>(); |
输出结果:
1 | [Java, Redis] |
2. 使用 removeIf()
1 | List<String> list = new ArrayList<>(); |
输出结果:
1 | [Java, Redis] |
当删除规则较简单时,removeIf() 更加简洁。
5.4 排序与二分查找
对数字列表进行排序:
1 | List<Integer> numbers = new ArrayList<>(); |
输出结果:
1 | [10, 20, 30] |
对于自定义对象,可以根据指定字段排序:
1 | class User { |
输出结果:
1 | [李四-18, 张三-22, 王五-25] |
当列表已经有序时,可以使用二分查找:
1 | List<Integer> numbers = new ArrayList<>(); |
输出结果:
1 | 2 |
需要注意:
1 | 二分查找的前提是:列表必须已经按照相同规则排序。 |
错误示例:
1 | List<Integer> numbers = Arrays.asList(30, 10, 20); |
由于列表没有排序,查找结果没有可靠意义。
六、常见陷阱与使用选择
6.1 Arrays.asList() 不能直接增删元素
下面代码会抛出异常:
1 | List<String> list = Arrays.asList("Java", "MySQL", "Redis"); |
原因是:
1 | Arrays.asList() 返回的是基于原数组的固定长度列表, |
下面的代码是可以执行的:
1 | List<String> list = Arrays.asList("Java", "MySQL", "Redis"); |
输出结果:
1 | [Spring Boot, MySQL, Redis] |
如果希望继续进行添加和删除操作,可以重新包装成 ArrayList:
1 | List<String> list = new ArrayList<>( |
输出结果:
1 | [Java, MySQL, Redis, Spring Boot] |
6.2 subList() 返回的是视图
subList() 返回的不是完全独立的新列表,而是原列表的一段视图。
1 | List<String> list = new ArrayList<>(); |
输出结果:
1 | [Java, MongoDB, Redis, Spring Boot] |
原因是:
1 | subList 与原列表共享对应范围的数据。 |
如果希望得到独立副本,应该写成:
1 | List<String> copy = new ArrayList<>(list.subList(1, 3)); |
6.3 clone() 只是浅拷贝
ArrayList 的 clone() 会复制列表容器,但不会复制其中的对象。
1 | ArrayList<User> users = new ArrayList<>(); |
输出结果:
1 | 李四 |
可以理解为:
1 | 原列表和复制后的列表是两个盒子, |
6.4 ArrayList 不是线程安全集合
1 | List<Integer> list = new ArrayList<>(); |
如果多个线程同时修改同一个 ArrayList,可能导致数据错误。
简单场景下,可以使用同步包装:
1 | List<String> list = Collections.synchronizedList(new ArrayList<>()); |
遍历时仍然需要手动同步:
1 | synchronized (list) { |
如果业务属于读多写少的场景,例如监听器列表、配置快照列表,可以考虑:
1 | List<String> listeners = new CopyOnWriteArrayList<>(); |
不过,CopyOnWriteArrayList 每次修改都可能复制数组,因此不适合高频写入场景。
6.5 ArrayList 与 LinkedList 的选择
ArrayList 与 LinkedList 的简单对比如下:
| 对比项 | ArrayList | LinkedList |
|---|---|---|
| 底层结构 | 动态数组 | 双向链表 |
| 根据下标查询 | 快 | 较慢 |
| 尾部添加 | 快 | 快 |
| 中间插入删除 | 需要移动元素 | 找到节点后修改链接 |
| 内存占用 | 相对较小 | 需要保存前后节点引用 |
| 普通业务使用频率 | 更高 | 特殊场景使用 |
大多数普通业务场景,可以优先使用:
1 | List<String> list = new ArrayList<>(); |
例如:
- 查询结果列表;
- 商品集合;
- 页面展示数据;
- 用户权限集合;
- 配置项列表;
- 临时计算结果。
需要注意的是,中间插入和删除虽然从结构上看链表更方便,但 LinkedList 在定位节点时也需要遍历。实际开发中,不应仅凭“插入删除多”就直接选择 LinkedList,而应该结合数据量、访问方式和性能测试进行判断。
如果业务需求是频繁从头部添加或删除元素,例如队列操作:
1 | addLast() |
通常更适合考虑:
1 | Deque<String> queue = new ArrayDeque<>(); |
6.6 常见操作时间复杂度
| 操作 | 示例 | 时间复杂度 | 原因 |
|---|---|---|---|
| 根据下标查询 | get(index) |
O(1) |
数组可以直接定位 |
| 根据下标修改 | set(index, value) |
O(1) |
直接覆盖对应位置 |
| 尾部添加 | add(value) |
摊还 O(1) |
大多数时候直接写入,偶尔扩容 |
| 指定位置插入 | add(index, value) |
O(n) |
需要移动后续元素 |
| 删除末尾元素 | remove(size - 1) |
O(1) |
通常不需要移动 |
| 删除中间元素 | remove(index) |
O(n) |
后续元素需要前移 |
| 根据值删除 | remove(value) |
O(n) |
先查找,再移动 |
| 根据值查找 | contains() / indexOf() |
O(n) |
需要逐个比较 |
| 排序 | sort() |
通常 O(n log n) |
依赖排序算法 |
| 二分查找 | binarySearch() |
O(log n) |
前提是列表已经有序 |
尾部添加的复杂度为什么叫做“摊还 O(1)”?
因为并不是每一次添加都一定只执行一步:
- 前 10 次添加:大多数情况下直接写入数组
- 第 11 次添加:容量不足,需要扩容和复制
- 扩容之后:后续若干次添加又可以直接写入
因此,将大量添加操作整体平均后,尾部添加的平均成本仍然很低。
七、完整测试代码
7.1 测试内容说明
下面的测试代码用于验证以下知识点:
| 测试方法 | 验证内容 |
|---|---|
testBasicOperations() |
基本增删改查 |
testNullAndDuplicate() |
重复元素和 null |
testCapacityGrowth() |
默认容量与扩容过程 |
testEnsureCapacityAndTrim() |
容量预留与压缩 |
testInsertAndRemove() |
中间插入与删除 |
testIntegerRemovePitfall() |
Integer 删除陷阱 |
testSearchSortAndBinarySearch() |
查找、排序、二分查找 |
testIteratorRemove() |
迭代器安全删除 |
testFailFast() |
快速失败机制 |
testSubListView() |
subList() 视图特点 |
testObjectReference() |
对象引用特点 |
7.2 完整可运行代码
1 | import java.lang.reflect.Field; |
总结
ArrayList 的本质,是一个基于数组实现的动态列表。
它保留了数组按下标访问速度快的优势,同时通过自动扩容解决了普通数组长度固定的问题。
学习 ArrayList 时,需要重点掌握以下内容:
| 核心知识点 | 结论 |
|---|---|
| 底层结构 | 基于 Object[] 动态数组 |
| 元素特点 | 有序、可重复、允许 null |
| 容量概念 | size 表示元素数量,capacity 表示底层容量 |
| 扩容机制 | 容量不足时创建更大的数组并复制旧元素 |
| 查询修改 | get()、set() 通常为 O(1) |
| 尾部添加 | 摊还时间复杂度为 O(1) |
| 中间插入删除 | 需要移动元素,通常为 O(n) |
| 遍历删除 | 使用 Iterator.remove() 或 removeIf() |
| 线程安全 | ArrayList 本身不是线程安全集合 |
| 常见陷阱 | remove(Integer)、Arrays.asList()、subList() |
在实际开发中,如果业务主要是顺序保存、频繁读取、尾部添加,通常可以优先考虑 ArrayList。而当业务需要频繁进行队列式的头尾操作时,可以进一步考虑 ArrayDeque 等更合适的数据结构。