☰
ArrayList与LinkedList核心差异:从源码剖析到性能实测与选型指南
2026/10/13 3:18:48 网站建设 项目流程

ArrayList与LinkedList核心差异,几乎是Java面试里每场必问的送分题。但我这些年面试下来,真正能把这道题答出彩的候选人极少。多数人张口就是“数组和链表,一个查得快一个增删快”,然后就没有然后了。实际上这道题能挖出你对数据结构底层实现、JVM内存模型、CPU缓存、均摊复杂度这几个层面的理解深度。这篇文章不打算给你背答案,而是把ArrayList与LinkedList从源码到实测掰开揉碎,搞清楚差异背后真正的原理,顺便把面试官可能追问的问题全部预演一遍。准备面试的初级中级开发可以当题库用,老手也可以看看有没有漏掉的知识点。

1. 面试官到底在追问什么:先搞懂差异的根源

1.1 数组和链表:两种存储结构的性格差异

ArrayList的底层是一块连续内存,用Object[]数组存放元素。访问第i个元素的时候,JVM可以直接通过“数组起始地址 + i × 元素大小”算出目标位置,这就是随机访问,时间复杂度O(1)。你可以把它想象成图书馆里一整排连在一起的书架,编号是多少,直接走过去第几个格子就行了,不需要中间询问任何人。

LinkedList的底层是双向链表,每个元素被包装成一个Node节点,节点里除了存数据item,还存前驱引用prev和后继引用next。节点与节点之间靠指针串联,内存地址不连续。想找第i个节点,只能从first或last开始一个个跳过去,这就是顺序访问,时间复杂度O(n)。类比就是每个房间只放一本书,但房间分散在城市各处,你只能通过墙上贴的路标一个个找过去。

这两种结构的性格差异决定了ArrayList与LinkedList在几乎所有操作上的表现。面试官问这个题,表面上是考察数据结构基础,实际上是想看你能不能从存储结构推导出操作效率,再从操作效率推导出选型建议,这是一个完整的逻辑链。只会背结论的人,一旦被追问“为什么中间插入LinkedList不一定快”,立马就会露馅。

1.2 ArrayList与LinkedList在实现上的直接映射

先看JDK源码里的类声明。ArrayList继承自AbstractList,实现了List、RandomAccess、Cloneable、Serializable接口。LinkedList继承自AbstractSequentialList,实现了List、Deque、Cloneable、Serializable接口。

关键差异在这两点。

第一,RandomAccess。这是一个空的标记接口,没有任何方法,作用就是告诉JVM和工具类“我这个List支持高效随机访问”。Collections.binarySearch做二分查找的时候,会先判断list instanceof RandomAccess,如果是就用索引直接取值,否则就改用迭代器遍历。所以这个标记接口不是摆设,它直接影响JDK内部算法的策略选择。

第二,LinkedList多实现了Deque接口。这意味着它不仅能当List用,还能当双端队列、栈用,提供了addFirst、addLast、removeFirst、pollFirst、peekLast这些双端操作方法。这是LinkedList独有的能力,ArrayList做不到。有些面试题会问“用List实现一个栈或队列”,如果你答LinkedList,说明你知道它的Deque特性;如果你答ArrayDeque,那更专业,后面我会专门提到。

底层字段也不同。ArrayList的核心是一个Object[] elementData数组,加上int size记录实际元素个数;LinkedList的核心是Node first、Node last两个指针,分别指向头节点和尾节点。一个用数组承载数据,一个用节点链接数据,这两个类的命运从这里就开始分叉了。

1.3 面试答题的黄金结构

我总结过一套回答这道题的黄金结构,照着讲基本不会漏重点。

第一步,先说存储结构:ArrayList底层是数组,连续内存,支持随机访问;LinkedList底层是双向链表,节点分散,只能顺序访问。

第二步,按操作逐项对比:增删改查的时间复杂度,包括尾插、头插、中间插、按下标查、按值查、遍历,把这些维度说清楚。

第三步,谈内存差异:ArrayList数组本身有连续引用,加上每个对象头;LinkedList每个节点额外存两个引用,能省则省。

第四步,给结论:无脑用ArrayList是行业的默认选择,LinkedList只在“明确需要频繁头尾双端操作”或“实现Deque语义”时才值得考虑,而且要评估ArrayDeque这个替代品。

这套结构的好处是逻辑递进,面试官可以从任何一层往下追问。比如他接着问“那你在什么场景下绝对不用LinkedList”,你就可以自然过渡到缓存局部性和内存开销的话题。

