LinkedList-底层源码解析
LinkedList 底层源码解析:一张双向链表的图讲透插入与删除
基于 JDK 8 / 11
java.util.LinkedList· 本文约 4000 字数据结构 · 源码阅读
目录
- 它到底是什么:一个双向链表
- 骨架:三个字段 + 一个静态内部类
- 插入:linkLast 与 linkBefore 的四步指针
- 删除:unlink 如何让邻居互相绕过
- 查找:node(int) 的折半遍历
- 复杂度对比与 ArrayList 之争
- 三个真实存在的坑
- 源码之外:序列化、Deque、fail-fast
- 手写一个迷你双向链表
- 小结与记忆口诀
1. 它到底是什么:一个双向链表
很多人的第一印象是"LinkedList 是一条链",这只对了一半。准确地说,java.util.LinkedList 的内部是一条 双向链表(doubly linked list):每个节点除了数据,还同时持有指向前驱和指向后继的两个引用。整个容器只在两端各保存一个引用,分别叫 first(头)和 last(尾)。

图 1 · 双向链表整体结构:上方弧线是 next(往后),下方弧线是 prev(往回)
读图要点有三条,先记住后面才能看懂源码:
- 上方的弧线是 next,下方的弧线是 prev。两个方向各自成链,互不相干,所以插入和删除要分别处理两个方向。
- 头节点的
prev为null,尾节点的next为null——这就是源码里所有if (prev == null)判断的来源。 - 拿到任意一个节点,都能 O(1) 地拿到它的前驱和后继。这既是优势,也是后面"删除其实不便宜"的伏笔。
2. 骨架:三个字段 + 一个静态内部类
整个类的骨架干净得出乎意料:只有三个成员变量。
public class LinkedList<E> extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable {
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;
}
}
}
三个细节值得单独说:
Node是 static 内部类,不是普通内部类。普通内部类会隐式持有外部LinkedList的引用,一百万个节点就白白多出 8MB(64 位下压缩指针约 4 字节 × 1e6)。加上static,节点只装它该装的东西。size / first / last都是transient。指针只在内存中有意义,序列化时没必要写出去,所以writeObject只写 size 和每个元素的值,反序列化时再重新串链。- 构造函数里
prev在前、next在后,跟字段声明顺序一致——源码里所有new Node<>(pred, e, succ)都是这个顺序,别记反。
空间开销账: 存一个元素,除了元素本身,还有一个 Node 对象头(约 16 字节)+ item / next / prev 三个引用(约 12 字节)= 每个元素约 28 字节的额外开销。这就是"双向链表比数组贵"的量化答案。
3. 插入:linkLast 与 linkBefore 的四步指针
LinkedList 的插入全部收敛到三个私有方法:linkFirst、linkLast、linkBefore。先看最简单的尾插 linkLast,它是 add(E e) 的底层实现:
void linkLast(E e) {
final Node<E> l = last; // ① 先存下旧的尾节点
final Node<E> newNode = new Node<>(l, e, null); // ② 新节点:前驱=l,后继=null
last = newNode; // ③ 更新尾指针
if (l == null) // 原链表为空
first = newNode;
else
l.next = newNode; // ④ 旧尾节点的 next 指向新节点
size++;
modCount++;
}
尾插只动两根指针(新节点的 prev、旧尾的 next),所以是 O(1)。真正通用的是在指定节点之前插入的 linkBefore,它是 add(int index, E element) 的核心:
void linkBefore(E e, Node<E> succ) { // succ = 插入位置上的原节点
// assert succ != null;
final Node<E> pred = succ.prev; // 前驱
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode; // ③ 后继回指新节点
if (pred == null)
first = newNode; // 插在最前面:更新 first
else
pred.next = newNode; // ④ 前驱的 next 指向新节点
size++;
modCount++;
}
注意 newNode 构造那一行就同时完成了第 ① ② 两步(X.prev = pred、X.next = succ)。把源码翻译成图就是这样:

