老实讲,LinkedList 是我在 Java 集合框架里见过“最被高估、又最被低估”的一个类。说被高估,是因为很多八股文把“增删快、查询慢”讲成了绝对真理,结果真有人拿 LinkedList 做随机访问,线上直接卡到报警;说被低估,是因为它不止是 List,还是一个双向链表、一个 Deque,在特定场景下有很多人没用出来的灵活性。这篇不从背诵结论出发,直接进 JDK 源码、跑几个对照实验,把 LinkedList 从节点结构、增删查改原理、内存开销到真实使用场景完整拆开。无论你是在准备 Java 面试,还是做集合选型,都能从里面找到能直接复用的判断依据。
1. 先看底层:LinkedList 的“双向链表”到底长什么样
1.1 一个 Node 节点,由三部分组成
LinkedList 的源码并不复杂,核心就是一个静态内部类 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;
}
}
每个节点上有三个关键信息:item 存真正的元素,next 指向后一个节点,prev 指向前一个节点。LinkedList 内部再维护两个字段 first 和 last,分别指向链表头和链表尾。
这和 ArrayList 的差异是本质性的。ArrayList 底层是一整块连续内存的 Object[],通过数组下标直接定位;LinkedList 则是一堆散落在堆里的 Node 对象,靠指针串成一条链。第一个 Node 和第二个 Node 在内存里未必挨着,甚至可能隔得很远,这就决定了它无法像数组一样用下标“跳”到某个位置,只能顺着 next 或 prev 一个一个找。
我第一次看这段源码的时候也觉得简单,但后面所有性能问题几乎都能从这个“指针串链”的结构推导出来。不要把 LinkedList 想成“带索引的链表”,它不维护任何索引,所有随机访问都靠遍历。
1.2 同时实现 List 和 Deque,双端能力是先天自带
一个很容易被忽略的事实是,LinkedList 不仅实现了 List 接口,还实现了 Deque 接口,所以它天生支持“双端队列”的语义。也就是说,它既能像列表一样按下标访问,也能像队列一样从头部入队、出队,还能像栈一样 push 和 pop。
看几个常用方法就知道了:
| 语义 | 方法 |
|---|---|
| 头部添加 | addFirst(e) / offerFirst(e) / push(e) |
| 尾部添加 | addLast(e) / offerLast(e) / offer(e) / add(e) |
| 头部移除 | removeFirst() / pollFirst() / pop() / poll() |
| 尾部移除 | removeLast() / pollLast() |
| 查看头部 | peekFirst() / peek() / element() |
| 查看尾部 | peekLast() |
因为这些能力,LinkedList 在一些历史代码里被拿来当栈、队列、双端队列用。但有一个细节要注意:ArrayDeque 同样实现了 Deque,而且通常性能更好;LinkedList 的独特之处是允许存放 null,而 ArrayDeque 会直接抛 NullPointerException。所以如果业务里确实需要往双端队列里放 null,ArrayDeque 不可用,LinkedList 是 Java 集合框架里少数能容纳 null 的 Deque 实现。
1.3 为什么 Java 选择双向而不是单向
有人可能问:既然要省内存,List 用单向链表不就行了吗?LinkedList 里的 Deque 操作需要经常删除尾节点,如果只有 next 指针,想删尾节点就得先从头遍历到尾找到它的前一个节点,时间复杂度直接变成 O(n)。有了 prev 指针,删除尾节点时只要看 last.prev 就能拿到前一个节点,再把 last 往前挪,整个过程 O(1)。
代价也明显:每个节点额外保存一个引用,Node 对象变大,内存占用变高。双向链表本质上是“用空间换灵活性”的典型设计。我在后面第 3 节会专门算一笔账,看看这个“双向”到底埋了多少内存进去。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 源码级别的增删查改:到底哪里快、哪里慢
2.1 尾部追加:linkLast 如何分配新节点
LinkedList 的 add(E e) 默认是往尾部追加元素,源码走的是 linkLast(e):
java复制void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
整个操作只做几件事:记录旧尾节点 l、创建新节点、把 last 指向新节点,然后让旧尾节点的 next 指向新节点。空链表时 first 和 last 同时指向这个新节点。没有扩容、没有搬元素,时间复杂度就是 O(1)。
对比一下 ArrayList 的尾部 add:它由数组支持,虽然均摊复杂度也是 O(1),但每次扩容时要创建更大的数组,再把旧数组元素复制过去,GC 压力更高。LinkedList 没有“一次性扩容”的概念,每次 add 都 new 一个 Node。这里有个容易忽略的点:Node 对象会逃逸到堆里,无法被 JIT 做逃逸分析消除,所以当大量 add 时,LinkedList 的对象分配频率和 GC 压力往往会比 ArrayList 更大。也就是说,“尾部 add 快”说的是复杂度,不等于说“实测一定最快”,后面实验会验证这一点。
2.2 按下标插入:为什么中间插入不是 O(1)
教科书经常说“链表中间插入快”,这句话在 Java LinkedList 里有一个很大的前提:你得已经站在那个位置。如果只给一个下标 index,LinkedList 必须先找到这个位置对应的节点,然后才能插入。
java复制public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
linkBefore 把新节点插到指定节点 succ 前面,改改 prev 和 next 指针,确实是 O(1)。但问题出在 node(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;
}
}
源码做了一个“双向查找”的优化:如果 index 小于 size 的一半,就从 first 往后找;否则从 last 往前找。所以随机访问和随机插入的最坏步数是 n/2,平均大约是 n/4。虽然比单向链表从头走到尾好一些,但本质上仍然是 O(n) 级别的线性扫描。
真正能发挥链表中间插入优势的办法,是先把 ListIterator 定位到目标位置附近,再连续调用迭代器的 add。ListItr 内部持有当前节点 next,调用 add 时如果 next 非空就 linkBefore,无需重新遍历。这种情况下连续插入 k 个元素,定位只花一次 O(n),后续每次都是 O(1)。
2.3 三种 remove 的成本完全不同
很多人说“LinkedList 删除快”,这句话属于过度简化。看 remove 的几种写法:
按对象删除 remove(Object o):从头开始遍历,比较每个元素的 equals,找到第一个匹配节点后 unlink。找不到就返回 false,复杂度 O(n)。这里有个鲜为人知的细节,源码在遍历时会区分 null 和非 null 判断,所以 LinkedList 允许存 null,也能成功删除 null。
按下标删除 remove(int index):先 node(index) 找到目标节点,再 unlink。复杂度主要由定位决定,接近 O(n/2)。
用迭代器删除 iterator.remove():迭代器已经停在当前节点,unlink 当前节点时只需要操作节点前后指针,O(1)。换句话说,同样是“删除中间某个元素”,如果你先用 for 循环配合 remove(int) 去删,因为每次都要先找位置,很快会退化;但如果在一次遍历过程中删除多个元素,迭代器方式是天然高效的。
我在实际项目里看到最多的问题就是有人用增强 for 遍历 LinkedList,在循环体里调 list.remove(item),结果直接抛 ConcurrentModificationException。原因后面第 4 节会细讲。
2.4 get(int index):看似只查一次,实际上要跨一大段链
get(int index) 内部就是调用上面贴过的 node(index),复杂度和 add(int, E)、remove(int) 一样。所以 LinkedList 的随机访问是 O(n/2),而不是 O(1)。
最典型的问题代码就是这种:
java复制for (int i = 0; i < linkedList.size(); i++) {
doSomething(linkedList.get(i));
}
表面上是 O(n) 的循环,但每次 get 都要从头或从尾重新走一遍,整个循环实际上是 O(n²)。如果列表里有 10 万个元素,ArrayList 的 for-i 循环大约微秒级完成,LinkedList 这种写法会跑到秒级甚至更久,量级完全不是一个档次。
顺便说一个性能之外的考量:ArrayList 底层是连续数组,CPU 遍历时缓存预取效果好;LinkedList 的节点散落堆中,每次沿着 next 找下一个元素都可能触发 cache miss。这也是为什么很多场景下,相同复杂度的操作,LinkedList 的实测耗时会明显高于理论预期。ArrayList 的迭代器遍历和 for-i 随机访问都很快;LinkedList 则必须用迭代器或 for-each,只走一遍链,才和 ArrayList 处于同一量级。
3. 实测与内存对比:LinkedList 和 ArrayList 到底差多少
3.1 一段很简单的对照测试
我不太推荐直接相信任何网上的精确数字,因为 JVM 版本、数据规模、对象大小都会影响结果,但数量级可以做参考。下面这段逻辑可以用在任何 JDK 8+ 环境里:
java复制int n = 100_000;
List<Integer> array = new ArrayList<>(n);
List<Integer> linked = new LinkedList<>();
for (int i = 0; i < n; i++) {
array.add(i);
linked.add(i);
}
long sum = 0;
long t1 = System.nanoTime();
for (int i = 0; i < n; i++) {
sum += array.get(i);
}
long t2 = System.nanoTime();
for (int i = 0; i < n; i++) {
sum += linked.get(i);
}
long t3 = System.nanoTime();
System.out.println("ArrayList get循环: " + (t2 - t1) / 1_000_000.0 + " ms");
System.out.println("LinkedList get循环: " + (t3 - t2) / 1_000_000.0 + " ms");
我在 JDK 17 下跑 10 万条数据,ArrayList 的 for-i 随机访问快到几乎可以忽略,LinkedList 的同等循环能跑到几千毫秒级别。注意这不是一次 get 的差距,而是 O(n²) 和 O(n) 的差距在规模放大后被彻底拉开。
如果改成头插法,结果又会反直觉地反过来:
java复制List<Integer> a = new ArrayList<>();
List<Integer> l = new LinkedList<>();
for (int i = 0; i < 100_000; i++) {
a.add(0, i); // ArrayList 每次都要搬移全部元素
l.addFirst(i); // LinkedList 在头部插一个节点
}
ArrayList 会非常痛苦,因为它每次在头部插入都要把所有已有元素向后移动一个位置;LinkedList 只是新建一个 Node 并把 first 指向它,差距可以到几个数量级。很多教科书里的“链表头插快、数组头插慢”在这里是完全成立的。
总结成一张常用图景:
| 操作 | ArrayList | LinkedList |
|---|---|---|
| 尾部 add | 快,均摊 O(1),但扩容有复制成本 | 快,O(1),但每次都 new Node |
| 头部 add/remove | 慢,O(n) 数组搬移 | 快,O(1) |
| 按下标 get | 快,O(1) | 慢,O(n/2) |
| 按下标 insert/remove | 慢,O(n) 数组搬移 | 不算快,定位 O(n/2),定位后 O(1) |
| 使用迭代器遍历 | 快,连续数组 | 不慢,但节点可能分散,实际比 ArrayList 略慢 |
| 使用 for-i 配合 get | 快 | 灾难,O(n²) 级别 |
3.2 内存占用:LinkedList 的 Node 比想象中更“重”
如果把 100 万个已经存在的 String 引用分别放进 ArrayList 和 LinkedList,内存差距主要来自容器自身的数据结构开销。
ArrayList 的容器层是一块连续 Object[],在开启压缩指针的普通 64 位 JVM 上,每个引用占 4 字节左右,100 万引用大约 4 MB,加上数组对象头也不过几 MB。LinkedList 的容器层是 100 万个 Node 对象,每个 Node 要保存 item、next、prev 三个引用,还要算上 Java 对象头,开启压缩指针时一个 Node 浅大小通常在 24 字节左右。粗算下来,100 万 Node 就是 24 MB 左右,比 ArrayList 的引用数组高出数倍。
这里要区分:如果元素本身就是大对象,容器结构带来的差异会被稀释;但如果是存大量小对象引用、Integer、短字符串,或者在做高频缓存时,LinkedList 在内存上会明显更吃亏。见过不少朋友用 LinkedList 存放大量日志或埋点对象,还没跑到多少条就报 java.lang.OutOfMemoryError: Java heap space,一查堆栈,Linked Node 占了很高比例。这就是典型的结构性开销没算进去。
更麻烦的还有 GC 影响。ArrayList 扩容后产生的旧数组和元素搬移是一次性压力;LinkedList 每插入一个元素就分配一个 Node 对象,大量短生命周期 Node 会显著增加 Young GC 频次。所谓“链表按需分配不浪费”这一优点,在高频插入时反而变成负担。
3.3 版本演进里的一个冷历史
现在 JDK 8 之后的 LinkedList 用 first/last 直指真实首尾节点,但在很老的 JDK 版本里曾经采用过“带头哨兵节点”