2. 源码级拆解:扩容、链接与内存账单

2.1 ArrayList扩容机制:1.5倍背后的算盘

先看无参构造。JDK 8里new ArrayList()并不会立刻创建容量为10的数组,而是把elementData指向一个共享的空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA,真正第一次add的时候才初始化成容量10。这是懒加载的思路,为了节省那些创建了但从不使用的空List占用的内存。

add方法里有个ensureCapacityInternal调用,随后进入grow方法。核心代码就一行:

int newCapacity = oldCapacity + (oldCapacity >> 1);

oldCapacity >> 1是右移一位,等价于除以2取整。所以新容量 = 旧容量 + 旧容量的一半,也就是1.5倍。为什么选1.5倍而不是直接翻倍?这是时间与空间的折中。如果每次只加1个容量,频繁扩容复制数组,插入均摊成本太高;如果直接翻倍,扩容次数少了,但浪费的空间也多。1.5倍可以让扩容次数和空间浪费处在一个相对均衡的位置。

扩容做了什么?核心是Arrays.copyOf,底层调System.arraycopy,这是一个native方法,由JVM直接做内存块拷贝,速度非常快。但你要知道,扩容本身是O(n)的操作,因为要把旧数组里的所有引用复制到新数组。不过由于扩容次数随容量指数增长,均摊下来每次add仍然是O(1)。这就是均摊复杂度的概念,面试的时候能主动说出这个词,含金量立刻不一样。

很多人忽略的一个细节是grow方法里对超大容量的处理:当oldCapacity加上1.5倍之后已经可能超过Integer.MAX_VALUE - 8这个MAX_ARRAY_SIZE时,会走到hugeCapacity方法,直接尝试用Integer.MAX_VALUE。数组长度上限接近int最大值,这是一个容易在极端场景下踩的坑,比如你要往ArrayList里塞超过20亿个元素,扩容计算就会溢出。虽然实际业务里几乎不可能出现,但面试官喜欢拿这种边界问题来试探你有没有读过源码。

2.2 LinkedList节点:三引脚的代价

LinkedList的内部节点类很朴素:

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; } }

每个节点除了存储元素item,还要存两个引用next和prev。这就是链表最直观的额外内存开销。每次add操作,必然会new一个Node对象,这个对象分配在堆上,没有连续内存布局可言。

add(E e)默认是尾插,源码逻辑是:把原来的尾节点记为l,创建新节点new Node<>(l, e, null),让尾指针last指向新节点,再处理first指针。整个过程只修改几个引用,不需要移动任何元素,所以时间复杂度确实是O(1)。注意这里的O(1)是在“已经知道尾节点位置”的前提下成立,因为LinkedList内部一直维护着last指针。

LinkedList还有一个我特别想提的细节:node(int index)方法里做了二分式索引优化。当index < size / 2时从头遍历,否则从尾遍历,把最坏查找时间从O(n)优化到O(n/2),虽然还是O(n)级别,但实实在在减少了一半的跳转次数。这个细节在源码里很容易被忽略,但面试时如果能说出来,说明你真的动手翻过源码。

链表的增删“快”是有前提的:必须已经拿到了目标节点的引用,比如你用迭代器停在某个位置,再往里插,那确实是O(1)。但如果你只给了一个下标index,就得先调用node(index)去遍历找节点,整体复杂度又回到O(n)。很多人忽略了这个前提,导致在实战中选错数据结构。

2.3 两个类在内存上的真实账单

要谈内存差异,不能只停留在“节点多了两个引用”这种口头层面。我按64位JVM、开启压缩指针的默认参数来粗略估算一下。

ArrayList的elementData是一个数组对象,数组对象头大概16字节;每个元素是一个4字节的引用(压缩指针),指向堆里的元素对象。如果存的是Integer对象,每个Integer除了4字节的int值,还有12到16字节的对象头和对齐填充。这里先不纠结精确到字节,量级才是关键。

LinkedList的每个Node对象,对象头约16字节,item引用4字节,next引用4字节,prev引用4字节,加上对齐填充,通常至少占24字节,再加上元素对象本身。存同样多的数据,LinkedList的额外内存开销大约是ArrayList的2到3倍。

搭建一个具体的例子来看。假设要存100万个int值的Integer对象:ArrayList大概需要数组引用4MB + 100万个Integer对象约16MB,总量约20MB级别。LinkedList需要100万个Node对象约24MB,加100万个Integer对象约16MB,总量约40MB级别。这还是保守估算。如果你的业务里有大量重复add和remove操作,节点对象频繁创建和回收,GC压力会非常明显。

