ArrayList 全过程原理
ArrayList 是 Java 最常用的 List 实现。它看起来只是“动态数组”,但真正写生产代码时,要理解数组为什么查询快、扩容为什么慢、插入删除为什么移动元素、modCount 为什么能 fail-fast、subList 为什么容易踩坑、Arrays.asList 为什么不能 add,以及大批量数据如何预估容量。
学习目标
| 目标 | 要能说清楚 |
|---|---|
| 底层结构 | elementData 数组和 size 分别表示什么 |
| add 流程 | 容量够直接写,不够扩容复制 |
| get/set 流程 | 为什么按下标访问快,越界怎么判断 |
| remove 流程 | 为什么中间删除要移动元素 |
| 扩容原理 | JDK 8 常见 1.5 倍扩容和数组拷贝 |
| fail-fast | modCount 如何发现并发修改 |
| 常见坑 | subList、Arrays.asList、边遍历边删除 |
| 商业场景 | 批量导入、分页查询、临时结果集怎么用 |
| 面试闭环 | 能把原理、复杂度、坑点和优化讲清楚 |
ArrayList 底层是什么
ArrayList 底层核心是一个 Object 数组:
transient Object[] elementData;
private int size;可以理解为:
flowchart TD
A["ArrayList"] --> B["elementData数组"]
A --> C["size=3"]
B --> D["0: A"]
B --> E["1: B"]
B --> F["2: C"]
B --> G["3: null 可用容量"]| 字段 | 含义 |
|---|---|
elementData.length | 当前数组容量 |
size | 实际元素个数 |
| 空余位置 | 已分配但还没使用的数组空间 |
容量和元素个数不是一回事。容量是数组能装多少,size 是当前已经装了多少。
get 为什么快
String value = list.get(3);ArrayList 可以直接通过下标定位数组位置:
flowchart TD
A["get(index)"] --> B{"index 是否越界"}
B -->|是| C["抛 IndexOutOfBoundsException"]
B -->|否| D["返回 elementData[index]"]数组在内存里是连续结构,JVM 可以根据数组起始位置和下标快速定位元素,所以 get 通常是 O(1)。
add 到尾部流程
flowchart TD
A["add(element)"] --> B{"size + 1 是否超过容量"}
B -->|否| C["elementData[size] = element"]
C --> D["size++"]
B -->|是| E["grow 扩容"]
E --> F["复制旧数组到新数组"]
F --> C容量够时,尾部追加非常快。
容量不够时,必须创建更大的新数组并复制旧元素,这就是扩容成本。
扩容为什么是性能点
JDK 8 中常见扩容策略可以理解为:
newCapacity = oldCapacity + oldCapacity / 2也就是约 1.5 倍扩容。
flowchart TD
A["旧数组容量 10"] --> B["容量不够"]
B --> C["创建新数组容量 15"]
C --> D["System.arraycopy 复制旧元素"]
D --> E["elementData 指向新数组"]为什么不是每次只加 1?
如果每次只加 1,连续 add 10000 次就可能复制 9999 次,成本极高。
为什么不是每次翻 10 倍?
扩太大会浪费大量内存。1.5 倍是时间和空间之间的折中。
指定初始容量
如果预计要放 10 万条数据:
List<String> rows = new ArrayList<>(100000);这样能减少扩容和数组复制。
错误写法:
List<String> rows = new ArrayList<>();
for (String row : source) {
rows.add(row);
}如果数据量很大,默认容量会多次扩容。批量导入、报表导出、一次性查询结果转换时,应该尽量预估容量。
中间插入为什么慢
list.add(1, "X");需要把 index 后面的元素整体后移:
flowchart TD
A["add(index, element)"] --> B{"index 是否越界"}
B -->|是| C["抛异常"]
B -->|否| D["确保容量足够"]
D --> E["index 后元素整体右移"]
E --> F["写入新元素"]
F --> G["size++"]示例:
原数组: [A, B, C, D]
add(1, X)
移动后: [A, _, B, C, D]
写入后: [A, X, B, C, D]所以 ArrayList 适合尾部追加和按下标读取,不适合大量头部或中间插入。
删除为什么慢
list.remove(1);删除中间元素后,后面的元素要左移:
flowchart TD
A["remove(index)"] --> B{"index 是否越界"}
B -->|是| C["抛异常"]
B -->|否| D["保存旧元素"]
D --> E["index 后元素整体左移"]
E --> F["最后一个位置置 null"]
F --> G["size--"]
G --> H["返回旧元素"]最后一个位置置 null 很重要,它可以让不再使用的对象尽快被 GC 回收。
遍历时删除怎么写
错误写法:
for (String item : list) {
if (item.startsWith("tmp")) {
list.remove(item);
}
}这可能抛 ConcurrentModificationException。增强 for 背后使用 Iterator,遍历期间直接修改 list 会让 modCount 不一致。
正确写法一:
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String item = iterator.next();
if (item.startsWith("tmp")) {
iterator.remove();
}
}正确写法二:
list.removeIf(item -> item.startsWith("tmp"));fail-fast 原理
ArrayList 有一个 modCount,表示结构性修改次数。Iterator 创建时会记录 expectedModCount。
flowchart TD
A["创建 Iterator"] --> B["记录 expectedModCount"]
B --> C["遍历 next"]
C --> D{"expectedModCount 是否等于 modCount"}
D -->|是| E["继续遍历"]
D -->|否| F["抛 ConcurrentModificationException"]结构性修改包括 add、remove、clear 等会改变列表结构的操作。set(index, value) 只是替换元素,不改变 size,通常不是结构性修改。
注意:fail-fast 是尽力而为的错误检测机制,不是线程安全机制。不能依赖它保证并发正确。
subList 的坑
List<String> list = new ArrayList<>(List.of("A", "B", "C", "D"));
List<String> sub = list.subList(1, 3);sub 不是独立新 ArrayList,而是原 list 的视图。
flowchart TD
A["原 ArrayList"] --> B["elementData"]
C["subList"] --> B
C --> D["记录 offset 和 size"]风险:
| 操作 | 风险 |
|---|---|
| 修改 subList | 会影响原 list |
| 修改原 list 结构 | subList 后续操作可能抛 ConcurrentModificationException |
| 把 subList 长期缓存 | 可能间接引用原大数组,导致内存无法释放 |
如果需要独立列表:
List<String> copy = new ArrayList<>(list.subList(1, 3));Arrays.asList 的坑
List<String> list = Arrays.asList("A", "B", "C");
list.add("D"); // 抛 UnsupportedOperationException原因:Arrays.asList 返回的是 java.util.Arrays 的内部固定长度 List,不是真正的 java.util.ArrayList。它底层仍然包着原数组,不能改变长度。
如果需要可增删:
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
list.add("D");ArrayList 线程安全吗
ArrayList 不是线程安全的。多线程同时 add 可能出现:
| 问题 | 原因 |
|---|---|
| size 不准 | size++ 不是原子操作 |
| 元素覆盖 | 多线程写同一个下标 |
| 扩容异常 | 多线程同时扩容和复制 |
| 读到 null | size 更新和元素写入顺序没有同步保障 |
多线程场景可选:
| 场景 | 方案 |
|---|---|
| 写少读多 | CopyOnWriteArrayList |
| 需要外部同步 | Collections.synchronizedList |
| 高并发队列 | BlockingQueue |
| 批量结果汇总 | 每个线程本地 List,最后合并 |
ArrayList 和 LinkedList 怎么选
| 对比 | ArrayList | LinkedList |
|---|---|---|
| 底层 | 数组 | 双向链表 |
| 随机访问 | 快,O(1) | 慢,O(n) |
| 尾部追加 | 通常快 | 快 |
| 中间插入删除 | 定位后还要移动元素 | 定位慢,但节点操作快 |
| 内存占用 | 相对低 | 每个节点有前后指针,开销大 |
| 实际常用度 | 更常用 | 较少用于普通业务 List |
不要简单背“LinkedList 插入删除快”。如果要先按下标找到位置,LinkedList 定位成本很高。普通业务列表优先 ArrayList。
商业场景:批量导入
import java.util.ArrayList;
import java.util.List;
public class ImportBuffer {
public List<String> loadRows(List<String> source) {
List<String> rows = new ArrayList<>(source.size());
for (String row : source) {
if (row != null && !row.isBlank()) {
rows.add(row);
}
}
return rows;
}
}为什么指定 source.size()?
源数据量已知,提前分配容量,避免 add 过程中多次扩容复制。
商业场景:分页结果转换
import java.util.ArrayList;
import java.util.List;
record UserEntity(Long id, String name) {
}
record UserVO(Long id, String displayName) {
}
public class PageConvert {
public List<UserVO> convert(List<UserEntity> entities) {
List<UserVO> result = new ArrayList<>(entities.size());
for (UserEntity entity : entities) {
result.add(new UserVO(entity.id(), entity.name()));
}
return result;
}
}分页转换是 ArrayList 很适合的场景:数据量有限、顺序遍历、尾部追加、最终返回。
常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 大批量 add 不设容量 | 多次扩容复制 | 预估容量 |
| 增强 for 中 remove | ConcurrentModificationException | 用 Iterator.remove 或 removeIf |
| 误以为 subList 是新 List | 修改互相影响或异常 | new ArrayList<>(subList) |
| Arrays.asList 后 add | UnsupportedOperationException | 包一层 new ArrayList |
| 多线程共享写 ArrayList | 数据丢失、异常 | 使用线程安全容器或隔离合并 |
| remove 后不理解置 null | 以为只是 size-- | 置 null 是为了帮助 GC |
面试标准回答
ArrayList 底层是什么
ArrayList 底层是 Object 数组,elementData 表示数组容量,size 表示实际元素个数。按下标查询时可以直接访问数组位置,所以随机访问通常是 O(1)。
ArrayList 扩容过程
add 时如果容量够,就写入 elementData[size] 并让 size 加一;如果容量不够,就创建更大的新数组,JDK 8 常见是约 1.5 倍扩容,然后用数组拷贝把旧元素复制过去,再写入新元素。扩容涉及新数组分配和元素复制,所以大批量添加应预估容量。
ArrayList 删除为什么慢
删除中间元素后,后面的元素必须整体左移,最后一个位置还要置 null 方便 GC 回收不再使用的对象。因此 ArrayList 适合随机访问和尾部追加,不适合大量头部或中间插入删除。
fail-fast 是什么
ArrayList 的 Iterator 创建时会记录 expectedModCount,遍历过程中如果发现 expectedModCount 和 List 的 modCount 不一致,就抛 ConcurrentModificationException。它是尽力而为的并发修改检测,不是线程安全机制。
ArrayList 和 LinkedList 怎么选
普通业务优先 ArrayList,因为随机访问快、内存开销小、CPU 缓存友好。LinkedList 虽然节点插入删除本身快,但通常还要先遍历定位节点,而且每个节点有额外指针开销,所以并不适合大多数普通列表场景。
关联知识点
| 知识点 | 为什么要看 |
|---|---|
| 集合框架 | 集合体系总览 |
| 数组 | ArrayList 底层依赖数组和数组拷贝 |
| HashMap全过程 | 对比数组列表和哈希表的不同设计 |
| BlockingQueue全过程 | 多线程队列场景不要用普通 ArrayList |
| JDK版本差异 | 了解集合在不同 JDK 中的演进 |
