刚开始学 Java 集合框架、或者准备数据结构面试的时候,几乎每个人都会撞上同一道题:LinkedList 与 ArrayList 有什么区别。这道题表面是考察两个类的用法,实际上是在看你对“线性表”的两种底层实现——数组和链表——到底有没有真正理解。如果面试只听到“一个基于数组,一个基于链表,所以数组查询快、链表增删快”,基本可以判断对方没有读过相关源码,也没有认真想过容量、内存和 CPU 缓存这些工程因素。这篇就把这道题讲透,顺便把面试官大概率会追问的源码细节和边界场景也一起覆盖。
1. 不背答案:这题在问三件事,复杂度只是第一层
1.1 面试官想从回答里听出的三个信号
很多人觉得这是一道知识背诵题,背完“数组查快、链表增删快”就去面试了,结果在追问环节被戳穿。实际上,面试官问这题想确认三件事。
第一件是“你真的知道底层结构长什么样吗”。ArrayList 不是“什么东西能按索引拿”,它背后是一段连续内存上的 Object 数组;LinkedList 也不是“慢一点的列表”,它是由一个个节点对象通过指针串起来的双向链表。能不能画出节点结构、说出扩容策略,代表你是不是真的接触过实现。
第二件是“你会不会评估操作成本”。实际开发中很少有人在十万条数据里高频调用 get(index),更多是遍历、尾部追加、头部插入、按条件删除。针对每一种操作,两个类的真实代价完全不同,这里不能只背一个 O(1) 和 O(n) 的结论。
第三件是“你会不会基于场景选型”。代码里每引入一个 List 实现,背后都是一次资源权衡:随机访问、内存占用、遍历顺序、是否兼作队列、是否能放 null。能结合业务说清取舍的人,才算是把这个知识点内化成了工程能力。
1.2 先把两者的操作复杂度对照表记住
动手分析之前,先给一张速查表。后续每一节都会回到这张表做解释。
| 操作 | ArrayList | LinkedList |
|---|---|---|
| get(int index) | O(1),数组下标直接定位 | O(n),需要从头或尾二分方向逐个扫描 |
| add(E e) 尾插 | 均摊 O(1),容量不够时触发扩容搬移 | O(1),新节点直接链到 last |
| addFirst / addLast | 只有 add(int, E) 可用,头部插入 O(n) | O(1),原生支持 |
| add(int index, E e) | O(n),后续元素整体后移一位 | O(n),先扫描定位到 index,再 O(1) 改指针 |
| remove(int index) | O(n),后续元素整体前移一位 | O(n),定位 O(n) + 摘除 O(1) |
| removeFirst / removeLast | 不支持,手动 remove(0) 是 O(n) | O(1) |
| 迭代器遍历 | O(n),且连续内存友好 | O(n),但缓存命中率更差 |
| 迭代器 remove | 每次删除后仍要搬移尾部元素 | O(1),游标直接持有当前节点 |
| 内存占用 | 较低,底层只有一个引用数组 | 较高,每个元素额外背负两个指针和对象头 |
| 是否实现 Deque/Queue | 否 | 是 |
| 是否实现 RandomAccess | 是 | 否 |
| 允许 null | 允许 | 允许 |
这张表基本就是标准答案的骨架。不过你如果直接背出来,面试官只会觉得你背过;得能解释为什么,整段回答才立得住。
1.3 一个可以直接套用的“面试口述框架”
我建议按“底层结构 — 关键操作成本 — 扩容/内存代价 — 额外接口能力 — 选型结论”五层来答,顺序不能乱。
先讲底层:ArrayList 基于 Object[],LinkedList 基于 Node 节点构成的双向链表。再各举一个最有代表性的操作,比如 ArrayList 的扩容会触发数组复制,LinkedList 的 get 需要折半扫描。然后提内存:ArrayList 一段连续数组引用,LinkedList 每个节点包装了 item、next、prev 和对象头。接着补功能:LinkedList 额外实现了 Queue 和 Deque,可以当栈/队列用,而 ArrayList 主要强调支持随机访问。最后给结论:默认优先 ArrayList,只有在明确需要频繁头部插入删除、或者要把列表当作双端队列的时候,才考虑 LinkedList。
这个顺序的好处是体现了“数据结构—操作—内存—接口—场景”的完整链路,而不是把 HashMap、排序、线程安全这些杂质混进来,说完反而丢分。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. ArrayList 是数组,所以“连续内存”推导出它的一切表现
2.1 默认构造并不是立刻创建容量 10 的数组
很多网课说“new ArrayList<>() 默认创建容量 10 的数组”,这个说法在 JDK 8 之后已经不准确了。看 JDK 源码就能发现,无参构造只是把 elementData 指向一个共享的空数组:
java复制private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
第一次调用 add(E e) 的时候,才根据最小容量和默认容量 10 做判断,把底层数组拉开:
java复制private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
换句话说,你 new ArrayList<>() 出来的是一个“零成本承载器”,真正分配 10 个槽位的数组发生在第一次写入时。这样设计的好处是:很多场景下容器创建后并不一定会写入数据,推迟分配能省掉一次无意义的内存申请。
我用数组存一个只可能添加少量数据的集合时,如果提前知道元素数量,就直接写:
java复制ArrayList<String> list = new ArrayList<>(expectedSize);
这里期望容量的作用是让底层数组一次到位,省掉后续因为 add 触发的多次扩容复制。
2.2 扩容为什么是 oldCapacity + (oldCapacity >> 1)
ArrayList 扩容的代码是大家很熟悉的 grow 方法。
java复制int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
elementData = Arrays.copyOf(elementData, newCapacity);
oldCapacity 右移一位等于除以 2,所以新容量大约是原来的 1.5 倍。为什么不每次只扩一个容量,或者直接翻倍?
每次只增加一个容量,意味着连续 add n 个元素时每次 add 都可能触发扩容和全量复制,整体代价会退化到 O(n²),这绝对不能接受。反过来如果每次直接翻倍,扩容次数虽然大幅减少,但最后一次扩容可能直接把用不到的大半段内存都锁住了,浪费明显。1.5 倍是两者之间的折中:既能控制扩容次数,让均摊成本收敛在 O(1),也不会像翻倍那样预留过多闲置空间。
这里有一个值得向面试官点出的细节:扩容的本质是 Arrays.copyOf,它内部走 System.arraycopy,等于先把旧数组所有元素复制到一个新数组,再把旧数组交给 GC。频繁扩容时,真正昂贵的不只是那次 Arrays.copyOf,还包括旧数组的分配释放。所以如果你能预估数据规模,一开始就指定容量是最便宜的优化。
2.3 中间插入删除的开销,来自整体平移而不是“插入”本身
搞清楚 ArrayList 的数组本质后,add(i, e) 和 remove(i) 的开销来源就很好理解了。执行中间插入时,ArrayList 要做的是把 index 及其之后的所有元素,通过 System.arraycopy 整体向后挪一格,空出的位置写入新元素:
java复制System.arraycopy(elementData, index, elementData, index + 1, size - index);
删除时相反,把 index + 1 到末尾的元素整体向前移动一格:
java复制System.arraycopy(elementData, index + 1, elementData, index, numMoved);
数据量大时,即使只是往列表头部加一个元素,也可能意味着十万个引用被搬了一次家。由于元素是引用、整体拷贝走的是 native 层数组复制,实际性能并不像想象中那么烂,但复杂度依然是 O(n)。这也是为什么“ArrayList 插入慢”这种说法并不完全准确——它在尾部插入很快,头部插入和中间插入才慢。
顺带说一个具体的坑:ArrayList 的 add(0, e) 和 add(size, e) 虽然都以 add(int, E) 形式存在,但成本天差地别。尾插有容量时就是 O(1);头插永远是 O(n),因为要把现有所有元素都往右平移一格。
2.4 容量控制:ensureCapacity 和 trimToSize 该怎么用
ArrayList 有两个公开的“容量调节”方法,很多人没用过。
java复制list.ensureCapacity(10000);
当你知道接下来要大量 add,但拿不准精确数量时,可以先给一个下界,减少扩容次数。如果你需要一个明确知道将来不会增长的数组列表,add 完之后调一下:
java复制list.trimToSize();
trimToSize 会把 elementData 按当前 size 复制一份,让底层数组刚刚好容纳现有元素,多出来的预留槽位全部释放。注意这两者也只是工程优化,不是功能必需;日常学习阶段了解即可,真到了“十万级数据批量装载”时,它们的价值才会明显体现。
3. LinkedList 的双向链表本质,一切从 Node 展开
3.1 先画出 JDK 里的 Node 结构
看 LinkedList 源码之前,先回忆一下数据结构课本上的双向链表:每个节点保存数据、前驱指针和后继指针。Java 的 LinkedList 就是这样实现的,JDK 内部有一个静态内部类 Node:
java复制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 和 size:
java复制transient Node<E> first;
transient Node<E> last;
transient int size = 0;
结构可以简化为下面这样的首尾指针模型:
text复制null <- [prev|item|next] <-> [prev|item|next] <-> [prev|item|next] -> null
^first ^last
三个字段的意思分别是:item 存真正的元素,prev 指向前一个节点,next 指向后一个节点。空链表 first = last = null,而非空链表第一项的 prev 和最后一项的 next 都是 null,所以它是一个不带头哨兵的双向非循环链表。
很多人在数据结构课上学的是“带头结点”的链表写法,比如严蔚敏版数据结构里,链表为了方便统一处理空表会设一个不存数据头的头结点;而 Java 的 LinkedList 选择了 first/last 指针方案,用 null 判断空表。两种设计都能工作,区别在于代码里判空逻辑放在哪。平时看到教科书和 JDK 实现打架不要慌,理解底层逻辑比死记“带头与否”更重要。
3.2 为什么 get(index) 只能折半扫描
链表节点离散分布在内存中,不可能用数组下标一步定位,LinkedList 只能从 first 或 last 出发,顺着指针一个个找。为了稍微快一点,JDK 做了一点优化:判断目标 index 靠近头还是靠近尾,然后决定从哪个方向遍历:
java复制Node<E> node(int index) {
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
size 右移一位相当于 size / 2。index 在列表前半段就从头部往后走,在后半段就从尾部往前走。这个“折半查找”虽然让 get(0) 和 get(size - 1) 很快,但 get(size / 2) 还是要走 n/2 步,复杂度仍然是 O(n)。所以面试里说到 LinkedList 随机访问,不能只说 O(n),最好补一句“它内部会从离目标更近的一端开始遍历”,这属于源码级加分项。
这种实现给普通 for 循环遍历埋了一个大坑:如果写
java复制for (int i = 0; i < linkedList.size(); i++) {
String value = linkedList.get(i);
}
get(i) 每次都是 O(n),整个循环就成了 O(n²),数据量到十万就开始肉眼可见地卡。真正的遍历应该用增强 for、迭代器或 stream,让游标顺着指针一路 next 下去,总代价才是 O(n)。
3.3 一个类同时是 List、Queue、Deque,这是选择它的重要理由
LinkedList 实现了三条核心接口链:List,用来当普通列表;Queue,用来当队列;Deque,用来当双端队列或栈。因为底层是双向链表,天然支持从头部和尾部操作。
它提供了一组非常顺手的头尾方法:addFirst/addLast、removeFirst/removeLast、getFirst/getLast、peekFirst/peekLast、pollFirst/pollLast、offerFirst/offerLast 等等。这意味着你可以用同一个对象既做栈又做队列:
java复制LinkedList<String> stack = new LinkedList<>();
stack.push("a");
stack.pop();
LinkedList<String> queue = new LinkedList<>();
queue.offerLast("job1");
String task = queue.pollFirst();
这是 ArrayList 完全不具备的能力。ArrayList 也能模拟栈,毕竟栈也就是尾插尾删,配合 push 和 pop 照样能用;但它无法高效地在头部连续插入删除。所以
