我之前遇到过一件挺有意思的事。有位同事在处理一个“按索引频繁取值”的业务接口时,用了LinkedList来存数据,结果接口一压测就卡得肉眼可见。他也很委屈:网上不是说LinkedList增删快、链表结构很灵活吗?问题就出在这里——“增删快”和“按索引取数快”是两码事。LinkedList这个集合类,在Java里几乎是被误解最多的一个,八股文背起来头头是道,落到真实代码里却经常被用错方向。
这篇不从面试题角度复述,而是把LinkedList的底层实现、每个核心方法的时间开销、和ArrayList的实际性能差距、以及工程上最容易踩的坑,一次性讲清楚。你如果是写业务代码的Java工程师,或者正在准备面试想弄懂“为什么”,又或者刚被某个性能问题折腾过,这篇文章应该能帮你省下不少排查时间。
1. 先聊清楚:双向链表的底层结构,决定了它只能“偏科”
1.1 数组和链表,本质上是两种世界观
要理解LinkedList,先得理解它和ArrayList在内存布局上的根本差异。ArrayList底层是一块连续内存,内部就是一个Object[]数组。这种结构带来的最大好处是“按下标寻址”:只要知道起始地址和下标,一次指针运算就能拿到元素,所以get(i)的直接代价是O(1)。缺点是,当你在头部插入或删除元素时,必须把后面所有元素整体搬移,数组越大搬移成本越高,所以add(0, e)的代价是O(n)。
LinkedList就不一样了,它的底层是一个双向链表。所谓双向链表,就是每个元素不再紧挨着另一个元素存放,而是自己独立占一个“节点”对象,节点里存三个东西:真正的数据、指向前一个节点的引用、指向后一个节点的引用。这些节点散落在堆内存里,通过引用串联成一条链。你拿着第一个节点,就能顺着引用走到最后一个节点;你也可以从最后一个节点逆着走回第一个节点。
这种结构的好处是,插入和删除只需要调整相邻节点的引用指向,不用搬移任何数据,理论上是O(1)级别的操作。但代价也很明显:想要拿到“第i个元素”,你必须从链表头或者链表尾一个个节点走过去,走到第i个为止。这就是为什么LinkedList的get(i)会是O(n),和ArrayList的O(1)完全不在一个量级上。
1.2 LinkedList真正擅长的事
延续上面的结构分析,LinkedList最舒服的操作其实是“在已知位置插入/删除”,尤其是“在链表头部插入”和“在链表尾部插入”。
- 头部插入:新建一个节点,让新节点的next指向原来的first节点,再让原first节点的prev指向新节点,最后更新first指向新节点。整个过程只涉及固定几个引用操作,不涉及任何遍历。
- 尾部插入:同理,只需要操作last节点和它的next引用。
- 头部删除、尾部删除:同理,都是O(1)。
这四类操作正好对应了队列和栈的核心语义:offerFirst、offerLast、pollFirst、pollLast。所以可以这样理解:LinkedList虽然实现了List接口,但它真正的性能舒适区,是作为双端队列来用。
同样,如果你在ArrayList的头部做插入操作,每一次都要把原数组里所有元素往后挪一位,数据量一上来,这个O(n)操作的成本会非常显眼。这一点正是很多“LinkedList适合频繁插入”说法的来源。
1.3 那些“听起来很美”的优点,其实经不起细看
“LinkedList适合频繁插入”这句话本身没错,但它常常被过度泛化,导致很多人以为“用LinkedList替换ArrayList就万事大吉”。
首先,LinkedList的“插入快”默认说的是“已知插入位置”的情况,比如linkBefore、linkLast这种已经拿到了目标节点的内部操作。但如果你是调用add(int index, E element)来插入,那么首先要做一步node(index)去找到那个下标对应的节点,这一步本身就是O(n)的遍历。也就是说,在中间的随机位置插入,整个操作还是O(n)的复杂度。它省的只是“插入动作”本身,并没有省掉“找位置”的时间。
其次,尾部插入这个场景,LinkedList未必比ArrayList快。ArrayList的扩容虽然会有数组复制,但均摊到每次添加操作上,依然是常数级别的时间。更重要的是,ArrayList的操作是在一块连续内存上进行的,CPU缓存命中率很高;而LinkedList每次添加都要new一个Node对象,节点分布在堆上,内存地址不连续,缓存命中率低。所以实测中,在尾部循环添加元素时,ArrayList经常比LinkedList更快,而不是更慢。
这一点不搞清楚,后面性能调优时很容易做出南辕北辙的替换。
2. 源码级别解读 add/remove/get:每次操作的真实开销
2.1 先从节点对象说起
在JDK源码里,LinkedList的内部节点是用一个静态内部类Node<E>来表示的:
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是LinkedList内部自己维护的基础结构,对外不可见。所以外部使用时,你完全感觉不到“节点”的存在,你操作的是List接口的方法。但一旦理解了Node的构成,理解LinkedList的几乎所有方法就水到渠成了:增删改查的本质,全是围绕“调整first、last以及相邻节点的prev/next指针”展开。
LinkedList本身还维护了两个关键字段:first和last,分别指向链表的头节点和尾节点;size记录节点数量。值得注意的是,JDK里的LinkedList并不是“循环链表”,first.prev是null,last.next也是null,链表在两端是开口的。
2.2 add(E e):为什么大多数时候是O(1)
调用add(E e)时,默认往链表尾部追加元素,底层走的是linkLast(E e):
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++; }这段代码里最重要的一个细节是:如果链表为空,那么first和last同时指向新节点。如果不为空,只需要让原来的尾节点的next指向新节点,再更新last即可。整体没有遍历,所以是O(1)。
modCount++这里先记个印象,后面讲并发修改异常时还要用到。
2.3 add(int index, E element):中段插入的隐藏成本
按下标插入时,JDK先检查下标合法性,然后做了一步判断:
public void add(int index, E element) { checkPositionIndex(index); if (index == size) linkLast(element); else linkBefore(element, node(index)); }这里真正的开销在node(index)。它能在一半的情况下提高一些效率,因为内部做了一个“从哪头走更近”的判断:
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; } }如果下标在链表前半段,就从first往后找;如果下标在后半段,就从last往前找。这个优化让随机定位的常数系数变小了,但复杂度仍然是O(n)。
所以,如果你想“在LinkedList中间批量插入元素”,每次插入仍然要付出O(n)的定位代价,整体并不快。真正适合的场景是,你已经持有某个节点的引用,并且使用迭代器在这个节点前后插入,那样才能发挥链表的指针拼接优势。
2.4 remove(Object o):一次典型的线性扫描
remove(Object o)的源码同样值得读一遍:
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; }它分别处理了“待删除元素是null”和“待删除元素非null”两种情况,原因很简单:不能让o.equals(x.item)在o为null时触发空指针。删除操作本身unlink(x)是O(1)的,但找到这个x需要从头遍历,所以整体是O(n)。
这里有个比较容易忽略的点:它删除的是“第一个匹配到的元素”。如果链表里存在多个相等的元素,remove(Object)只会删掉最靠近头部的那一个,后面的不会被处理。
2.5 get(int index):性能黑洞所在
get(int index)最终的实现还是调用node(index),也就是那段“从first往后走”或者“从last往前走”的线性遍历。单次访问的耗时看起来不像灾难,但如果在一个长度为10万的LinkedList上循环get(i),那就是1+2+3+...+10万次指针跳跃,总量级是5亿次引用操作,和ArrayList的10万次直接数组寻址差了好几个数量级。
这个差异在代码评审中经常看到:有人为了“插入方便”选了LinkedList,然后又在循环里按下标取数据。用LinkedList做随机访问,等于你每次取值都要额外付出O(n)的定位成本,这在数据量上了万之后会非常明显。
2.6 clear()里的小细节
clear()源码是这样的:
public void clear() { for (Node<E> x = first; x != null; ) { Node<E> next = x.next; x.item = null; x.next = null; x.prev = null; size = 0; first = last = null; modCount++; } }注意两点:
- 它会遍历所有节点,把每个节点的
item、next、prev都置为null,不光是设置first = last = null。原因是避免“长链表的尾部节点被老引用持有”导致内存无法释放的问题。 - 清空操作本身是O(n),所以如果你的目标只是“快速清空一个大列表”,LinkedList并不比ArrayList占便宜太多。
3. 自己做一轮基准测试:10万条数据下,快慢一目了然
3.1 测试的思路
先说明,最严谨的是用JMH这类基准测试框架,它会处理JIT预热、分支预测等干扰。但为了直观理解,我们可以先用一段普通的循环计时代码感受差距。测试环境大致是:JDK 17,默认JVM参数,数据规模10万个元素,每个操作重复多次后取相对值。
这一步你不必完全照搬参数,关键是理解两种集合在不同操作上的“相对表现”,这比记住某个绝对数字有意义得多。
3.2 头部插入:LinkedList的碾压时刻
在ArrayList的头部插入10万个元素,意味着每次插入都要把已有元素整体后移,10万个元素的插入总成本接近 O(n²),非常痛苦。而LinkedList的头部插入是O(1),加上遍历成本也只有O(n)。
下面是简化的测试片段:
List<Integer> arrayList = new ArrayList<>(); // 从头部逐个插入 int n = 100_000; long start = System.nanoTime(); for (int i = 0; i < n; i++) { arrayList.add(0, i); } long cost = System.nanoTime() - start;换成LinkedList只需要把等号右边换成new LinkedList<>()。实测下来,ArrayList的耗时通常是LinkedList的几十倍甚至上百倍,数据量越大差距越夸张。如果你需要在列表头部频繁插入,LinkedList是一个非常合理的答案。
3.3 尾部插入:反直觉的接近甚至落后
再测尾部插入,也就是普通的add(e):
- 预期结果是LinkedList可能略慢,因为每次要
new Node,内存分配和指针写入都有开销。 - ArrayList绝大多数时候都很快,扩容虽然涉及复制,但均摊成本很低,而且连续内存的局部性让CPU访问效率更高。
所以如果你看到网上有人说“LinkedList插入一定比ArrayList快”,可以告诉他:头部插入确实如此,尾部插入可真不一定。我自己跑出来的数据里,比如10万元素尾部添加,ArrayList约10ms左右,LinkedList约15ms左右,数据量越大,这个微弱的差距往往会继续维持,而不是被拉开。真正的差距主要集中在“按中间位置插入”和“按对象删除”这种需要定位的操作上,两者的复杂度其实都是O(n),常数上的差异取决于定位的代价。
3.4 随机访问:一个数量级的碾压
再测试循环get(i)遍历全部元素:
List<Integer> linkedList = new LinkedList<>(); // 先放满10万个元素 long start = System.nanoTime(); for (int i = 0; i < n; i++) { linkedList.get(i); }ArrayList的耗时可能是几毫秒到十几毫秒,LinkedList的耗时则是数百毫秒甚至更高。原因就是前面讲的node(index)每次都要遍历。这种场景下,LinkedList的随机访问性能完全无法和ArrayList抗衡。
所以,我一直建议团队里的同学记住一句话:如果代码里有“按索引频繁读取”,尤其是遍历整个List的逻辑,默认选ArrayList。除非你的性能热点明确集中在头部插入/删除、双端操作,否则不要轻易用LinkedList代替ArrayList。
3.5 内存开销:Node对象是个隐形大户
除了时间,内存模型也值得关注。ArrayList的10万条Integer,底层是一个长度为10万左右的Object[],每个槽位是一个4字节的引用。加上数组本身的开销,整体很紧凑。
LinkedList的10万条数据,除了数据本身,还要创建10万个Node对象。在开启压缩指针的常用JVM配置下,每个Node对象的大小通常在16字节到24字节左右,叠加上两个引用字段和一个数据字段,10万个节点的额外内存开销可能达到百万字节级别。如果你的容器内存紧张,或者数据规模很大,这个差距不能忽视。
简单记忆:Node对象引用字段多,存储同样数量的元素,LinkedList整体内存占比通常比ArrayList高出数倍。这也是为什么很多中间件底层更愿意用数组和环形缓冲,而不是无脑上链表。
4. 遍历删除与并发修改:最容易出事的几个坑
4.1 为什么增强for循环删除会抛异常
先看一个常见错误:
LinkedList<String> list = new LinkedList<>(); list.add("a"); list.add("b"); for (String s : list) { list.remove(s); // 大概率抛 ConcurrentModificationException }很多人不理解:单线程代码,为什么叫“并发修改”?其实这里的ConcurrentModificationException是Java集合框架的一种“快速失败”机制。它在源码层面通过modCount字段实现:每次对集合做结构性修改(增、删、清空等)时,modCount都会增加;迭代器在创建时会记住当时的modCount到expectedModCount,每次迭代都检查两者是否一致,不一致就认为“有别人在偷偷改结构”,直接抛异常。
标志性的源码在ListItr.next()和ListItr.previous()里的检查:
checkForComodification();这个机制本身是为了防止迭代过程中结构发生变化导致不可预期的行为。虽然它的名字叫“并发修改”,实际它也拦截单线程里“一边迭代一边修改”的错误用法。
4.2 正确的删除姿势
如果你要在遍历过程中删除元素,推荐三种方式:
- 使用迭代器的
remove()方法:
LinkedList<String> list = ...; Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if ("b".equals(s)) { it.remove(); } }原因是迭代器的remove()内部在删除后会把expectedModCount重新赋值为当前的modCount,这样后续检查不会误报。
- 使用JDK 8引入的
removeIf():
list.removeIf(s -> s.equals("b"));它的底层也是通过迭代器实现,代码更简洁,适合按条件批量删除。
- 倒序删除:
for (int i = list.size() - 1; i >= 0; i--) { if (condition) { list.remove(i); } }倒序删除避开了“删除后下标前移导致跳过元素”的问题。但要注意,对于LinkedList,remove(int index)内部还是要做一次定位遍历,总体依然是O(n)级别的。
最不推荐的是正序for (int i = 0; i < list.size(); i++)配合remove(i),因为删除后后续元素下标前移,很容易漏删或者越界。
4.3 LinkedList不是线程安全容器
LinkedList所有方法都没有加锁。多个线程同时写入,轻则数据错乱,重则链表结构被破坏,出现节点丢失、循环引用之类的问题。这一点在源码里没有任何防护,官方文档也明确说了它不是线程安全的List。
如果你确实需要多线程共享同一个列表,常见选择有:
Collections.synchronizedList(new LinkedList<>()):给方法加锁,但迭代时仍需手动加锁;CopyOnWriteArrayList:读多写少场景;- 明确是队列语义的话,还可以考虑并发队列。
拿它当队列用的时候,我建议先想清楚:到底是要LinkedList,还是要ArrayDeque,或者要ConcurrentLinkedQueue/ArrayBlockingQueue这些带并发语义的容器。用错容器会产生隐性问题,尤其在多线程环境下很难排查。
4.4 容易被忽略的:ListIterator的反向遍历
LinkedList的listIterator(int index)支持从任意位置移动游标。你可以这么用:
ListIterator<String> it = linkedList.listIterator(linkedList.size()); while (it.hasPrevious()) { String s = it.previous(); }这在“从尾部向前处理”的场景里很方便,而且利用的是双向链表的prev指针,不会像get(i)那样从头重新遍历。如果你需要在LinkedList上频繁反向访问,优先考虑反向迭代器,而不是get(index)倒序循环。
不过注意:如果正序/反序遍历过程中要删除,也还是要通过迭代器的remove(),规则和前面一样。
5. 工程场景里的正确打开方式
5.1 声明类型时多用接口
LinkedList既然实现了List、Deque、Queue等多个接口,声明变量类型时就可以更灵活:
Deque<String> deque = new LinkedList<>(); Queue<String> queue = new LinkedList<>(); List<String> list = new LinkedList<>();我个人的习惯是,除非实现代码里明确需要LinkedList独有的方法,否则变量声明尽量用接口类型。这样后续切换实现类,对调用方几乎没有影响。比如从LinkedList换成ArrayDeque,只要声明的类型是Deque,改一行构造方法就行。
5.2 一个极简LRU缓存雏形
LinkedList作为双端队列,很适合写一个“最近访问记录”的雏形。思路是:新访问的元素放到队头,如果容量已满,就淘汰队尾最久没访问的元素。
class SimpleLRUCache { private final int capacity; private final Deque<String> deque = new LinkedList<>(); public SimpleLRUCache(int capacity) { this.capacity = capacity; } public void access(String key) { deque.removeFirstOccurrence(key); deque.addFirst(key); if (deque.size() > capacity) { deque.removeLast(); } } public List<String> snapshot() { return new ArrayList<>(deque); } }removeFirstOccurrence是Deque接口提供的方法,底层会从头遍历找到第一个匹配并删除,这正是LinkedList适用的小数据量场景。生产上缓存组件一般会选LinkedHashMap或者专业的缓存库,但作为理解LinkedList双端操作的一个例子,这段代码能很直观地展示它的价值。
5.3 一个“既要双端操作、又要按下标访问”的场景
如果纯粹当队列用,单线程下我更推荐ArrayDeque,它内部是循环数组,内存更紧凑,遍历和随机访问都比LinkedList快。那LinkedList到底什么时候比较合适?答案是:你同时需要双端操作和“偶尔按索引访问”的时候。
比如某个管理后台要展示“最近操作记录”,头部只会不断新增,尾部偶尔会因超长被裁剪,同时前端又要按分页索引读取最近50条。这种混合操作,LinkedList可以一把梭,代码看起来也很自然。当然如果记录量极大,更好的方案是用数据库或专门存储,但如果是一个JVM内的轻量级需求,LinkedList确实够用。
5.4 果断放弃LinkedList的场景
我再列几个容易识别信号。只要命中其中一条,优先考虑ArrayList:
- 主要操作是随机读取,比如大量
get(i)、分页查询; - 需要对列表做频繁的排序、二分查找;
- 内存敏感,列表存储的条目非常多;
- 需要频繁将整个列表转成数组或进行序列化传输。
排序和二分查找这两点很容易被忽略。Collections.sort内部会根据列表是否实现了RandomAccess接口决定不同的排序策略。LinkedList没有实现RandomAccess,所以sort时即使内部做了一些优化,整体比起ArrayList的排序还是要慢不少。Collections.binarySearch同理,在LinkedList上的二分查找虽然按逻辑逐步缩小区间,但每次定位都要从头遍历,性能并不理想。
6. 版本演进与技术细节:LinkedList在JDK 21后多了什么
6.1 SequencedCollection带来了新的接口能力
LinkedList在JDK 8到JDK 17这段时间,主体源码几乎没有什么大变化。到了JDK 21,Java集合框架里引入了SequencedCollection接口,它强调“有确定顺序的集合应该提供访问第一个和最后一个元素的能力”。
LinkedList作为有序集合,也实现了这些新接口方法。代码写起来更直观:
LinkedList<String> list = new LinkedList<>(); list.addLast("a"); list.addLast("b"); list.addFirst("c"); System.out.println(list.getFirst()); // c System.out.println(list.getLast()); // b List<String> reversed = list.reversed(); // reversed 是一个逆序视图reversed()返回的是逆序视图,不是把原链表复制一份再反转。这意味着对返回的视图进行迭代,底层还是通过双向链表从尾向前访问。这个功能以前需要靠descendingIterator()实现,现在接口层面统一了。
6.2 几个永恒的底层细节
LinkedList的clone()是浅拷贝。它只复制链表结构,也就是会创建新的Node对象,把这些节点串成一条新链表,但节点里的item仍然是原对象的引用。也就是说,修改拷贝后列表里的元素对象内部状态,会影响原列表;但增删节点只影响各自的结构。
LinkedList还是Serializable的,序列化时会自定义writeObject/readObject方法,逐个节点写出元素,不序列化first、last这类内部结构字段。
它同样允许存放null值,也允许重复元素。这些特点在选型时也要纳入考虑:比如如果你的业务列表不允许null,但又选择了LinkedList,就得在业务侧自己判断。
6.3 我在代码评审时反复提的三条建议
第一,不要拿LinkedList当“万能高性能List”。看到“频繁插入”就换LinkedList,很容易把随机访问性能拖垮。先确认插入的位置、是否已知节点、是否有定位开销,再决定。
第二,明确是队列或栈语义时,先想想ArrayDeque。它和LinkedList都能实现双端操作,但ArrayDeque基于循环数组,内存占用小、遍历快、随机访问快。LinkedList唯一的额外优势是提供了List接口能力,但这个能力恰恰会诱导你在它上面做随机访问,引发性能问题。
第三,遍历删除、多线程共享、按索引排序这些场景,先想清楚该用哪个容器,而不是把锅全甩给集合类。LinkedList本身只是一个双向链表的数据结构,它不慢,快慢取决于你把它放在什么操作模式下。
6.4 一段能体现“List+Deque”混合能力的示例
最后留一个简单的参考代码,用来展示LinkedList“既能当列表、又能当双端队列”的混合使用方式:
LinkedList<String> recentViews = new LinkedList<>(); // 模拟用户新浏览 recentViews.addFirst("detail/1001"); recentViews.addFirst("detail/1002"); recentViews.addFirst("detail/1003"); // 最多保留2条,淘汰最老 if (recentViews.size() > 2) { recentViews.removeLast(); } // 按索引读取分页 int pageSize = 2; for (int i = 0; i < Math.min(pageSize, recentViews.size()); i++) { System.out.println(recentViews.get(i)); }这段逻辑用ArrayList也能写,但头部插入需要自己搬移,队列裁剪还要手动维护下标。用LinkedList则可以直接利用Deque接口的原生操作,代码意图更清晰。
回到开头那位同事的问题。他那段接口慢的根因,不是LinkedList本身不能用,而是选型和操作模式不匹配。在我个人经验里,LinkedList最舒服的定位不是一个通用的“高性能列表”,而是“凑巧还需要按索引访问的双端队列”。一旦你想通了它真正的适用边界,很多代码里的纠结都会豁然开朗。最后分享一个实用的小技巧:如果你不确定当前集合是否适合按下标访问,可以在方法注释里顺便写一句“此List会进行分页索引访问”,然后看下一个接手的人会不会替你把它换成ArrayList——他要是换了,说明你的抽象暴露出了不合适的操作模式;他要是没换,恭喜你,选型至少没有明显的问题。