结论先行

绝大多数业务场景优先使用 ArrayList。它的随机访问是 O(1),连续内存带来更好的 CPU 缓存局部性。LinkedList 只有在已经持有节点位置并频繁在该位置插入、删除时才可能占优。

ArrayList 的结构与扩容

ArrayList 底层是动态数组。添加元素超过容量后会创建更大的数组并复制旧元素。常见 JDK 实现按原容量约 1.5 倍扩容,但不应把具体倍数当成 Java API 契约。

List<Integer> values = new ArrayList<>(10_000);
for (int i = 0; i < 10_000; i++) values.add(i);

已知数据规模时设置初始容量,可以减少扩容和数组复制。size 是元素数量,capacity 是内部数组容量,两者不是一回事。

LinkedList 的结构

LinkedList 是双向链表,每个节点保存元素以及前驱、后继引用。访问第 n 个元素需要从头或尾逐个移动,因此随机访问为 O(n)。此外,每个节点都是额外对象,内存开销和 GC 压力通常更大。

操作复杂度

操作 ArrayList LinkedList
按下标读取 O(1) O(n)
尾部追加 均摊 O(1) O(1)
头部插入 O(n) O(1)
按下标插入 查找 O(1) + 移动 O(n) 查找 O(n) + 链接 O(1)
迭代 缓存友好 需要追逐节点引用

“LinkedList 插入一定快”并不准确。若先调用 get(index)add(index, value) 定位节点,查找本身仍是 O(n)。

删除元素的陷阱

List<Integer> 同时存在 remove(int index)remove(Object value)。传入基本类型会删除指定下标,想按值删除需要显式装箱:

list.remove(Integer.valueOf(1));

遍历时直接调用集合的 remove 可能触发 ConcurrentModificationException,应使用迭代器的 remove,或使用 removeIf

并发与 fail-fast

二者都不是线程安全集合。迭代期间检测到结构性修改时通常会快速失败,但 fail-fast 只是尽力检测的错误提示机制,不能当作并发安全保证。读多写少可评估 CopyOnWriteArrayList,高并发写入则应重新考虑数据结构和同步策略。

选择建议

  • 普通查询、批量遍历、尾部追加:选择 ArrayList
  • 队列或双端队列:通常选择 ArrayDeque,而不是 LinkedList
  • 大量头部操作:优先评估 ArrayDeque
  • 只有确实需要链表节点操作,并通过基准测试证明收益时,才选择 LinkedList

核心考点清单

  • ArrayList 随机访问 O(1),尾部添加均摊 O(1),中间插入需要移动元素。
  • LinkedList 按下标访问 O(n),节点对象带来额外内存和较差缓存局部性。
  • “链表插入 O(1)”仅在已经持有节点位置时成立。
  • 双端队列场景通常优先 ArrayDeque,而不是 LinkedList。

高频追问与参考回答

追问:ArrayList 扩容为什么是均摊 O(1)?

单次扩容要复制 O(n) 个元素,但容量按比例增长,连续多次追加的总复制成本可摊到每次操作上。

追问:CopyOnWriteArrayList 适合什么场景?

适合读远多于写、集合较小且允许读取短暂快照的场景;每次写都会复制数组,不适合频繁写入或大集合。

机制全景图

下面把「ArrayList 与 LinkedList 应该怎么选?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。

flowchart LR
    A["接收访问模式"]
    A --> B["选择连续数组或链节点"]
    B --> C["定位目标元素"]
    C --> D["执行读写或插入"]
    D --> E["扩容回收并返回"]

完整链路:从输入到结果

沿着「接收访问模式 → 选择连续数组或链节点 → 定位目标元素 → 执行读写或插入 → 扩容回收并返回」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。

1. 接收访问模式

先确认主要操作是随机访问、尾部追加还是中间插入,不能只依据 Big-O 口号选择。

2. 选择连续数组或链节点

ArrayList 用连续引用数组获得局部性,LinkedList 为每个元素额外保存前后节点指针。

3. 定位目标元素

数组可按下标 O(1) 定位,链表必须从头尾遍历;现代 CPU 缓存使实际差距通常比复杂度表更明显。

4. 执行读写或插入

ArrayList 中间插入要移动元素,LinkedList 在已持有节点时改指针很快,但通过下标找节点仍是 O(n)。

5. 扩容回收并返回

