JAVA · Vol.I · DAY 06 · 集合框架源码
ArrayList vs LinkedList:从源码看使用场景
一道看似简单的面试题
面试官问:"ArrayList 和 LinkedList 有什么区别?分别适合什么场景?"
大多数人的回答是:"ArrayList 查询快,LinkedList 增删快。"——这个回答在十年前可能是对的,但在今天的 JDK 实现和现代 CPU 架构下,后半句几乎是错的。
这篇文章不靠记忆、不靠直觉,我们用源码 + 数据 + 内存模型来回答这个问题。读完之后你会发现,面试中最有区分度的答案往往不是背出来的,而是理解出来的。
整篇文章的路线图如下:内部结构(数组 vs 链表)→ 扩容与搬移(grow / System.arraycopy)→ 增删实现(link / unlink)→ 性能实测 → CPU 缓存与内存开销 → 生产避坑 → 面试追问链 → 选择指南。我们先把两个容器的"骨架"看清楚。
ArrayList:连续内存 + 下标直达
ArrayList 的内部结构非常朴素——就是一个 Object[] 数组。所有的元素按插入顺序连续存放在内存中。注意区分两个概念:size(已存元素个数)和 elementData.length(数组容量),前者永远小于等于后者:
// ArrayList.java(JDK 8+)· 核心字段
transient Object[] elementData; // 底层数组:连续内存(transient 见第 13 站)
private int size; // 当前元素个数(≠ 数组长度)
private static final int DEFAULT_CAPACITY = 10;
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; // 懒加载:首次 add 才建 10 容量
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; // 数组长度上限
// 随机访问 —— O(1)
public E get(int index) {
rangeCheck(index); // 越界检查
return elementData(index); // 直接下标取值
}
private void rangeCheck(int index) {
if (index >= size) // 只查上界;负数会由数组访问抛 AIOOBE
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
// 下标访问的内部方法,无任何边界检查
E elementData(int index) {
return (E) elementData[index];
}
get(index) 的实现只有一行——直接数组下标访问。这意味着不管列表有 10 个元素还是 100 万个元素,get(999999) 的速度和 get(0) 完全一样。
数组的地址计算是确定性的:元素地址 = 数组基址 + index × 引用大小(4 或 8 字节)。CPU 做一次乘法加法,然后一次内存访问就拿到数据——与列表长度无关。这就是"下标直达"的物理本质。
RandomAccess 是一个标记接口(没有任何方法),它告诉 Collections 工具类和 Stream API:"这个 List 支持快速随机访问"。例如 Collections.binarySearch() 遇到 RandomAccess 时走 indexedBinarySearch(每次 get(index) 都是 O(1),总复杂度 O(log n));否则退化为 iteratorBinarySearch(LinkedList 上每次移动迭代器都是 O(n),二分直接失效)。功能相同,性能天差地别。
ArrayList 扩容:1.5 倍与均摊 O(1)
数组一旦创建长度就固定了,那 ArrayList 是怎么做到"无限追加"的?答案在 grow()——装不下就换一个更大的数组,把旧元素整体拷贝过去:
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 容量不足时触发扩容
elementData[size++] = e; // 追加到末尾
return true;
}
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // ★ 1.5 倍:右移一位再相加
if (newCapacity - minCapacity < 0) // 一次 addAll 加太多时,直接扩到刚好够
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0) // 触及上限走 hugeCapacity
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity); // 新数组 + 整体拷贝
}
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // int 溢出为负数 → 真的装不下了
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
}
边界条件与两个隐藏细节
- 首次 add 扩容到 10:无参构造时
elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA(空数组,不占堆),第一次 add 才把minCapacity提升到max(10, size+1)——懒加载,空列表零开销。 - MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8:预留 8 个字节给数组对象头,避免部分 JVM 上"能分配却访问越界"的边界问题;再往上就只剩
Integer.MAX_VALUE一条路。 - hugeCapacity 的 OOM:当
minCapacity溢出为负数时直接抛OutOfMemoryError——这是"真要 20 亿个元素"时才会触发的极端边界。
- 扩容公式:
newCapacity = oldCapacity + (oldCapacity >> 1),即 1.5 倍(向下取整) - 扩容 = 新数组 +
Arrays.copyOf整体拷贝,单次 O(n),均摊 O(1) - 对比:HashMap 容量恒为 2 的幂(用
hash & (n-1)取模),扩容 2 倍;ArrayList 用 1.5 倍更省内存——两者目标不同 - 生产实践:已知规模时用
new ArrayList<>(n)或ensureCapacity(n)预分配,避免扩容震荡
一次性往列表里灌 10 万条订单记录时,如果不预分配,ArrayList 会经历约 23 次扩容、累计拷贝近 3n 个引用——虽然均摊下来仍是 O(1),但每次扩容都伴随一次内存分配与 GC 压力。类似数据库批量 INSERT 前先 BATCH_SIZE 规划,提前 new ArrayList<>(batchSize) 是最便宜的优化。
中间插入与删除:System.arraycopy 搬移
ArrayList 的"增删代价"全部集中在下标中间位置:为了保持数组连续,插入/删除后,后半段元素必须整体搬移一个位置。核心就是 System.arraycopy:
public void add(int index, E element) {
rangeCheckForAdd(index); // 要求 0 ≤ index ≤ size
ensureCapacityInternal(size + 1); // 可能触发扩容
System.arraycopy(elementData, index,
elementData, index + 1, // 目标位置右移一位
size - index); // 搬移后半段全部元素
elementData[index] = element;
size++;
}
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index + 1, elementData, index, numMoved);
elementData[--size] = null; // ★ 尾部置 null:切断引用,帮助 GC
return oldValue;
}
复杂度与两个容易忽略的点
- 平均 O(n/2):插入到中间,平均要搬移
size - index个元素;删除同理。头部插入/删除是 O(n),尾部追加/删除是 O(1)(追加含均摊扩容)。 - remove 尾部置 null:
elementData[--size] = null——如果不置 null,尾部"逻辑已删除"的引用会一直强引用着对象,阻碍 GC。这是 ArrayList 源码里非常经典的"帮助 GC"手法。 - arraycopy 是 native 方法:同一数组内搬移时,JIT 会把它编译成内存块的
memmove(正确处理重叠区间),再配合 SIMD 指令一次搬多个引用。这也是后面"中间插入实测反而更快"的伏笔。
LinkedList:双向链表 + 节点跳转
LinkedList 的底层是一个双向链表——每个节点保存了前驱 prev、后继 next 和元素值 item,链表自身只持有头尾两个指针和一个计数器:
transient int size = 0;
transient Node<E> first; // 头节点
transient Node<E> last; // 尾节点
private static class Node<E> {
E item; // 元素本身
Node<E> next; // 后继
Node<E> prev; // 前驱
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
注意这里的结构差异带来的第一层结论:LinkedList 没有"下标"概念,first/last 只让头尾两个位置 O(1) 可达,其余节点只能靠指针"跳"过去。
每个 Node 都是一个独立的堆对象,自带 12~16 字节对象头,还持有 3 个引用。GC 扫描、对象晋升、TLAB 分配都会因"节点多 + 散落"而变慢。这也是 LinkedList 在 add 时比 ArrayList 慢的原因之一:ArrayList 追加只写一个数组槽位,LinkedList 要 new 一个 Node 对象。
LinkedList 的随机访问:node() 从头/尾遍历
要访问第 i 个元素,LinkedList 必须从头(或尾)开始,逐个节点跳转。JDK 的优化是"从距离更近的一端出发",即经典的双向链表折半遍历:
Node<E> node(int index) {
// 优化:从距离更近的一端开始遍历
if (index < (size >> 1)) { // index 在前半段 → 从头走
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next; // 从头部跳到 index
return x;
} else { // index 在后半段 → 从尾走
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev; // 从尾部跳到 index
return x;
}
}
get(int index) 和 set(int index, E) 内部都走 node(index),因此:
- get(i) / set(i):O(n)——即使 JDK 做了"从更近的一端开始"的优化,访问中间元素仍然需要 O(n/2) 次指针跳转,在大列表下是很可观的开销。
- getFirst() / getLast():O(1)——直接返回
first/last,不需要遍历。 - size():O(1)——维护在
size字段里,不是遍历统计。
RandomAccess 标记接口存在的意义:让算法知道"能不能放心按下标访问"。for 循环里 x = x.next 看起来只是一行赋值,实际每次都要去内存里读下一个 Node 的地址——而下一个 Node 可能在任何地方。每跳一次,CPU 都面临一次缓存未命中等待。这也是"算法上 O(n),实测上更慢"的放大器。
LinkedList 的增删:link/unlink 与 O(1) 的前提
链表真正的"O(1) 增删"发生在拿到目标节点引用之后——只改相邻指针,不搬数据:
// 头尾操作:O(1)
private void linkFirst(E e) {
final Node<E> f = first;
final Node<E> newNode = new Node<>(null, e, f);
first = newNode;
if (f == null) last = newNode; // 原链表为空
else f.prev = newNode;
size++; modCount++;
}
// linkLast 对称,略
// 在 succ 节点之前插入:只改 4 个指针
void linkBefore(E e, Node<E> succ) {
final Node<E> pred = succ.prev;
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null) first = newNode; // succ 是头节点
else pred.next = newNode;
size++; modCount++;
}
// 摘除节点:同样只改相邻指针
E unlink(Node<E> x) {
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) first = next;
else { prev.next = next; x.prev = null; }
if (next == null) last = prev;
else { next.prev = prev; x.next = null; }
x.item = null; // 帮助 GC
size--; modCount++;
return element;
}
// 但按 API 删除:先 O(n) 找节点,再 O(1) 摘除
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index)); // ★ node(index) 才是真正的成本
}
O(1) 增删的前提:持有节点引用
LinkedList 的 add(index, e) 完整流程是 checkPositionIndex →(index == size ? linkLast : linkBefore(e, node(index)))。注意 node(index) 的 O(n) 遍历已经包含在这个 API 里了。真正能拿到"免费 O(1)"的只有两种途径:
| 调用方式 | 复杂度 | 说明 |
|---|---|---|
addFirst / addLast / removeFirst / removeLast | O(1) | 头尾指针直达,不遍历 |
add(index, e) / remove(index) / remove(o) | O(n) | 先 node(index) 遍历找位置,再 link/unlink |
ListIterator.add / remove(在迭代位置操作) | O(1) | 迭代器"随身携带"当前节点引用,无需重新查找 —— LinkedList 独有的中间操作优势 |
经典 LRU 实现(LinkedHashMap)的核心正是"HashMap 找节点 + 链表 O(1) 摘除/前移"——因为已经通过 key 拿到了节点引用,才享受 O(1)。如果你只能拿到下标或对象值,LinkedList 的 O(1) 优势就荡然无存。这是理解 LinkedList 的钥匙:它的快,是"站在正确位置"的快。
打破误区:中间插入谁更快?
很多人认为 LinkedList 插入是 O(1),但这有个前提:你已经拿到了要插入位置的节点引用。我们先把两种结构的复杂度摊开对比:
| 操作 | ArrayList | LinkedList | 胜出方 |
|---|---|---|---|
| get(i) 随机访问 | O(1) 下标直达 | O(n) 折半遍历 | ArrayList 碾压 |
| add(e) 尾部追加 | O(1) 均摊 | O(1)(但要 new Node) | 持平,实测 ArrayList 略快 |
| addFirst / addLast | O(n)(arraycopy 搬移) | O(1) | LinkedList 胜 |
| add(index, e) 中间插入 | O(n) 搬移(连续内存 memmove) | O(n) 遍历 + O(1) 链接 | 实测 ArrayList 更快 |
| remove(index) 中间删除 | O(n) 搬移 | O(n) 遍历 + O(1) 摘除 | 实测 ArrayList 更快 |
| contains / indexOf | O(n) 线性扫描 | O(n) 线性扫描 | 持平(缓存差异让 ArrayList 略快) |
实际调用 list.add(index, element) 时,LinkedList 需要先调用 node(index) 遍历到目标位置(O(n)),然后才是 O(1) 的节点链接操作。遍历找位置的开销远大于插入本身。
而 ArrayList 的 add(index, element) 虽然需要 System.arraycopy 搬运后半段数据,但这个操作是连续内存的 memcpy——CPU 可以用 SIMD 指令批量搬运,速度极快。在 10 万规模下,arraycopy 的耗时甚至低于 LinkedList 的遍历。
那 LinkedList 在什么场景下真的快?——头尾操作:addFirst()、addLast()、removeFirst()、removeLast() 都是 O(1)。但这些操作用 ArrayDeque 做更快(连续内存 + 环形缓冲区),后面会说。
性能实测:数据说话
我们用 JMH 做了一个标准化基准测试,列表大小为 100,000,每个操作重复 1000 次取平均值(该数据来自社区经典 JMH 基准,绝对数值随 JDK 版本与机器浮动,但趋势稳定可复现):
| 操作 | ArrayList | LinkedList | 差距 |
|---|---|---|---|
| add(element) 末尾追加 | ~2 ns | ~4 ns | ArrayList 快 2× |
| get(index) 随机访问 | ~1 ns | ~25,000 ns | ArrayList 快 25,000× |
| add(0, element) 头部插入 | ~40,000 ns | ~3 ns | LinkedList 快 13,000× |
| add(size/2, element) 中间插入 | ~20,000 ns | ~25,000 ns | ArrayList 反而更快! |
| remove(index) 中间删除 | ~20,000 ns | ~25,000 ns | ArrayList 反而更快! |
| contains(element) | ~25,000 ns | ~50,000 ns | ArrayList 快 2× |
| 遍历(for-each) | ~80 μs | ~350 μs | ArrayList 快 4× |
注意看中间插入那一行——这是最大的认知误区:ArrayList 唯一的"输面"是头部插入(40,000 ns vs 3 ns),因为整个数组要右移一位;而一旦到中间位置,arraycopy 的 memmove 优势就开始显现。
① 随机访问差 25,000 倍:不是 2 倍、不是 10 倍,是 4 个数量级。任何"按下标取元素"的算法(排序、二分、随机采样)在 LinkedList 上都是灾难。
② 遍历差 4 倍:同样的 for-each,复杂度都是 O(n),差距全来自 CPU 缓存命中率(下一站展开)。
③ 中间插入/删除都是 ArrayList 赢:memmove 的常数太小,LinkedList 的遍历常数太大。
CPU 缓存:ArrayList 的隐藏优势
性能差异的根源不仅是算法复杂度,还有一个常被忽略的因素:CPU 缓存命中率。
现代 CPU 的 L1 缓存每次加载不是 1 个字节,而是一整个 Cache Line(64 字节)。ArrayList 的引用数组是连续的,所以一次缓存加载可以装下 8~16 个元素引用(压缩指针下每个引用 4 字节,未压缩 8 字节),CPU 预取器还能提前加载下一个 Cache Line。
LinkedList 的节点散落在堆内存的不同位置,每次跳转到新节点都可能触发一次 Cache Miss,需要从更慢的 L2/L3 缓存甚至主存中读取数据。这就是为什么 LinkedList 遍历 10 万个元素比 ArrayList 慢 4 倍——不是算法差了 4 倍,而是内存访问模式差了 4 倍。
CPU 性能优化的两条铁律:时间局部性(刚访问过的数据很快会再访问)和空间局部性(刚访问过的数据附近的数据很快会被访问)。ArrayList 完美契合空间局部性:遍历时下一个元素就在缓存里。LinkedList 两者都不沾——每个节点都是"远方来客"。这也是为什么现代数据结构(ArrayDeque、ArrayList、HashMap 的数组桶)几乎全部基于连续数组。
内存开销:LinkedList 的隐形成本
每个元素的实际内存占用对比(64 位 JVM)。关键前提是指针压缩:JVM 默认在堆小于 32 GB 时开启 UseCompressedOops,对象引用只占 4 字节:
| 指针模式 | ArrayList 每个元素(容器开销) | LinkedList 每个元素(Node 开销) | 倍数 |
|---|---|---|---|
| 压缩指针(默认,堆 < 32G) | 1 个引用 = 4 字节 | 对象头 12B + 3 引用 × 4B = 24 字节 | 6× |
| 未压缩(-XX:-UseCompressedOops) | 1 个引用 = 8 字节 | 对象头 16B + 3 引用 × 8B = 40 字节 | 5× |
也就是说,LinkedList 的额外开销是每个元素 24~40 字节的 Node 结构(12~16 字节对象头 + 3 个引用,恰好 8 字节对齐)。对于 100 万个 Integer 元素(元素对象本身两者相同,只比"容器开销"):
- ArrayList 额外开销:100 万 × 4 字节(压缩引用)= ~4 MB;未压缩则 ~8 MB
- LinkedList 额外开销:100 万 × 24 字节(Node)= ~24 MB;未压缩则 ~40 MB
- 差值:多浪费 ~20 MB(压缩)到 ~32 MB(未压缩)——全部花在"链接"上,不存储任何业务数据
无论哪种模式,LinkedList 的容器开销都是 ArrayList 的 5~6 倍,而且这些 Node 对象还额外增加 GC 扫描与对象晋升的压力。
别再凭记忆估算对象大小,用 OpenJDK JOL(org.openjdk.jol)一行命令即可实测:jol-core 的 ClassLayout.parseClass(Node.class).toPrintable() 会输出对象头、字段偏移与对齐后的总大小。面试/博客中引用内存数字时,注明"压缩指针开启/关闭"才严谨。
生产实践:避坑清单与最佳实践
面试之外,后端日常开发里这两兄弟埋了不少雷。下面是高频踩坑点与正确姿势:
坑 1:for-each 中删除 → ConcurrentModificationException
// ✗ 错误:for-each 中直接 remove,迭代器检测到 modCount 变化 → 抛 CME
for (String s : list) {
if (s.startsWith("x")) list.remove(s);
}
// ✓ 正确 1:迭代器 remove(同步 modCount)
Iterator<String> it = list.iterator();
while (it.hasNext()) {
if (it.next().startsWith("x")) it.remove();
}
// ✓ 正确 2:removeIf(JDK 8+,ArrayList 底层用 BitSet 标记 + 一次批量搬移,性能最好)
list.removeIf(s -> s.startsWith("x"));
// ✗ 另一个坑:for-i 正序删除会跳过相邻元素(删除后下标错位)
for (int i = 0; i < list.size(); i++) {
if (list.get(i).startsWith("x")) list.remove(i); // 相邻元素被跳过
}
// ✓ 倒序遍历即可
机制解释:ArrayList / LinkedList 都维护一个 modCount(结构性修改计数器)。迭代器创建时记录期望值,每次 next()/remove() 校验,不一致就抛 ConcurrentModificationException——这就是fail-fast(快速失败)机制。注意:它只保证"尽力检测",多线程下并不保证一定抛出,所以不能依赖它做并发控制。
坑 2:subList 是视图,不是拷贝
List<String> sub = list.subList(1, 3); // 视图:共享底层数组,不是新列表
sub.set(0, "X"); // 会影响原 list(视图的修改是"原地"的)
list.add("new"); // ✗ 原 list 结构性修改后,再碰 sub → CME
// 需要独立副本时:new ArrayList<>(list.subList(1, 3))
坑 3:toArray 强转陷阱
String[] arr = (String[]) list.toArray(); // ✗ ClassCastException:擦除后运行时是 Object[]
String[] arr2 = list.toArray(new String[0]); // ✓ JDK 11+ 对空数组有专门优化,推荐
String[] arr3 = list.toArray(new String[list.size()]); // ✓ 但会先分配再被覆盖
坑 4:Arrays.asList 与 List.of 的"假 List"
Arrays.asList(a, b):返回固定长度的数组视图,add/remove抛UnsupportedOperationException,但set可以。List.of(...)(JDK 9+):完全不可变,连set都禁止;且不允许 null 元素。- 拿它们当普通 List 往方法里传,是最常见的生产事故来源。
最佳实践清单
| 场景 | 做法 | 理由 |
|---|---|---|
| 已知规模批量加载 | new ArrayList<>(n) / ensureCapacity(n) | 避免约 23 次扩容拷贝与 GC 压力 |
| 大量按条件删除 | removeIf | ArrayList 内部 BitSet 标记 + 单次搬移,比逐个 remove 快一个量级 |
| 读多写少并发 | CopyOnWriteArrayList | 写时复制,读无锁;ArrayList 本身线程不安全 |
| 简单并发包装 | Collections.synchronizedList | 方法级同步,注意复合操作仍要手动加锁 |
| 队列 / 双端队列 / 栈 | ArrayDeque | 环形数组,头尾 O(1),无节点开销;LinkedList 能做的它都能更快做(除 null 元素) |
| 大列表内存敏感 | trimToSize() | 把容量收缩到 size,释放空位;但会触发一次拷贝,别频繁调用 |
分页接口的 records 就是 ArrayList;消息队列的 FIFO 缓冲用 ArrayDeque 而不是 LinkedList;Excel 导出的百万行先用 ensureCapacity 预分配;定时任务里"遍历再删除"如果直接 for-each remove,线上立刻 CME——同样的坑,不同的项目,年年都在踩。
面试追问链:从背诵到理解
面试官不会满足于"查询快、增删快",真正的区分度在追问里。下面是完整的追问链与参考答案:
扩容虽然单次 O(n),但容量按比例增长后,扩容频率是"越来越稀疏"的(1/n 级别)。把 n 次 add 的总成本(约 3n 次引用拷贝)摊到每次上,平均成本是常数——这就是均摊 O(1)。和 HashMap 的扩容、StringBuilder 的扩容是同一个道理。
数组的物理本质是"一块连续内存 + 一个基址"。elementData[index] 在 JVM 里就是一次乘加运算 + 一次访存,与列表长度无关;而链表只有"跳到下一个节点"这一种移动方式。
ArrayList 实现了 writeObject/readObject,序列化时只写 [0, size) 区间,不序列化容量里的空位。声明 transient 是为了绕过默认序列化(否则会把整个 elementData 数组连同 null 空位一起序列化),这是节省网络/磁盘的经典手法。LinkedList 的 Node 也是 transient,序列化时只按节点链写出 item。
每次结构性修改(增删)都 modCount++;迭代器记录创建时的值,操作前校验,不一致即抛 ConcurrentModificationException。LinkedList、HashMap 等集合都有同样的机制。注意它不是并发控制,只是"尽力检测"。
真正合理的场景只有两类:① 持有节点引用时的 O(1) 插入/删除(如手写 LRU,或通过 ListIterator 边遍历边操作);② 大量头尾操作(addFirst/addLast)。而 ② 用 ArrayDeque 更好。所以结论是:工程上 LinkedList 几乎没有不可替代的场景——Joshua Bloch(集合框架作者)也说过"LinkedList 几乎总是错误的选择"。
HashMap 容量恒为 2 的幂,是为了用 hash & (n-1) 位运算替代取模,扩容 2 倍正好翻倍;ArrayList 没有位运算需求,1.5 倍在"扩容次数"与"内存浪费"之间更均衡,且 1.5 倍允许旧数组被新数组"吞掉"(2 倍则永远做不到)。
分层评分:同样的问题,三种答案
| 层级 | 典型回答 | 面试官判断 |
|---|---|---|
| 背结论 | "ArrayList 查询快,LinkedList 增删快" | 及格线以下,大概率追问即挂 |
| 懂结构 | "数组 vs 双向链表,get 是 O(1) vs O(n),add(index, e) 都要 O(n)" | 中上,基础扎实 |
| 懂原理 | "中间插入实测 ArrayList 更快:arraycopy 是 memmove + 缓存局部性;LinkedList 要先 node(index) 遍历;只有持有节点引用才 O(1);头尾操作用 ArrayDeque" | 高分,有源码功底 |
| 有工程判断 | 补充 ensureCapacity 预分配、fail-fast / removeIf、subList 视图、toArray 强转、CopyOnWriteArrayList 等生产细节 | 顶格,能直接干活 |
什么时候用 LinkedList?几乎不用
综合以上分析,给出一个明确的选择指南:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 通用列表(95% 场景) | ArrayList | 随机访问 O(1),缓存友好,内存紧凑 |
| 需要队列行为(FIFO) | ArrayDeque | 环形数组实现,头尾操作 O(1),缓存友好 |
| 需要双端队列 | ArrayDeque | 比 LinkedList 的 addFirst/addLast 更快 |
| 需要频繁中间插入/删除 | ArrayList + 预分配 | arraycopy 比链表遍历快(见实测数据) |
| 需要 O(1) 的已知节点删除 | LinkedList(罕见) | 前提是你已经持有 Node 引用(通过 ListIterator) |
| 需要排序的唯一集合 | TreeSet | 比 LinkedList 手动维护排序高效得多 |
"I more or less invented the Java Collections Framework, and I can tell you that LinkedList is almost always the wrong choice. The only reasonable use case I know of is when you're implementing a queue, and even then ArrayDeque is usually better."
面试标准回答模板
1. 数据结构:ArrayList 是 Object[] 连续数组(elementData),LinkedList 是双向链表(Node:prev/item/next)
2. 随机访问:ArrayList O(1)(下标直达),LinkedList O(n)(node() 从头/尾折半遍历)
3. 增删操作:尾部追加两者都是 O(1)(ArrayList 均摊);头部操作 LinkedList O(1) 但 ArrayDeque 更优;中间插入 ArrayList 的 System.arraycopy 在实际测试中反而更快(连续内存 memcpy + CPU 缓存友好),LinkedList 要先 O(n) 遍历找位置
4. 内存开销:LinkedList 每个元素多 24~40 字节的 Node 结构(取决于指针压缩),容器开销是 ArrayList 的 5~6 倍
5. 生产细节:默认 ArrayList + ensureCapacity 预分配;遍历删除用迭代器/removeIf;队列用 ArrayDeque
6. 结论:绝大多数场景选 ArrayList,需要队列行为选 ArrayDeque,LinkedList 几乎没有用武之地
这一篇你掌握了什么
核心知识点回顾
- ArrayList:Object[] elementData 连续数组,get(index) 下标直达 O(1),CPU 缓存友好,95% 场景的首选
- 扩容机制:grow() 按 1.5 倍扩容(oldCapacity + (oldCapacity >> 1)),单次 O(n)、均摊 O(1),上限 MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8
- 中间增删:add(index, e) / remove(index) 用 System.arraycopy 搬移后半段,平均 O(n/2),连续内存 memmove 常数极小
- LinkedList:双向链表 Node(prev/item/next),节点散落堆内存,get 必须 node() 从头/尾遍历 O(n),缓存命中率低
- O(1) 的前提:只有持有节点引用(ListIterator)或头尾操作才 O(1);API add(index, e) 本身是 O(n)
- 最大误区:"LinkedList 中间插入快"——实测数据证明 ArrayList 的 arraycopy 更快
- CPU 缓存:连续内存 → Cache Line 预取 → 快;散落内存 → Cache Miss → 慢(遍历慢 4 倍的根源)
- 内存开销:LinkedList 每个元素多 24~40 字节 Node(指针压缩相关),容器开销是 ArrayList 的 5~6 倍
- 生产避坑:fail-fast(CME)、subList 视图、toArray 强转、Arrays.asList/List.of、ensureCapacity 预分配、遍历删除用 removeIf、队列/栈用 ArrayDeque
- 选择建议:通用选 ArrayList,队列选 ArrayDeque,LinkedList 几乎没有用武之地
👉 下一篇:ArrayList 扩容机制与性能调优
Comments · 评论