我先把话放在前面:如果你是那种把LinkedList当成"增删快、查询慢"一句话背完就觉得自己会了的人,那这篇文章可以帮你把那层窗户纸捅破。真正动手写过、看过源码、被性能问题坑过的人都知道,LinkedList远不止一个"双向链表"的标签那么简单,尤其是它的头尾指针设计,直接影响着每一个API的时间复杂度、遍历方式,甚至你在面试里能不能给出让人眼前一亮的答案。
作为一个用Java写了多年业务代码、也带过新人、也刷过不少面试题的人,我今天想把LinkedList从头到尾拆一遍。不是那种贴几段源码然后翻译成人话的水文,而是真正把我自己踩过的坑、绕过弯路、实测过的数据、以及面试里真正会被追问的点,一次讲清楚。你如果正在准备Java面试,或者在纠结某个场景到底该用ArrayList还是LinkedList,又或者想搞明白"头尾指针"到底神奇在哪,这篇文章就是给你准备的。
1. 从Node到first/last——LinkedList的骨架到底长什么样
1.1 每个节点其实是一个"三指针结构"
很多人第一次看LinkedList源码的时候,会被Node<E>这个内部类搞得一头雾水。其实它特别简单,你把它想象成一个“带前后指针的小盒子”就行:
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;
}
}
对比一下单链表里的节点,通常只有data和next两个字段,只能从头往后走。而JDK的LinkedList用的是双向节点,每个节点同时知道自己的前驱和后继。这意味着你可以从尾部往前遍历,也可以从中间任意一个节点同时往两边走,这是"含头尾指针"这个设计能够成立的地基。
我第一次自己手写链表的时候,只写了一个头指针,结果每次在尾部插入都要从头遍历到尾,那个时间复杂度让我怀疑人生。后来一查JDK源码,才发现人家直接维护了first和last两个指针,把头尾操作的时间复杂度全部压到了O(1)。这个差距,在你实现一个高频插入的队列场景时会体现得非常明显。
1.2 链表对象本身只有三个字段
LinkedList实例本身没多少东西,JDK 8里的字段就这三个:
java复制transient int size = 0; // 节点个数
transient Node<E> first; // 头指针
transient Node<E> last; // 尾指针
你没看错,整个LinkedList的"全部家当"就是这三个字段。size记录元素个数,first指向第一个节点,last指向最后一个节点。当链表为空时,first和last都为null。
这正是"含头尾指针"这个设计的精妙之处:它让LinkedList同时具备了栈、队列、双端队列的潜质。addFirst()只需要操作first,addLast()只需要操作last,两边都是O(1)。而如果你只维护一个头指针,那尾部插入就退化成了O(n),LinkedList最大的优势就直接没了。
我用一个生活化的类比帮你理解:单链表就像一条单行道,你开车只能从头到尾一路开过去,想掉头必须从头再来;而JDK LinkedList的双向链表加头尾指针,等于在路的两端各修了一个入口,而且每条路都是双向的,你从哪边进都能走,想中途拐弯也随时可以。
1.3 为什么头尾指针是"线性表"场景下的最优解
线性表的特点是元素之间有"一对一"的逻辑关系,链表和顺序表(对应ArrayList)是最典型的两种实现。顺序表用连续内存存储,随机访问快,但中间插入删除要搬移元素;链表用离散节点存储,插入删除天然灵活,但随机访问慢。
LinkedList把头尾指针都挂上,本质上是把"线性表两端操作"这个最常用的场景优化到了极致。你想想平时用List最频繁的操作是什么:尾部追加、头部弹出、判断空、遍历。而这些操作在LinkedList里,全部都是O(1)或者O(n)但常数极小。特别是它实现了Deque接口之后,addFirst、addLast、pollFirst、pollLast这些双端操作全都有,当队列、当栈都行。
这点也是面试里容易忽略的加分项:很多人只知道LinkedList是List,不知道它同时还是Deque的实现类。就这一个知识点,就够你在回答"LinkedList有什么特点"时比别人多一个层次。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. add/remove方法背后的指针操作,每一行都要看懂
2.1 linkFirst和linkLast——头尾插入为什么是O(1)
看JDK源码,addFirst()最终会调linkFirst(),addLast()和默认的add()会调linkLast()。这两个方法才是LinkedList头尾插入的核心:
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++;
}
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++;
}
核心逻辑就四步:记住原来的头/尾,创建新节点,把新节点挂到边界,让原来的头/尾的prev或next指向新节点。全部是常数级操作,不需要遍历,不需要搬移,这就是O(1)的底气。
你可能会问,modCount++是干什么的?这是用来实现fail-fast机制的。当迭代器遍历过程中检测到modCount变了,就会抛ConcurrentModificationException,防止你在遍历的同时修改结构。这个我在后面遍历部分会详细说,因为这是面试里一个很爱问的点,也是实际开发里一个让人头疼的坑。
2.2 中间插入:先定位,再linkBefore
中间插入就没有那么爽快了。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));
}
这里最关键的是node(index)方法。它不是随机访问,而是从头部或尾部根据index的位置选择更近的一头开始遍历:
java复制Node<E> node(int i
