LinkedList可能是Java集合框架里最容易被“背答案”的一个类。网上随便一搜就是“ArrayList查快增删慢,LinkedList查慢增删快”,然后面试也这么答,写完一个for循环get(i)之后被性能打脸也不知道为什么。这次我想用一篇完整的源码笔记,把LinkedList从类声明、Node结构、add/get/remove核心方法,到它作为Deque和顺序集合的另一面,逐层拆开来讲。通篇以JDK 8的经典实现为主参照,顺带聊JDK后续版本出现的接口变化,适合正准备啃集合源码的Java开发者,也适合面试前想真正搞懂而不是死记结论的人。
1. LinkedList到底是个什么样的“链表”
1.1 从类声明反推设计意图
打开源码第一行就能看到一个有意思的信息:
java复制public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
它同时实现了List<E>和Deque<E>两个接口,这决定了LinkedList不能只被当作“列表”来理解。Deque是双端队列,意味着它能从头部加、从头部取、从尾部加、从尾部取,所以LinkedList天生就可以当栈、当队列、当双端队列使用。这跟ArrayList有本质区别,ArrayList只是List,想实现栈和队列要么自己包一层,要么用ArrayDeque。
AbstractSequentialList这个父类也很关键。它名字里的“Sequential”不是“顺序访问”的意思,而是“按顺序逐个访问”。这个类为“迭代式访问”的列表提供了骨架实现,把get(int index)、set(int index, E element)、add(int index, E element)、remove(int index)这些按下标操作的方法,统一转成了基于ListIterator的迭代逻辑。
问题来了:既然父类已经用迭代器实现了按下标操作,为什么LinkedList还要自己重写一遍?因为父类的实现方式是“从链表头部开始一个节点一个节点往后找”,而LinkedList有首尾两个指针,可以折半查找,两边同时往中间摸,但ArrayList没有这个能力。它自己重写这些方法,本质是在用“结构信息”换性能。
从类声明还能推出一个重要结论:LinkedList在绝大多数实现里不是一个普通的“单链表”。很多初学者脑中想象的链表是“当前节点只存next”,像手拉手一串,但LinkedList内部是双向的。它是双向链表,每个节点都有前驱指针prev和后继指针next。
1.2 Node:整个链表的最小积木
LinkedList内部用一个静态内部类表示节点,JDK 8里的定义是这样的:
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;
}
}
三个字段分别对应:当前节点存的值、后继节点引用、前驱节点引用。链表的“串”不是靠连续内存实现的,而是靠每个节点的next/prev互相指认。这就引申出两个关键结论:
- 内存不是连续分配的,所以不需要像ArrayList那样预留连续空间,也不需要“扩容”和“搬移”元素。
- 想从某个节点找到相邻节点,代价是O(1),只要改一下next和prev的指向就能插入或删除。
不过,内存不连续也带来了明显的劣势:无法通过“起始地址 + 索引 * 对象大小”直接算出某个位置的内存地址,想按下标找到节点必须从头或尾沿途走一遍,这也是后面讲node(int index)时为什么需要折半搜索的根源。
我一直建议真正读源码时先看内部类,内部类的形态基本决定了这个集合的底层策略。看到Node带prev和next,心里就应该浮现出一幅“双向链表图”,后续所有方法都是在维护这张图不破。
1.3 三个字段维系整张链
LinkedList实例本身的字段非常少,JDK 8中就是:
java复制transient int size = 0;
transient Node<E> first;
transient Node<E> last;
没有数组,没有Map,也没有其他辅助存储。first指向链表的头节点,last指向尾节点,size记录节点总数。LinkedList能实现O(1)头尾插入,靠的就是first和last这两个哨兵一样存在的指针,每次在头部或尾部插入时只需要局部改几个引用,然后更新first/last即可。
transient的作用在这里需要多说一句。LinkedList重写了writeObject和readObject,序列化时只序列化size和每个节点的item,不序列化节点之间的next/prev引用,因为链表的引用关系在反序列化后必然失效,硬序列化结构信息不仅浪费空间,还会让不同JVM版本之间的序列化结果更难兼容。自定义序列化是这个类实现java.io.Serializable时刻意做的优化,读源码时留意到这一步,会对“为什么LinkedList要看重写序列化方法”有更具体的认识。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 新增元素:从add(E e)到add(int index, E element)
2.1 add(E e):永远挂在尾部
平时最常用的add(E e),源码内部实际调用的是linkLast(E e):
java复制public boolean add(E e) {
linkLast(e);
return true;
}
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。
- 创建新节点,新节点的prev指向l,next为null。
- 把last更新为新节点。
- 如果链表原来是空的,那么first也指向新节点;否则让原来的尾节点l.next指向新节点,把前后串起来。
最后size++和modCount++很多人容易忽略。size好理解,modCount是结构性修改计数,它是迭代器fail-fast机制的重要依据。等看到迭代器抛ConcurrentModificationException时你就会知道,每次修改结构却没同步modCount,会在后续遍历时引发大问题。
这里最值得体会的是“尾插O(1)”的真正含义。LinkedList在尾部插入新节点根本不需要遍历链表,因为last永远指向当前最后一个节点,只要改最后两个节点的引用就能完成插入。而在ArrayList尾部添加也通常是O(1),只有在数组容量满时才需要扩容,所以结论不是“LinkedList尾部插入好于ArrayList”,而是两者在绝大多数场景下都很快,没必要把“尾部插入”当成LinkedList的独家卖点。
2.2 add(int index, E element):先“指路”再“接线”
按下标插入就复杂一些了:
java复制public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
checkPositionIndex(index)做了边界校验,索引必须在0到size之间,允许等于size,因为插在末尾是合法的。如果插入位置恰好在末尾,它就直接走linkLast,又变成O(1)。否则,它先通过node(index)找到当前位于index位置的节点,然后调用linkBefore(element, succ),把新节点插到那个节点之前。
linkBefore内部的实现是:
java复制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;
else
pred.next = newNode;
size++;
modCount++;
}
假设要在索引2的位置插入,就先找到原来索引2的节点succ,新节点插在succ和succ.prev之间。需要修改的引用包括succ.prev、pred.next(如果pred不为空)以及first(如果插入的是头节点)。它不像数组那样需要把后续所有元素往后搬一格,只需改两三个引用,增删本身确实是O(1)的局部操作。
但请注意我特意用的表达——“增删本身是O(1)”。对整个add(int index, E)而言,前面的node(index)是O(n)的查找过程。所以从完整方法复杂度看,它仍是O(n)。很多人背“LinkedList增删快”却忽略了中间插入之前必须先找到那个位置,而查找恰恰是链表的短板。
2.3 头插与尾插:三个公开方法背后是同一个link逻辑
如果你调用addFirst(E e)或offerFirst(E e),会走进linkFirst;调用addLast(E e)、offerLast(E e)会走进linkLast。LinkedList的代码风格非常规矩:公开方法负责参数检查、选择策略,真正干活的私有方法一律叫linkXxx或unlinkXxx。
java复制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恰好镜像对称。在链表没有节点时,first和last要同时指向新节点,这个边界处理几乎所有链表实现的Bug高发点。看源码时多盯这类“if (f == null)”分支,你会形成一种条件反射:任何涉及头尾指针的更新都必须考虑空链表特例。
3. node(int index)与get:真正理解“随机访问慢”
3.1 按索引查找的核心方法竟然不长
LinkedList按下标查找走的是这个私有方法:
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;
}
}
虽然只有短短几行,但它是LinkedList“按下标操作一切复杂度的根源”。设计上做了一个折半优化:先判断index是在链表前半段还是后半段。如果在前半段,就从first往后走index步;如果在后半段,就从last往前走size-1-index步。最坏情况下,要找的节点在链表正中间,需要走大约n/2步,时间复杂度依然是O(n),只是把平均查找步数压到了原来的二分之一。
这个折半逻辑在面试里经常被单独拎出来问。很多人知道LinkedList按索引慢是O(n),但对“前半段还是后半段”的优化一无所知。看源码之后你会发现,它不是傻乎乎每次都从头开始数,而是会聪明地利用last指针从尾部往回找。这个细节足以让你在面试中把同样一个问题回答得比别人更有层次。
3.2 get(int index)为什么被冤枉
顺着node方法走,get(int index)的实现几乎透明:
java复制public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
先校验索引必须在0到size-1之间,然后取node(index)节点的item。所以结论很明确:LinkedList的get(index)是O(n)。
但你真要在项目中用LinkedList做“按索引遍历”,实际伤害比理论更大。很多人写了这样的循环:
java复制LinkedList<Integer> list = new LinkedList<>();
// 假设里面放了10000个元素
for (int i = 0; i < list.size(); i++) {
// 业务处理 list.get(i)
}
每次get(i)都会从链表头或尾重新走一遍,总步数大约等于n的平方除以4。当n到十万级别时,这个循环会卡到人怀疑机器出了问题。正确做法是用迭代器或增强for循环,让节点一个一个往后挪:
java复制for (Integer value : list) {
// 业务处理 value
}
增强for循环底层就是迭代器,每次拿到下一个节点只花O(1)。同一个需求,从O(n^2)降到O(n),差别在数据量小的时候无感,数据量一大就是天壤之别。这也是我建议所有Java开发者都不要只背“ArrayList比LinkedList随机访问快”这种粗糙结论的原因——要能说清楚快在哪里、慢在哪里、在什么循环方式下才会把慢放大成灾难。
4. 删除元素与迭代器:从源码看待fail-fast
4.1 remove(int index)和remove(Object o)是两套逻辑
LinkedList提供了两个名字特别容易混淆的remove方法,我见很多新手踩坑。
remove(int index)按位置删:
java复制public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
remove(Object o)按值删:
java复制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 false;
}
一个返回被删除的元素,一个返回是否删除成功;一个按下标定位节点,一个用equals从头到尾比较值。这两个方法互为补充,但如果不看源码,写代码时很容易混淆它们各自的开销。
值得留意的是remove(Object o)允许删除null元素,并且对null做了单独判断,用的是x.item == null而不是o.equals(x.item),因为null没有equals方法。这是源码中处理null的惯用方式,在HashMap、ArrayList的类似方法里也能见到同一模式。
4.2 unlink到底在做什么
真正干活的unlink(Node<E> x)是删除操作的万能核心:
java复制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;
size--;
modCount++;
return element;
}
删除一个节点,要分三步想清楚:
- 如果被删节点是头节点,那么first要指向它的后继;否则让前驱节点的next指向后继,并把被删节点的prev置空。
- 如果被删节点是尾节点,那么last要指向它的前驱;否则让后继节点的prev指向前驱,并把被删节点的next置空。
- 将被删节点的item置为null,帮助GC尽早回收对象。
这个“把x.prev和x.next都置空,再把x.item置空”的操作,是LinkedList比某些自己手写的链表更规范的地方。很多手写链表只改前后引用不清理被删节点,导致被删节点仍然持有业务对象引用,垃圾回收的根可达路径被意外延长,内存释放被拖延。源码里对null的处理透着一个CTRL+C/V之外的重要设计点:数据结构不只是“逻辑正确”,还要关心GC友好性。
4.3 为什么遍历时直接list.remove会抛异常
LinkedList的迭代器基于modCount做快速失败。以ListItr为例,它在创建时记录了:
java复制private int expectedModCount = modCount;
每次执行next()或previous()时都会调用:
java复制final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
如果你在for-each循环里直接调用list.remove(...),modCount增加了,但迭代器记录的还是旧值,下一次next()时就会抛出ConcurrentModificationException。
正确的删除方式是用迭代器自己的remove:
java复制Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
Integer value = iterator.next();
if (条件成立) {
iterator.remove();
}
}
ListItr.remove()内部会把expectedModCount同步成最新的modCount,所以不会抛异常。Java 8之后还能直接写list.removeIf(...),它内部做了同样的modCount同步。
这个机制的本意是在迭代过程中及时发现并发修改,避免出现“遍历到一半,链表已经被别人改坏了”的不可预期结果。但很多单线程场景也会触发,原因就是结构化修改后没有走迭代器自己的更新路径。看过源码后,这类问题的排查速度能快很多,因为听到ConcurrentModificationException你脑子里浮现的就不再是一个神秘异常,而是一张modCount对不上的账。
5. LinkedList还是一位Deque选手:队列、栈与双端操作的正面价值
5.1 从Deque接口角度看它的第二身份
很多人看LinkedList只关注它的List身份,忽略它实现了Deque这一事实。Deque接口提供了非常完整的两端操作API:
addFirst/addLast:两端添加,失败抛异常offerFirst/offerLast:两端添加,失败返回特殊值removeFirst/removeLast:两端删除,失败抛异常pollFirst/pollLast:两端删除,失败返回nullgetFirst/getLast:只查看两端头元素,失败抛异常peekFirst/peekLast:只查看两端,失败返回null
比如用addFirst向头部插入时,实际调用的是前面讲的linkFirst(e);用pollFirst()删除头节点时,实际走的是unlinkFirst。队列、栈只是这些方法的组合使用方式:
java复制Deque<String> stack = new LinkedList<>();
stack.push("A"); // 等价于 addFirst
stack.pop(); // 等价于 removeFirst
push、pop这些Deque默认方法最终都落到LinkedList的首部操作上。所以LinkedList完全可以当栈用,而且不会像java.util.Stack那样在继承Vector时拖着一堆不相干的方法。同样,把它当成先进先出的队列也没问题,offer(e)往尾放,poll()从头取。
这种双重身份让LinkedList成了集合框架中难得的“多功能工具”。不过在真实项目里我不会轻易拿它当高并发的业务队列,背后的线程安全问题并不会因为它是链表就自动消失,多线程环境仍然要加锁或用并发集合,Deque身份只解决“结构上方便”,不解决“线程安全”。
5.2 同为Deque实现:LinkedList和ArrayDeque怎么选择
既然LinkedList实现了Deque,而JDK里还有一个专门的ArrayDeque也实现了Deque,那两者在“栈和队列”场景下该如何选?答案可能让很多人意外:纯做栈或队列时,ArrayDeque通常是更好的选择。
原因可以从底层结构来推:
- ArrayDeque用循环数组实现,内存连续,CPU缓存命中性好。
- ArrayDeque的头尾操作同样是O(1),但常数更小。
- ArrayDeque不允许存null元素,LinkedList允许存null。这既是限制也是安全信号:如果业务上不允许null,ArrayDeque能在入口就拦住,而LinkedList会把问题推迟到业务逻辑。
- 内存开销上,LinkedList每个节点除了item还额外持有两个引用,存储密度更低。
LinkedList相对ArrayDeque的核心优势在于它同时还是List,能按下标访问,能随时在中间插入。而在“双端队列”这一定位上,ArrayDeque明显更专注。理解了这一点,你在选型时就不会“因为LinkedList支持Deque就什么场景都用它”,而会按身份拆开看:当列表用要考虑查询和插入位置的复杂度;当栈/队列用要跟ArrayDeque做内存和缓存命中率上的对比。
6. 从JDK 21看LinkedList的未来:SequencedCollection的加入
6.1 顺序集合接口带来的新能力
JDK 21引入了一个新接口家族,SequencedCollection就是其中一员。LinkedList的类声明在JDK 21之后变成了:
java复制public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable, SequencedCollection<E>
对LinkedList来说,这个接口带来的直接变化是它可以用一套统一的方法处理“有序集合”的收尾两端:
getFirst()/getLast()removeFirst()/removeLast()addFirst(E)/addLast(E)reversed():返回逆序视图
这些方法在List和Deque里以前就部分存在,但SequencedCollection把它们统一成了通用契约。比如你现在可以这样写:
java复制SequencedCollection<String> seq = new LinkedList<>();
seq.addLast("Java");
seq.addLast("Source");
System.out.println(seq.getFirst());
System.out.println(seq.reversed());
reversed()返回的是逆序视图,不是复制整个链表,所以对这个视图的修改会反映到原列表上。对双向链表而言,实现逆序视图天然占便宜,因为它可以借助每个节点的prev指针从后往前迭代。而ArrayList实现reversed就要包一个反向索引的视图,虽然也能做,但思维模型上LinkedList和这个接口显然更贴合。
不过这并不改变LinkedList底层“双向链表”的本质,也不解决随机访问慢的问题。接口变了,核心算法和复杂度没变。学习时把JDK 8和JDK 21的都看一遍,会发现Java集合框架整体的演进方向之一就是把分散在List、Deque里的“两端操作”抽象成更通用的顺序集合规范,LinkedList作为既有双向链表结构的类,在这个演进里顺理成章拿到了新能力。
6.2 源码阅读带来的实际收益
作为一个经常跟Java集合源码打交道的人,我最大的体会是:理解LinkedList不能靠背“数组和链表对比”结论,而是要把每个结论都落到源码对应的某一行。
比如“插入快”这个结论,对应的是linkBefore里仅修改pred.next和succ.prev这两三步操作;但又必须同时看到node(index)的折半查找循环,才能解释通为什么“中间插入”整体并不快。再比如“LinkedList可以作为队列”这类说法,对应的是它实现了Deque接口并拥有linkFirst/linkLast/unlinkFirst/unlinkLast这一整套底层能力。结论背后都要能指回源码,才是一个可迁移的知识点,而不是一道面试题的参考答案。
我自己做选型时有一套简单的判断逻辑:如果只需要在头尾做读写,优先考虑ArrayDeque;如果除了双端操作还需要按下标访问、中间插入、允许null元素,那LinkedList确实有它独特的位置。大多数中间件或业务代码里真正高频使用LinkedList的场景,往往不是因为它是“链表”,而是因为它同时具备了双向、双端、迭代器支持完善这几个特性的组合。
最后再送一个关于源码阅读的小习惯
看LinkedList这类集合类源码时,我建议你手边准备一张草稿纸,每遇到一个link/unlink方法就画出前后两个状态下的链表结构图。特别是空链表插入第一个节点、删除唯一节点这两个边界情况,画一遍比读十遍都管用。JDK源码看起来代码量不大,但指针操作非常容易眼高手低。真到面试官让你手写一个双向链表删除方法时,那些在源码里见过的null判断分支、头尾指针更新顺序,会成为你写对代码的底气。把LinkedList读透之后,再回头看ArrayDeque的循环数组、ConcurrentLinkedQueue的无锁链表,会发现集合框架里很多设计都是相通的,那时候你才算真正开始吃透Java集合这块内容。