图 2 · 插入操作:橙色是新节点的指针,紫色是邻居的指针
为什么第 ④ 步必须最后做?
因为第 ③ 步P.next.prev = X还要靠P.next找到后继节点 N。一旦先执行P.next = X,原来的 N 就再也找不到了,链表后半段直接丢失。源码里把succ.prev = newNode放在pred.next = newNode之前,正是这个道理。
所以 add(int index, E element) 的复杂度是 O(n) + O(1) = O(n)——瓶颈不在"改指针",而在先找到那个位置:
public void add(int index, E element) {
checkPositionIndex(index); // 越界检查:0 <= index <= size
if (index == size)
linkLast(element); // 尾部:O(1)
else
linkBefore(element, node(index)); // 中间:定位 O(n) + 插入 O(1)
}
4. 删除:unlink 如何让邻居互相绕过
删除只有 unlink(Node<E> x) 一个方法,remove(int)、remove(Object)、poll() 全都复用它:
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; // 删的是头节点:first 后移
} else {
prev.next = next; // ① 前驱的 next 跨过 x 指向后继
x.prev = null; // 断开,帮助 GC
}
if (next == null) {
last = prev; // 删的是尾节点:last 前移
} else {
next.prev = prev; // ② 后继的 prev 跨过 x 指向前驱
x.next = null;
}
x.item = null; // ③ 清空数据,让 GC 能回收元素对象
size--;
modCount++;
return element;
}
对照图看,删除比插入简单:每个方向只需要改一根指针,然后把自己置空交给 GC。

