深蓝黑背景封面:右侧大标题「ArrayList 和 LinkedList,别只背查询快插入快」,副标题「底层结构决定性能特征」;左侧原创对比示意——上方一排连续方格表示数组,下方一串用箭头相连的圆角节点表示双向链表;左上标注「面试经典 · Java 后端」,右上标注「Day 02 · 10-20」 图 1:本文封面,连续方格对指针链——结构差异决定一切。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「ArrayList 和 LinkedList 有什么区别」是 Java 集合面试的开胃题,标准答案人人会背:数组查询快、链表插入快。但这句话只说对了一半,而且是最容易误导人的一半。

想象面试现场:「ArrayList 和 LinkedList 怎么选?什么场景用哪个?」

第一步,从底层结构说起。

ArrayList 底层是一块连续的可扩容数组:元素挨着排,按下标访问就是基地址加偏移,一步到位;容量不够时换一块更大的数组整体搬过去,所以尾部追加是「摊还常数时间」——JDK 官方文档的原话,add 操作摊还 O(1),而 get、set、size 是严格的常数时间。LinkedList 底层是双向链表:每个节点是堆上独立的对象,带着数据和前后两根指针;想访问第 N 个元素,只能从离它最近的一头沿线走过去——官方文档也写明了,带下标的操作会从开头或结尾遍历,挑近的一边。

第二步,把「插入快」这句半对的话拆开。

LinkedList 的插入确实只改两根指针,O(1)。但请注意:这是「已经站在目标位置」的代价。真实场景里的「在中间插入」包含两步——先走到那个位置(O(n)),再插入(O(1))。链表省掉的只是搬移元素的那一步,找位置的一步它照样要走,而且走得比数组慢得多。ArrayList 在中间插入要把后续元素整体右移,同样是 O(n),但数组的搬移是连续内存的整块拷贝,常数因子极小。两边真正的分野只剩头部插入:链表改一根指针,数组要把全部元素挪一遍。

还有一个常被忽略的维度:内存。链表的每个节点除了数据还要存两根指针,外加对象头的开销,同样存一百万个元素,链表的内存占用是数组的数倍;更要命的是缓存局部性——数组元素在内存里连续,CPU 预取一拿一批;链表节点散落各处,每次跟随指针都可能是一次缓存未命中。现代硬件上,这个差异常比大 O 记号更决定真实速度。

第三步,用实验把直觉钉死。

下面用 Node.js 22.15.0 复现「连续内存 vs 指针链」的差异(Java 里也是同一套物理原理):二十万个元素的顺序求和、两千次随机访问、三万次头部插入,数组与链表正面打擂。直接运行(node array-vs-linked.js)即可:

'use strict';

const N = 200000;

const arr = Array.from({ length: N }, (_, i) => i);

let head = null;
for (let i = N - 1; i >= 0; i--) head = { val: i, next: head }; // 与数组同序的链表

function time(label, fn) {
  fn(); // 预热
  const t0 = process.hrtime.bigint();
  fn();
  const ms = Number(process.hrtime.bigint() - t0) / 1e6;
  console.log(`${label}: ${ms.toFixed(1)} ms`);
}

time('数组顺序求和', () => {
  let s = 0;
  for (let i = 0; i < arr.length; i++) s += arr[i];
  return s;
});
time('链表顺序求和', () => {
  let s = 0;
  for (let p = head; p; p = p.next) s += p.val;
  return s;
});

time('数组随机访问 2000 次', () => {
  let s = 0;
  for (let k = 0; k < 2000; k++) s += arr[Math.floor(Math.random() * N)];
  return s;
});
time('链表随机访问 2000 次', () => {
  let s = 0;
  for (let k = 0; k < 2000; k++) {
    const idx = Math.floor(Math.random() * N);
    let p = head;
    for (let i = 0; i < idx; i++) p = p.next;
    s += p.val;
  }
  return s;
});

time('数组头部插入 30000 次', () => {
  const a = [];
  for (let i = 0; i < 30000; i++) a.unshift(i);
  return a.length;
});
time('链表头部插入 30000 次', () => {
  let h = null;
  for (let i = 0; i < 30000; i++) h = { val: i, next: h };
  return h.val;
});

笔者机器上的实测输出(毫秒数随机器波动,量级稳定):

数组顺序求和: 0.1 ms
链表顺序求和: 0.3 ms
数组随机访问 2000 次: 0.1 ms
链表随机访问 2000 次: 195.2 ms
数组头部插入 30000 次: 48.9 ms
链表头部插入 30000 次: 0.2 ms

三组数字各讲一个道理:顺序遍历同为 O(n),链表仍慢三倍,指针跳跃的常数因子摆在那里;随机访问差了三个数量级,O(1) 对 O(n) 的正面碾压;头部插入则是链表唯一的保留项目,200 多倍的差距同样真实。

结构对比加结论图:上方左侧 ArrayList 底层是连续数组,随机访问按下标一步到位、尾部追加快,但中间插入要搬移后续元素;上方右侧 LinkedList 底层是双向链表,访问第 N 个要沿线走,头部插入只需改两根指针。中部性能对照四行——随机访问数组 O(1) 对链表 O(n)、头部插入数组 O(n) 对链表 O(1)、内存占用数组紧凑对链表每节点多两个指针、缓存局部性数组好对链表差。下方结论:链表赢面只剩「高频头部插入且几乎不随机访问」的窄场景,工程默认选数组 图 2:结构差异、四维对照与最终结论。

选型结论与易错点。

工程默认选 ArrayList:随机访问、遍历、尾部追加全是它的主场,内存还省。LinkedList 的赢面只剩一个窄场景——高频头部插入且几乎不随机访问;而即便这个场景,Java 里通常也有更优解:ArrayDeque,数组实现的循环队列,头尾操作都是 O(1),缓存还友好。JDK 文档对 ArrayList 的评价可以当结论背下来:它的常数因子比 LinkedList 低。

易错点有三:背「插入快」却忘了找位置本身是 O(n);忽略每个节点两根指针加对象头的内存膨胀;以及把「理论复杂度」当成全部,无视缓存局部性这种硬件层因素。这套思维在 AI 工程里同样通用:特征列表、批次缓冲区的容器选型是数据侧日常,向量计算和批处理拼的也是顺序访问的局部性。回到面试现场,建议先讲结构、再拆「插入快」的半对之处、补上内存与缓存两个维度,最后给出「默认数组、头部插入用队列」的选型口诀。你在项目里真的用对过一次 LinkedList 吗?是什么场景?欢迎聊聊。