缓存局部性更是一个隐形的性能杀手。CPU读取内存不是一次读一个字节,而是按缓存行(通常64字节)批量加载。ArrayList的数组元素在内存里紧挨着,CPU加载一个缓存行就能连续处理多个元素;LinkedList的节点分散在堆的不同位置,每访问一个节点大概率都要重新从主内存加载一次,缓存命中率非常低。这就解释了为什么很多情况下ArrayList即使做“需要移动元素”的插入,性能反而比LinkedList好——后面的实测部分我会详细说。

3. 实测视角:别再轻信“集合选择口诀”

3.1 “LinkedList增删快”为什么是伪命题

有一个流传很广的口诀:查询用ArrayList,增删用LinkedList。这个口诀害人不浅,因为它没有限定场景。

实际测一下中间的插入。我用一个简单的循环,分别在ArrayList和LinkedList的中间位置插入10万次元素。ArrayList每次要移动后半段所有元素,但移动使用的是System.arraycopy原生块拷贝,速度极快;LinkedList每次要先通过node(index)遍历到中间位置,再修改引用,还要new一个Node对象。

实测下来,在元素总量10万左右时,ArrayList的中间插入反而比LinkedList快。元素量越大,LinkedList的劣势越明显,因为查找距离越来越长。只有在头部大量插入这种场景,LinkedList的O(1)头插优势才能真正体现出来,而ArrayList头插要移动全部元素,O(n)的代价就很大。

尾插对比更反直觉。ArrayList在容量充足时直接写数组尾部,连扩容都不用;LinkedList虽然也是O(1),但要new Node、修改两处引用、可能触发GC。两者的绝对时间差虽然不大,但不要指望LinkedList有任何性能上的惊喜。说白了,链表的口碑优势只有“在已知节点处插入删除”时才成立,而工程场景里你很少会持有一个节点的引用,多数时候你手里只有下标。

3.2 遍历方式:fori、foreach、迭代器、Stream

遍历测试能暴露LinkedList的真正短板。ArrayList用fori循环通过get(i)连续访问,JIT编译器能把数组访问优化到接近直接内存遍历的速度。LinkedList如果用fori循环,每一次get(i)都要从头或尾遍历到第i个节点,整个循环的时间复杂度直接变成O(n²),10万元素就会卡到你怀疑人生。

正确的遍历方式是用foreach语法或显式迭代器。foreach在字节码层面就是Iterator,LinkedList的迭代器会维护当前节点的引用,调用next()只需向后跳一次,这样整个遍历才是O(n)。所以面试如果问“LinkedList能不能用fori遍历”,标准答案是不建议,因为O(n²)复杂度,数据量一大就是灾难。

还有个细节很多人不知道:对LinkedList做remove操作,如果用Iterator.remove(),迭代器已经持有目标节点引用,删除是O(1)的;如果用List.remove(int)或remove(Object),还是要先遍历查找,又是O(n)。这个差异在频繁删除的场景下会被放大,比如循环里删除一半元素,用迭代器可能只要O(n),用remove方法就变成O(n²)。

遍历接口的选择也有讲究。JDK 8之后很多人习惯用stream(),但要注意,对LinkedList而言,stream遍历和迭代器遍历性能相当,并不会因为用了lambda就变快。用fori + LinkedList.get(i)才是真正的反面教材,这个坑我在代码评审里见过不止一次。

3.3 场景选型:什么时候用ArrayList,什么时候用LinkedList

我根据实际项目经验列一个选型速查表:

场景特征推荐选择原因
默认无特殊需求ArrayList随机访问快、缓存友好、内存紧凑
尾部追加为主ArrayList均摊O(1),扩容损耗小
按下标随机访问ArrayList真正O(1)
头部频繁插入删除LinkedList头插头删O(1)
需要双端队列/栈语义ArrayDeque比LinkedList更紧凑,应优先考虑
已知节点处插入删除LinkedList拥有节点引用时O(1)
大量中间插入删除看数据规模小数据ArrayList可能更快;大数据需要做基准测试
遍历为主ArrayList缓存局部性碾压

我特别想强调的是ArrayDeque这个竞争者。既然LinkedList实现了Deque,很多人就默认用LinkedList做队列或栈。但从内存和性能角度看,ArrayDeque用循环数组实现双端操作,头尾插入删除都是O(1),没有节点对象开销,缓存更友好,几乎在所有队列/栈场景都优于LinkedList。LinkedList作为List和Deque的混合体,为了两头兼顾,牺牲了纯双端结构的紧凑性。