ArrayList 扩容会复制数组并短暂占用两份空间,链表则持续承担节点对象与 GC 成本。

源码与实现定位

入口 阅读重点
java.util.ArrayList grow、fastRemove 与数组移动
java.util.LinkedList Node、node(index) 从头尾查找

源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。

参数配置与可复现实验

var list = new ArrayList<Order>(100_000);
for (Order order : source) list.add(order);

用 JMH 分别测试随机 get、中间插入、尾部追加和增强 for;数据量固定 1k/100k/1m,配合 JFR 记录 Node 分配。

验证步骤与预期结果

1. 固定输入和基线

先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「10 万次随机 get」为主基线,记录值应满足「ArrayList 应明显更快」;同时保存 集合尺寸分布、扩容与数组复制次数,使后续变化能够回到同一时间轴比较。

2. 从实现入口确认路径

在「java.util.ArrayList」确认请求确实进入「grow、fastRemove 与数组移动」对应的实现,再沿「java.util.LinkedList」观察「Node、node(index) 从头尾查找」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。

3. 注入本文特有的失败模式

优先复现「在 LinkedList 上按索引循环」,并把单一变量逐级放大,直到「10 万次随机 get」越过「LinkedList 呈 O(n²)」。随后再分别验证「ArrayList 未预估容量导致反复扩容」和「迭代时结构修改引发并发修改异常」,三类故障分开执行,避免多个变量同时变化而无法归因。

4. 执行止损和根因修复

第一轮只应用「把索引循环改为迭代器」,确认它能控制影响范围;第二轮应用「普通队列改用 ArrayDeque」,验证核心链路恢复;最后落实「批量装载前按可知规模预分配」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。

5. 通过退出条件

实验只有同时满足三项才算通过:「10 万次随机 get」回到「ArrayList 应明显更快」、「每元素额外对象」回到「ArrayList 仅引用槽」、「扩容次数」回到「预估容量后接近 0」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。

量化基线

指标 样例基线/口径 风险线 结论
10 万次随机 get ArrayList 应明显更快 LinkedList 呈 O(n²) 禁止按索引遍历链表
每元素额外对象 ArrayList 仅引用槽 LinkedList Node 数=元素数 评估堆与 GC
扩容次数 预估容量后接近 0 多次复制大数组 传入合理 initialCapacity

这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。

事故复盘:十万条分页结果处理变慢

代码认为链表插入快,把数据库结果装入 LinkedList 后反复 get(i) 遍历,实际退化为 O(n²)。改为增强 for 顺序遍历已经改善,但由于主要是追加与扫描,最终使用预估容量的 ArrayList 获得更低内存和更好的缓存局部性。

失败模式 首要证据 第一处置动作
在 LinkedList 上按索引循环 集合尺寸分布 把索引循环改为迭代器
ArrayList 未预估容量导致反复扩容 扩容与数组复制次数 普通队列改用 ArrayDeque
迭代时结构修改引发并发修改异常 对象分配率 批量装载前按可知规模预分配

发布与回滚检查点

  • 发布前:确认「java.util.ArrayList」对应实现和上述配置在目标版本仍然有效,并保存「10 万次随机 get」基线。
  • 灰度中:同时观察 集合尺寸分布、扩容与数组复制次数、对象分配率;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「把索引循环改为迭代器」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「在 LinkedList 上按索引循环」没有再次出现,才关闭变更观察窗口。

方案对比与选型

方案 更适合的场景 主要收益 代价与边界
ArrayList 随机访问、尾部追加和批量遍历 内存紧凑、CPU 缓存友好 中间移动与扩容复制
LinkedList 频繁操作两端且直接持有迭代位置 双端增删语义直接 节点开销高、随机访问慢
ArrayDeque 队列、栈和双端操作 连续数组、性能稳定 不支持 null,按下标访问不是主要能力

选型至少带上 元素数量、读写比例、遍历方式、并发度和内存预算,并用上面的量化基线验证;未知数据应明确为待测假设。

设计边界与工程取舍

复杂度是上界模型,不包含缓存局部性、对象头和 GC;除非确有链表语义,ArrayList 或 ArrayDeque 通常是更稳妥的默认选择。

工程落地遵循:先保证数据结构语义正确,再依据访问模式选择实现。回答时直接引用「java.util.ArrayList」、配置实验和事故数据,比复述固定模板更有说服力。