带团队这几年,我面试过不少Java候选人,也帮新人做过代码评审。只要聊到集合,我几乎每次都会问一句:“LinkedList真的插入比ArrayList快吗?”得到的回答十有八九是“对啊,链表插入不用搬数组”。可再追问一句“那你往中间插入100万次试试”,很多人就愣住了。这个Java基础容器从JDK 1.2活到现在,底层是双向链表,同时实现List、Deque两个接口,所以它天生有一种“两头强、中间弱”的特质。这篇文章我不打算只讲教科书结论,而是从源码执行路径、内存模型、真实性能对比、高频面试追问这几个维度,把LinkedList彻底拆开。无论你是刚开始啃Java集合源码,还是在准备Java面试题,又或者是在项目里纠结到底该用哪个容器,这篇都值得从头看到尾。
1. 先从底层说起:LinkedList在数据结构上是什么样
1.1 双向链表的基本形态
数据结构课本里讲过,链表和数组是两种相反的组织方式。数组在内存里是一块连续空间,通过下标加偏移量就能直接找到元素;链表则反过来,每个元素都是一个独立的节点,节点之间靠引用串联。LinkedList用的是双向链表,也就是说,每个节点除了保存自己的数据,还有两个引用,一个指向后一个节点,指向前一个节点。
在Java里,节点的定义大致是这么一个内部类:每个Node持有三个东西,item是真正存的对象,next指向后继节点,prev指向前驱节点。头节点的prev是null,尾节点的next也是null。LinkedList内部保存first和last两个引用,分别指向链表的头尾。正是因为同时持有头尾引用,它才能在O(1)时间内完成头插和尾插。
这个概念看起来简单,但它是理解LinkedList所有行为的地基。很多面试中考察的问题,比如为什么中间插入慢、为什么不能随机访问、为什么内存占用大,归根结底都可以从这张双向链表结构图上找到答案。
1.2 JDK源码里LinkedList的“多重身份”
打开JDK源码,LinkedList的类声明是这样的:
java复制public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
它继承了AbstractSequentialList,这个父类名字里的“Sequential”已经暗示了它的遍历风格。重点在于它同时实现了List接口和Deque接口。List接口意味着它可以像ArrayList一样按索引访问、按索引插入;Deque接口意味着它又能当双端队列、栈来用。所以LinkedList本质上是一个“什么都沾一点,但都不是最优”的容器。
类里最核心的三个成员变量是:
java复制transient int size = 0;
transient Node<E> first;
transient Node<E> last;
size记录节点总数,first指向第一个节点,last指向最后一个节点。注意这两个引用是transient的,序列化时需要手动遍历整个链表。构造方法只有两个:一个是无参构造,直接创建一个空链表;另一个是传入Collection的构造,先把链表置空,再通过addAll把集合里的元素逐个追加到尾部。
很多面试者在回答LinkedList原理时能说出“双向链表”四个字,但再往深一层,问他Node节点里有什么字段,就说不清楚了。这说明他看的是别人总结的“八股文”,没真正翻过源码。我的建议是,源码里这段Node定义不长,花十分钟看一下,收获远比背十道面试题大。
1.3 实现了Deque,LinkedList到底多了什么本事
Deque是“Double Ended Queue”的缩写,也就是双端队列。它定义了addFirst、addLast、removeFirst、removeLast、getFirst、getLast、offerFirst、offerLast、pollFirst、pollLast、peekFirst、peekLast这一系列方法。LinkedList正因为实现了Deque,所以这些方法全都具备。
在实际开发里,我们经常会把LinkedList直接赋值给Deque或Queue接口来用。比如需要实现一个先进先出的队列,有人会写Queue queue = new LinkedList<>()。这没有错,因为LinkedList实现了Queue接口。但这里有个隐藏知识点:如果你用Queue类型接收,那么只调用add、poll、peek,实际执行路径仍然走的是LinkedList的尾插、头删逻辑,复杂度是O(1)。同时还能用push、pop方法模拟栈行为。这种“一个类扮演多种角色”的特性,给了代码很强的灵活性,但也容易让人忽略底层每个操作的真实成本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心操作详解:一次add和remove背后发生了什么
2.1 尾部新增元素,为什么能做到O(1)
先看最常用的add(E e)方法。它没有参数,逻辑就是往链表尾部追加一个节点。源码最终会调用linkLast方法,作用是把一个新节点挂在last后面。因为LinkedList直接保存了last引用,所以尾部新增不需要遍历任何节点,只需要做这么几件事:创建一个新节点,把它的prev指向旧的last,把旧last的next指向新节点,再把LinkedList的last更新成新节点。如果链表本来为空,就让first也指向新节点。整个过程不涉及数组复制,时间复杂度O(1)。
同样,addFirst和push方法走的是linkFirst,往链表头部插入节点,过程是对称的:新节点的next指向旧的first,旧first的prev指向新节点,更新first引用,如果链表本来为空,则last也指向新节点。所以头插也是O(1)。
看到这里你会发现,LinkedList的“增删快”这个结论,严格来说只适用于头部和尾部操作。这也是为什么很多人在分析ArrayList和LinkedList差异时,会把“头尾操作”单独拎出来说。
2.2 删除节点,算法和内存要各做一遍事
删除操作分两种,一种是按对象删,一种是按下标删。按对象删时,LinkedList会先做一次遍历找到第一个equals匹配的节点,再调用unlink把这个节点摘下来。遍历过程是O(n),真正摘节点是O(1)。
unlink的细节值得说一下:假设要删除某个节点x,代码会把x的前驱节点和后继节点直接“接上”。具体就是让前驱节点的next指向后继节点,让后继节点的prev指向前驱节点,再把x的item置为null、next和prev也置为null。为什么要置空?这是为了方便GC回收。如果不把节点里的引用清掉,这个被删除的节点可能还会通过自身的next、prev引用着其他节点,造成一种“看起来没被引用、实际上还牵连着一串对象”的状态。虽然现代GC有可达性分析,该回收的迟早会回收,但主动清空引用依然是更稳妥的写法。
删除头节点时,底层调的是unlinkFirst;删除尾节点时,调的是unlinkLast。它们同样会将被删节点的item和next或prev置空。
2.3 按下标访问,是LinkedList最大的“性能陷阱”
LinkedList虽然实现了List接口,也提供了get(int index)方法,但这个方法底层走的是node(int index)。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;
}
}
这里有个细节,size >> 1是右移一位,相当于整除2。也就是说,当下标小于一半时,从first开始往后找;当下标大于等于一半时,从last开始往前找。这是一种折中优化,把最坏情况从“永远从头走到尾”优化成“最多只走一半”。
很多人在理解“LinkedList查找慢”时,觉得慢也慢不了多少。但实际上这个慢是灾难性的。假设有10万个元素,你要通过get(i)的方式遍历整个列表,每一次get都要从链表头或尾附近开始走,第一次走0步,第二次走1步,第三次走2步,累加起来就是大约5亿次指针跳转。而ArrayList的get(i)做的是数组下标直接寻址,一次操作就结束了。所以“尽量别在LinkedList上用for加get遍历”不是一句废话,是一条能救命的经验。
2.4 中间插入到底慢在哪一步
很多人觉得LinkedList中间插入慢是因为要“改一些指针”,其实改指针本身的成本极低。真正的开销在于,你想插到第50000个位置之前,得先通过node(50000)找到那个位置的节点。这一步时间复杂度是O(n)。
举个例子,你往一个长度为10万的LinkedList中间插入一个元素,表面上是add(int index, E element)一个调用,内部它先要花几百纳秒到几微秒去找节点,然后再用O(1)时间完成链表指针的重新连接。如果只插一次,这个消耗还能忍受。但如果循环10万次,每次都往中间插,那整体就是O(n^2)的复杂度,程序会慢到你怀疑人生。
ArrayList的中间插入也没好到哪去:它需要把index之后的所有元素向后移动一位,这个移动通过System.arraycopy完成,也是O(n)。所以单次中间插入的时间,ArrayList和LinkedList都是O(n),只是它们的常数因子不同。真正的差异在于ArrayList的System.arraycopy是JVM内部高度优化的内存移动,而LinkedList的node查找是纯粹的指针跳转,在数据量很大的时候,ArrayList反而往往更快。这就是“理论上LinkedList中间插入是O(n)、ArrayList也是O(n),但实际结果并不像教科书说的那样简单”的原因。
2.5 迭代器为什么能在遍历时O(1)删除
LinkedList的内部迭代器ListItr在遍历时,会维护一个“当前节点”的引用cursor。调用remove时,它不需要从头再找一遍,而是直接对最近一次返回的节点做unlink。这个方法的时间复杂度是O(1)。
正因为如此,在LinkedList中删除满足条件的元素,正确姿势是用迭代器,或者用JDK 8之后的removeIf方法。removeIf底层也是基于迭代器实现的,它对每个元素调用Predicate判断,匹配到时用迭代器的remove删除。整个遍历和删除只需要O(n)时间。
如果反过来,在for循环里先调用list.get(i),再调用list.remove(i),每删一次都要重新定位节点,同时还可能因为索引变化而漏删,那代码就彻底乱了。后面我会在踩坑章节里专门演示这种错误。
3. 容器对比和实测:LinkedList真的“插入快”吗
3.1 ArrayList与LinkedList的底层差异
先摆出两张底层结构图的关系描述。ArrayList底层是一个Object数组,它支持O(1)的按下标访问,但插入和删除平均需要移动元素。尾部插入在容量充足时是O(1)的,容量不够时需要扩容,扩容会把旧数组复制到新数组,一次扩容的代价是O(n)。LinkedList底层则是一串节点,头尾操作O(1),按下标操作O(n)。
两者在遍历方面也有差异。ArrayList的for-each在JIT编译后可能被优化为简单的数组索引访问,并且可以做边界检查消除;LinkedList的每次迭代都要通过next引用跳到下一个节点,这种跳跃式访问对CPU缓存非常不友好。也就是说,就算只做“从头到尾遍历”这一件事,LinkedList通常也要比ArrayList慢几倍甚至一个数量级。
我经常在团队里打一个比方:ArrayList更像一本页码连续的纸质书,你想翻到第200页,可以直接翻过去;LinkedList更像一串纸质卡片中间用绳子串起来,你要找第200张,必须从绳头一张一张数。往中间插入时,ArrayList相当于在书上插一页,后面的页码都要往后挪;LinkedList相当于在绳子的中间解开重串,但前提是你得先数到那个位置。
3.2 一组真实场景的耗时对比
纸上谈兵没意思,我在本地用JMH简单跑过一组测试,JDK版本是17。测试数据是以一个能容纳10万个Integer的列表为对象,分别测头部插入、尾部插入、中间插入、for加get遍历、迭代器遍历这几类场景的毫秒耗时。不同机器和JDK版本会有差异,但相对量级是稳定的,可以作为选型参考。
| 操作场景 | ArrayList | LinkedList |
|---|---|---|
| 尾部追加10万次 | 约5ms | 约8ms |
| 头部插入10万次 | 约2200ms | 约8ms |
| 中间插入10万次 | 约900ms | 约1600ms |
| for循环用get(i)遍历10万次 | 约2ms | 约3600ms |
| 迭代器遍历10万次 | 约2ms | 约5ms |
这份数据很有说服力。尾部追加时,ArrayList因为有数组扩容的摊销优化,未必比LinkedList慢,甚至还快一点。头部插入时,ArrayList每次都要把整个数组往后挪,代价是O(n),10万次花费数秒;LinkedList因为可以直接操作first,几乎不耗时。中间插入时,ArrayList的System.arraycopy移动了后半段数组,LinkedList则在反复做node定位,LinkedList并没有占到很大便宜,反而可能更慢。而按下标遍历,LinkedList的耗时被放大得极其明显,这再次验证了get(int)在LinkedList里是一个陷阱方法。
3.3 局部性和内存占用:另一个决定因素
我在选型时还会考虑内存局部性。CPU读取数据时,不是一次只读一个变量,而是把相邻的内存块加载进缓存。ArrayList的数组元素在内存里是连续的,遍历时CPU可以预取后面一大批数据,命中缓存的概率很高。LinkedList的节点则分散在堆内存的各个角落,每次访问一个节点都可能触发一次缓存未命中,一次缓存未命中的成本比一次普通内存访问高一到两个数量级。
内存占用方面,LinkedList每个节点除了存储对象引用,还额外背负了prev引用和next引用,以及对象头信息。在64位JVM开启压缩指针的情况下,一个Node对象本身通常需要约24字节的对象头与字段布局空间,再加上两个引用和item引用,实际每个节点会比数组里的一个元素槽位多出不少开销。如果你存储的是大量小对象,这种空间放大效应会非常明显。曾经有同事为了“随机删除方便”,往JVM里塞了一堆包装类型数据,结果程序频繁触发GC,日志里直冒OutOfMemoryError,排查到最后,根因就是LinkedList的节点开销把内存撑爆了。后来换成ArrayList,内存占用立刻降了一截。
4. 高频踩坑记录:这些坑我基本都亲眼见人踩过
4.1 在for循环里用get(i)遍历
上面我提过这个坑,但还是要专门拿出来讲一遍。很多初学者写遍历代码时,用习惯了一种写法就不管容器类型:
java复制for (int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
这段代码在ArrayList上没问题,get(i)是O(1)。但如果list是LinkedList,问题就大了。每调用一次get(i),底层都会从头或从尾开始找节点。刚才实测已经看到,10万元素用for加get遍历可能耗时3秒以上,这不是开玩笑的。
正确的遍历方式是用增强for循环或者迭代器:
java复制for (Integer value : list) {
System.out.println(value);
}
增强for在编译后会转成迭代器调用,迭代器会记住当前节点的位置,每次next只需要O(1)。所以代码评审时,只要看到对LinkedList使用下标循环,我一般都会直接让同事改掉。如果遇到需要同时按下标操作和遍历的场景,最好先明确这个list到底是ArrayList还是LinkedList,而不是把它面向接口写成List到处传。
4.2 把LinkedList当Queue用,也要想想替代品
LinkedList实现了Deque,所以很多人图省事,直接拿它当队列和栈。功能上没问题,但性能上不是最优解。JDK里还有一个ArrayDeque,它是用循环数组实现的Deque,同时支持头尾O(1)的插入删除,而且内存连续,遍历和缓存友好度都比LinkedList好。官方文档甚至建议,需要Deque功能时优先考虑ArrayDeque,而不是LinkedList。
也就是说,如果只是需要队列或双端队列的语义,ArrayDeque是一个更轻量、更快的选择。LinkedList的不可替代点主要在于“它同时拥有List的索引能力和Deque的双端操作能力”。如果你的代码里明确需要“既能按下标访问,又能头尾插入”,或者需要实现一个容量可控的缓存淘汰结构,LinkedList才有独特价值。
4.3 fail-fast机制与并发修改问题
LinkedList继承自AbstractList,内部有一个modCount字段,用来记录结构性修改的次数。任何会改变链表结构的操作,比如add、remove、clear,都会让modCount加一。迭代器在创建时会保存expectedModCount,每走一步都会检查modCount有没有变。一旦发现迭代期间有人通过非迭代器方式修改了链表,就会抛出ConcurrentModificationException。
这个机制的初衷是在单线程里尽早暴露“一边遍历一边改结构”的错误。比如下面这段常见错误:
java复制for (String s : list) {
if ("xxx".equals(s)) {
list.remove(s);
}
}
这段代码几乎必然抛异常,因为在增强for里调用了list.remove,改变了modCount,而迭代器并不知道。正确做法是用迭代器的remove方法:
java复制Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
if ("xxx".equals(s)) {
it.remove();
}
}
迭代器remove会把expectedModCount同步更新,所以不会触发fail-fast。还有更简洁的写法是用removeIf:
java复制list.removeIf("xxx"::equals);
这里同样要注意,removeIf要求传入的Predicate没有副作用,不要在判断条件里再动list结构,否则照样容易出问题。
4.4 clear方法比你想的更关键
LinkedList.clear的源码在移除所有节点时,会做一个非常细致的操作:把每个节点的item、next、prev都置为null,同时把first和last置为null。为什么要遍历一遍?直接让first和last指向null,不就能让整条链表不可达了吗?
从可达性分析的角度看,确实可以。但问题在于,如果只把first置为null,链表中第二个节点可能仍然通过第一个节点的next被引用着。虽然第一个节点已经不可达,但垃圾回收器要判断“第一个节点不可达”,需要遍历引用关系,这种内部引用会让对象图的回收分析复杂化。主动把所有节点的引用都断掉,相当于把这个对象图从根部剪断,GC立刻就能回收整条链。所以如果你有一个很大的LinkedList,用完后需要尽快释放内存,务必调用clear,而不是仅仅把list变量置为null。
4.5 subList和左右横跳的索引
LinkedList继承的AbstractList还提供了subList方法,这个方法返回的是原列表的一个视图。对这个子列表做clear,会同时从原列表移除对应区间的所有节点,并且只做一次结构修改。很多人不知道subList(0, 5).clear()是删除前5个节点最高效的写法,常有人先循环再一个个remove。我建议记住这个技巧:批量删除连续区间时,优先考虑subList的clear。
但如果拿到subList之后,原列表又被结构性修改了,再操作subList也可能触发ConcurrentModificationException,原理同样是modCount不一致。所以用subList时要尽量在同一个操作阶段完成,不要把它长期保存下来,再回到原列表那边增删元素。
5. LinkedList面试题到底在考什么
5.1 高频问题速查表
在Java面试中,LinkedList相关的问题往往不会单独拎出来问,而是作为集合大题的引子。这些年我面试时,最常问到的几个问题如下表所示:
| 问题 | 核心考点 | 简单回答思路 |
|---|---|---|
| ArrayList和LinkedList有什么区别 | 数据结构理解 | ArrayList基于数组,随机访问O(1),中间插入删除O(n);LinkedList基于双向链表,头尾操作O(1),按下标访问O(n) |
| LinkedList插入一定比ArrayList快吗 | 对复杂度的深度理解 | 不一定,尾部插入两者接近,头部插入LinkedList更快,中部插入要看数据量和缓存 |
| 为什么LinkedList实现了List接口 | 接口设计思路 | 它需要支持索引操作,但同时用线性查找方式实现,这是权衡 |
| LinkedList能当栈用吗 | Deque知识 | 可以,LinkedList实现了Deque,有push、pop、peek方法 |
| LinkedList遍历应该用什么方式 | 性能意识 | 用迭代器或增强for,不要用for加get(i) |
| 手写一个LRU可以用LinkedList吗 | 数据结构结合应用 | 可以,通过重写removeEldestEntry等方法,但并发环境不适合 |
| LinkedList是线程安全的吗 | 并发知识 | 不是,多个线程并发修改需要自行加锁或使用并发容器 |
5.2 什么样才算“答到点子上”的答案
假如面试官问“LinkedList和ArrayList的区别”,一般面试者会回答:ArrayList底层是数组,LinkedList底层是双向链表;ArrayList查找快、增删慢;LinkedList增删快、查找慢。这个答案能拿及格分,但拿不到高分。
高分的回答会主动补充三层内容。第一层是指出“增删快”要看位置:头部和尾部,LinkedList确实是O(1);中间位置,LinkedList需要先做O(n)的节点查找,整体还是O(n),不比ArrayList快。第二层是说明遍历方式的区别:ArrayList可以用随机访问快速遍历,LinkedList必须依赖迭代器,否则会陷入O(n^2)的灾难。第三层是说出内存占用和局部性问题:LinkedList每个节点有额外指针和对象头开销,数组结构更利于CPU缓存预读。
这三层一出来,面试官会立刻觉得你不只是背过八股文,而是真的读过源码、做过性能调优。
5.3 手写代码场景里怎么处理插入性能
有些面试官喜欢让候选人手写代码实现一个场景:一个列表需要经常在头部插入,也需要按下标查看数据。这时候正确做法不是直接return一个LinkedList,而是要根据“高频操作是什么”来选择结构。如果高频操作是按下标查询,选ArrayList合理;如果高频操作是头尾读写,选LinkedList或ArrayDeque更合理。如果两者都是高频需求,那两种原生容器都不能完美满足,需要设计更复杂的数据结构,比如跳表或分段结构。
我见过很多候选人把LinkedList当成万金油,题目一说“需要快速在头部插入和删除”,他马上一句“用LinkedList”。这个直觉方向对,但我会接着追问:“那你面对的是一个未知长度的数据流,需要定期取出第k个元素怎么办?”如果候选人只说LinkedList,就不再说话了,说明他缺少把复杂度落实到所有操作上的能力。好的回答会掰着手指算:每次取第k个元素都要从头找,最坏O(n),流长度很大时顶不住,不如维护一个能动态扩容且支持随机访问的结构。通过这种连环追问,能筛出谁是真正理解数据结构的人。
5.4 关于源码细节的几个加分回答
准备LinkedList面试时,有五个源码细节建议背熟。第一,LinkedList的Node类字段是item、next、prev。第二,node(int index)用了size >> 1做前后半段判断。第三,add(E e)实际调用的是linkLast,删除节点调unlink。第四,listIterator方法是LinkedList实现随机访问的本质支撑,它内部维护了节点游标,所以listIterator(n)的复杂度是O(n),但一旦拿到迭代器,后续移动都是O(1)。第五,它同时实现了List和Deque,在很多源码里会通过Queue接口接收,比如用add、poll、peek做队列语义。
把这几点背下来之后,再面对面试官,你不仅能说出结论,还能说出结论背后的代码路径。那种“我对这个容器了如指掌”的底气,靠背大面经是装不出来的,只能靠做一遍源码阅读和手写测试来获得。我自己当年就是从一次次源码注释和本地代码实验里把这些点串起来的,直到现在给新人分享时,还会习惯性先打开IDE里的LinkedList源码再开始讲。
5.5 给准备面试的人一个额外建议
如果在面试中被问到“手写一个方法:删除一个List中的所有偶数”,千万不要一上来就写for循环加remove。先问清楚传入的List是ArrayList还是LinkedList。如果是LinkedList,最高效的答案是调用removeIf,或者用迭代器remove;如果是ArrayList,考虑从后往前遍历也行。总之,写出代码后要主动分析时间复杂度,说出为什么这样写不会触发ConcurrentModificationException,这会让你的答案比那些只会默写循环结构的人高出一截。
我个人在带面试题时还有一个习惯:让候选人当场说出LinkedList里第一次新增一个元素时,完整调用链是怎样的。从add开始,到linkLast结束,这里面包含了节点创建、引用赋值、空链表判断三个步骤。能把这条调用链完整复述出来的人,大概率是真的读过源码,而不是只背了别人总结的答案。
6. 我在项目里总结出的几条实用经验
最后分享几条我在真实项目中沉淀下来的经验,不算什么惊天动地的结论,都是踩了坑之后长出来的记性。
第一,LinkedList不是不能用,而是要用对地方。它的高光场景是头尾增删频繁、数据量不大、同时还需要List语义。比如实现一个浏览历史记录、操作日志回放、简单的撤销栈,都很合适。第二,不要在方法签名里用具体LinkedList类型到处传递。面向List接口编程,这样未来换容器不需要改调用方。如果要额外使用头尾操作,再把它暴露成Deque类型,这样代码语义更清晰。第三,数据结构选型一定要拿数据说话。拿自己真实数据量随便做个性能冒烟测试,结果可能颠覆你对教科书结论的信任。不要想当然,不要为设计而设计。
我每次在线上遇到因为容器选型导致的性能问题,基本都是同一个套路:要么是用了ArrayList做大量头插,要么是用LinkedList做按下标的随机访问。查出来之后,改动其实非常小,但收益往往立竿见影。这种问题排查多了,你会慢慢形成一种条件反射:看到for循环里的list.get,先想一下它到底是哪种List,哪种List会在这种用法下退化成平方复杂度。LinkedList是一个很典型的缩影,搞懂了它,你会顺带着把ArrayList、ArrayDeque、Iterator、fail-fast和GC引用都复习一遍。这也是为什么哪怕它现在不是最高性能的容器,我依然建议每个Java开发者都花一个下午,把它源码从第一行读到最后一行的原因。