工程上我最常见的做法是:默认ArrayList,只有“明确需要从头部批量插入或删除”这一类场景才考虑LinkedList,而且会先和ArrayDeque做一轮对比测试。大多数时候,ArrayList用reverse后追加等方式可以绕开头部操作的需求,根本轮不到LinkedList出场。

4. 高频追问与避坑清单

4.1 面试官最爱问的延伸题

回答完核心差异之后,面试官通常不会收手,而是往以下几个方向追。这些延伸问题才是拉开差距的地方。

第一个,ArrayList和LinkedList哪个线程安全?标准答案:都不安全。Vector是线程安全的,但那是全方法加synchronized,并发性能差;CopyOnWriteArrayList是并发场景的替代品,读多写少时效果极佳。一旦候选人主动提到CopyOnWriteArrayList,面试官就会更欣赏,因为它证明你不只知道两个List,还了解JUC并发容器。

第二个,删除ArrayList元素时为什么要倒序遍历?因为正序删除会导致后面元素前移,下标错乱。比如循环里i=0删掉第0个元素,原先第1个元素变成第0个,下一次i=1就直接跳过了它。倒序删除不会影响前面未处理的下标。另一个优雅方案是用Iterator.remove(),removeIf方法在JDK 8之后也更加推荐。

第三个,ArrayList和LinkedList都可以存null吗?可以。两个类对null值没有任何限制。不过实际业务代码里建议尽量避免,以免NPE问题变得难以排查。

第四个,LinkedList的add(E e)返回值为什么是boolean而不是void?因为List接口的方法签名定义就是boolean add(E e),LinkedList要实现接口就必须保留这个返回值。真正内部实现永远返回true,你完全可以忽略它。这个问题的意义在于考察候选人是否理解接口契约对实现类的约束。

第五个,什么时候抛ConcurrentModificationException?ArrayList维护一个modCount字段记录结构修改次数,每当你创建迭代器时,expectedModCount会被记录为当时的modCount;迭代过程中如果其他操作修改了modCount,两者不一致就抛异常。这就是fail-fast机制。顺带说一声,不要依赖这个异常做并发控制,它只是快速失败,不是安全保证。

第六个,ArrayList的subList有什么坑?subList返回的是原List的视图而不是新List,对子列表的修改会直接反映到原列表。更危险的是,对原列表做结构性修改后,子列表再来操作就会抛ConcurrentModificationException。所以subList只适合短时读取,不适合长期持有。

第七个,capacity和size有什么区别?size是已经放进来的元素个数,capacity是底层数组的容量。两者不等价,size永远小于等于capacity。理解这个区别才能理解为什么需要扩容。

4.2 手写代码环节的真实翻车点

面试手写题里,链表是重灾区。我把常见的翻车现场一个个列出来。

第一个,用for循环删除链表节点时忘记更新前驱或后继指针。很多人写删除代码只改当前节点本身的next,却没有让前一个节点的next指向当前节点的next,链表当场断掉或者出现环。写完之后至少要在纸上跑一遍三节点的用例。

第二个,在LinkedList上用fori遍历导致超时。有些题目本身不限制遍历方式,但候选人习惯性写for(int i = 0; i < list.size(); i++),然后list.get(i)。在ArrayList上没问题,在LinkedList上就变成O(n²),数据量一大直接超时。遇到链表题,第一反应就该切换成迭代器或者while循环。

第三个,以为LinkedList在任何场景都“增删快”,结果帮倒忙。有一次让我用数组模拟LRU缓存,我一开始写LinkedList,每次访问都要把节点移到头部,但是需要先O(n)找到这个节点。后来改成HashMap + 双向链表,自己维护节点引用,才真正把查找变成O(1),同时保持移动节点O(1)。这个例子说明,链表的O(1)优势必须建立在“已有节点引用”的前提上。

第四个,手写LinkedList的get(int index)时直接从头遍历,忘了源码里的二分索引优化。虽然都是O(n),但现实场景里list size如果很大,少跳一半节点很可观。

第五个,ArrayList扩容手写时没有考虑容量溢出。想象一下你自己实现一个动态数组,如果扩容计算用newCapacity = oldCapacity * 2,在极端情况下可能会溢出为负数。JDK源码对溢出的处理值得反复学习。

4.3 版本演进与JDK的悄悄改动

ArrayList与LinkedList在JDK版本演进中都有过不为人知的调整,面试时说出来可以制造知识增量。