图 3 · 删除操作:两个方向各改一根指针,P 被"架空"后连引用一起置空
源码里那两处 x.prev = null / x.next = null / x.item = null 不是为了正确性,而是为了 不制造内存泄漏:如果只让邻居绕过 x,x 自己还攥着 item、next、prev 三个引用,会连带把整条链"拖住"不释放。
5. 查找:node(int) 的折半遍历
双向链表唯一的查找手段就是"一个一个数过去"。但因为有 last 指针,JDK 做了一个朴素却有效的优化——看下标在前半段还是后半段,决定从哪头开始走:
Node<E> node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) { // index < size/2,从头往后
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else { // 否则从尾往前,这正是 prev 的价值
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
size >> 1 是位运算版的除以 2。注意它只是把常数砍半,复杂度依然是 O(n)——这也是 get(int index) 为什么被反复劝退的原因。
6. 复杂度对比与 ArrayList 之争
| 操作 | LinkedList | ArrayList | 说明 |
|---|---|---|---|
add(E) 尾插 |
O(1) | O(1) 均摊 | LinkedList 每次都 new Node;ArrayList 扩容时才搬 |
addFirst / addLast |
O(1) | O(n) | 数组头部插入要整体后移 |
add(index, E) |
O(n) | O(n) | 链表卡在定位;数组卡在 arraycopy,但后者是内存块级拷贝,常数极小 |
get(index) |
O(n) | O(1) | 链表最大短板 |
remove(int index) |
O(n) | O(n) | 同理,定位 + 搬运 |
| 已知节点处插/删 | O(1) | — | 通过 Iterator / ListIterator 迭代时才是 O(1) |
| 内存 | 每元素约 +28 字节 | 数组尾部可能有闲置容量 | 链表节点还分散在堆上,缓存命中率差 |
纠正一句流传很广的话:"LinkedList 增删快,ArrayList 增删慢"。 这句话漏掉了定位成本。
只有在已经持有节点引用(比如用
ListIterator迭代到某个位置)时,LinkedList 的增删才是 O(1)。只要方法是按index或按对象操作的,LinkedList 都要先 O(n) 找过去,总体并不比 ArrayList 快,反而因为指针跳转、缓存不友好,实测常常更慢。
7. 三个真实存在的坑
坑一:用下标 for 循环遍历,直接变成 O(n²)
// 灾难:每次 get(i) 都要从链头/链尾重新走一遍
for (int i = 0; i < list.size(); i++) {
use(list.get(i)); // 总复杂度 O(n²)
}
// 正确:迭代器持有当前节点,next() 只走一步
for (String s : list) { // 增强 for 底层就是 Iterator,O(n)
use(s);
}
10 万元素的 LinkedList,两种写法能差出几百倍。这是面试里最高频的一道题。
坑二:remove(Object) 只会删第一个匹配项
public boolean remove(Object o) {
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null) { unlink(x); return true; }
}
} else {
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item)) { unlink(x); return true; } // 找到就 return
}
}
return false;
}
注意它是从头单向遍历的,没有用折半优化。要删所有匹配项,得用 removeIf 或迭代器的 remove()。
坑三:Null 元素与 remove() 的歧义
LinkedList 允许存 null,所以 remove() 无参版本(来自 Deque)删除的是头节点并返回它的值;如果你存了 null,remove() 返回 null 时你分不清"删掉了 null 元素"还是"链表本来就空"。空链表时它直接抛 NoSuchElementException,而 poll() 才返回 null——需要"空就返回 null"的语义时用 poll()。
8. 源码之外:序列化、Deque、fail-fast
- 序列化:字段全
transient,writeObject只写size再加每个item;readObject反过来用linkLast逐个元素重建链表。所以序列化后的体积与指针结构无关。 - Deque:LinkedList 实现了
Deque,所以有一整套offerFirst/offerLast/pollFirst/pollLast/peekFirst/peekLast/push/pop。这些全是 O(1),因为只动first/last——这是它最被低估的优势场景:当队列/栈用。 - fail-fast:每个结构性修改都
modCount++,迭代时若发现expectedModCount != modCount立即抛ConcurrentModificationException。要在遍历中删除,请用iterator.remove(),它会同步expectedModCount。
9. 手写一个迷你双向链表
把上面的四步指针自己敲一遍,比读十遍源码都管用。下面是一个保留核心逻辑的精简版:
public class MiniLinkedList<E> {
private static class Node<E> {
E item; Node<E> prev, next;
Node(Node<E> prev, E item, Node<E> next) {
this.prev = prev; this.item = item; this.next = next;
}
}
private Node<E> first, last;
private int size;
public void add(E e) { // 尾插 O(1)
Node<E> l = last;
Node<E> node = new Node<>(l, e, null);
last = node;
if (l == null) first = node; else l.next = node;
size++;
}
public E get(int i) { return node(i).item; }
private Node<E> node(int i) { // 折半遍历
if (i < (size >> 1)) {
Node<E> x = first;
for (int k = 0; k < i; k++) x = x.next;
return x;
}
Node<E> x = last;
for (int k = size - 1; k > i; k--) x = x.prev;
return x;
}
public E remove(int i) { // 定位 O(n) + 解除 O(1)
Node<E> x = node(i), p = x.prev, n = x.next;
if (p == null) first = n; else { p.next = n; x.prev = null; }
if (n == null) last = p; else { n.prev = p; x.next = null; }
E old = x.item; x.item = null; size--;
return old;
}
}
10. 小结与记忆口诀
- 结构:三个字段(
size / first / last)+ 一个staticNode(item / prev / next)。头 prev、尾 next 为 null,源码里所有边界判断都源于此。 - 插入:四根指针——新节点先接两头(1、2),邻居再回头指(3),最后才改前驱的 next(4)。顺序错了就丢节点。
- 删除:两根指针——
prev.next = next和next.prev = prev,再把 x 的三个域全部置空。 - 查找:只能遍历;
node(index)用size >> 1判断从哪头走,砍半常数,仍是 O(n)。 - 选型:需要按下标随机访问 → ArrayList(或数组);需要频繁在两端增删 / 当栈和队列 / 在迭代器位置插入删除 → LinkedList。
一句话版本: 双向链表用"每个元素多两个引用"换来了"已知节点处 O(1) 增删 + 双向遍历",代价是随机访问退化成 O(n)、内存开销和缓存命中率变差。想清楚你的访问模式,再决定用哪个 List。
插图:双向链表结构、插入四步指针、删除两根指针(同时提供 SVG 源文件与 PNG 高清版,见 images/ 目录)。

浙公网安备 33010602011771号