先看ArrayList。JDK 7及之前,new ArrayList()会直接创建一个容量为10的数组,不管你用不用。JDK 8之后改成懒加载,构造时指向DEFAULTCAPACITY_EMPTY_ELEMENTDATA,第一次add时才真正扩容到10。这样写节省了海量空List的内存开销,也让构造函数变得几乎零成本。如果你在面试里回答“ArrayList默认容量是多少”,最好加上“JDK 8之后是首次添加时才初始化为10”,而不是简单说“默认容量10”。

再看LinkedList。JDK 6之前,LinkedList是一个带哨兵头节点的双向循环链表,头节点不存数据,last节点的next指向头节点。JDK 7之后改成了普通双向链表,没有哨兵头节点,first直接指向第一个数据节点,last指向最后一个,null表示空链表。这个改动让代码更直观,也省了一个哨兵节点的内存。注意细节别记反,面试官问“LinkedList是循环链表吗”,答“JDK 6及以前是循环实现,JDK 7之后不是”才算完整。

另外一个版本相关的话题是for-each和Iterator的区别。Java语言规范中说,for-each在底层就是Iterator,但ArrayList有一个特殊性:因为实现了RandomAccess,很多JDK内部工具在遍历时会选用随机访问方式而不是迭代器。比如Collections的binarySearch就会判断RandomAccess来决定走哪条分支。所以ArrayList的随机访问能力不只是数据结构意义,它还改变了JDK算法库的行为。

4.4 性能对比:别信随手一跑的时长

网上有很多测试贴,说LinkedList和ArrayList谁快谁慢,结果经常互相矛盾。问题往往出在测试方法上。如果你要在项目里做选型测试,这几个坑必须避开。

第一,JVM需要预热。代码跑几十次之后,JIT才会把热点代码编译成机器码,前面的冷启动时间会严重拖慢结果。至少先跑上万次预热,再正式开始计时,或者直接用JMH这种基准测试框架。

第二,避免JIT优化掉代码。如果测试循环里没有使用计算结果,JIT可能判定代码为死代码直接优化掉,结果测出来全是0毫秒。正确做法是让结果有一个累积变量,或者让结果无法被编译器推导。

第三,System.arraycopy是native方法,它在不同JVM版本上的表现会有差异,但只要JVM实现成熟,它一定比逐元素复制快得多。这也是ArrayList中间插入没有被LinkedList甩开的核心原因之一。

第四,测量内存要用专业工具。不要用Runtime.freeMemory()这类粗糙方式,它受GC时机影响极大。想看真实内存账单,用JFR、JVisualVM或者直接看堆转储。真要精确对比两个数据结构的内存占用,建议构造完数据之后强制Full GC,然后对比堆使用量。

5. 我的实际体会与两个压箱底建议

这道题我其实建议每个Java开发者认真对待,不只是为了面试。因为在实际项目里,我见过太多因为“ArrayList应该够用吧”而导致的性能问题,也见过反过来用LinkedList导致GC飙升的案例。有一次一个定时任务要频繁在头部插入再遍历,一开始因为口诀用了LinkedList,跑了半小时内存就开始吃紧;换成ArrayList配合reverse追加和逆序读取之后,内存占用直接降了一个量级。这个经历让我彻底意识到,差异背后的局部性原理不是理论,是能救项目的实践。

我最后再分享两个压箱底的建议。

第一个建议:把ArrayList和LinkedList的源码各读三遍,重点是grow方法和node方法。读的时候不要求记住每行代码,而是去理解每个决策的原因。为什么选1.5倍?为什么node里要判断index < size >> 1?为什么LinkedList需要额外实现Deque?想通了这些问题,你不需要背任何面试答案,所有引申问题都能现场推出来。

第二个建议:做选型决策时,永远把“你手里有什么”作为第一问。你手里只有下标,LinkedList没有优势;你手里有节点引用,LinkedList才能发挥链表本色;你手里是大量读操作,ArrayList的缓存局部性就是碾压。把这三个问题在心里过一遍,比记一百个结论都管用。

说回面试现场,如果你能从头到尾把存储结构、操作复杂度、内存账单、缓存局部性、场景选型、JDK版本差异这条线串起来讲,再主动提一下ArrayDeque和CopyOnWriteArrayList,这场Java面试的集合部分基本就稳了。关键是,这些知识你真的能从原理层面讲明白,而不是背了一套说辞。就按这个思路去准备,吃透之后你会发现,面试官无论怎么变着问都绕不出你提前搭好的逻辑网。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询