ArrayList与LinkedList深度对比 ------ 从源码看透本质
文章目录
- [ArrayList与LinkedList深度对比 ------ 从源码看透本质](#ArrayList与LinkedList深度对比 —— 从源码看透本质)
-
- 前言
- 一、底层数据结构对比
-
- [1.1 ArrayList的底层实现](#1.1 ArrayList的底层实现)
- [1.2 LinkedList的底层实现](#1.2 LinkedList的底层实现)
- 二、核心操作源码分析
-
- [2.1 ArrayList的add()方法](#2.1 ArrayList的add()方法)
- [2.2 ArrayList在指定位置插入](#2.2 ArrayList在指定位置插入)
- [2.3 LinkedList的add()方法](#2.3 LinkedList的add()方法)
- [2.4 ArrayList和LinkedList的get对比](#2.4 ArrayList和LinkedList的get对比)
- 三、性能对比实测
- 四、内存占用分析
- 五、使用场景最佳实践
-
- [5.1 什么时候用ArrayList](#5.1 什么时候用ArrayList)
- [5.2 什么时候用LinkedList](#5.2 什么时候用LinkedList)
- [5.3 实际代码示例](#5.3 实际代码示例)
- 六、常见面试陷阱
-
- [陷阱1:遍历LinkedList时使用for + get(i)](#陷阱1:遍历LinkedList时使用for + get(i))
- 陷阱2:Arrays.asList的坑
- 总结
- [✅ 亮点总结](#✅ 亮点总结)
- 适用场景
- 扩展方向
前言
在Java日常开发中,ArrayList 和LinkedList是最常用的两种List实现。当面试官问"ArrayList和LinkedList的区别"时,很多人会说"ArrayList底层是数组,LinkedList底层是链表",但仅此而已往往不够。
真正理解它们的区别需要深入到三个层面:①源码层面 ------它们各自如何实现add/get/remove操作;②性能层面 ------不同操作在数据量级不同时的实际耗时表现;③内存层面------ArrayList的扩容空位和LinkedList的节点对象开销各有多大。更重要的是,你要能根据业务场景做出最优选型------90%的情况下ArrayList是正确的默认选择,但知道那10%的特殊场景(如消息队列的头尾操作)才体现你的专业深度。本文将从底层数据结构、源码实现、性能测试和最佳实践四个维度,带你彻底搞懂它们的差异与适用场景。
一、底层数据结构对比
理解数据结构是理解性能差异的前提。ArrayList和LinkedList的底层设计差异决定了它们在增删改查操作上的性能天差地别------这就像理解发动机工作原理才能理解为什么跑车加速快但油耗高。
1.1 ArrayList的底层实现
ArrayList 底层是一个Object数组 (transient Object[] elementData),通过数组实现随机访问。数组的本质是一块连续内存------对于CPU来说,访问数组中第N个元素只需要"起始地址 + N × 元素大小"的一次地址计算,所以是O(1)。这也是ArrayList随机访问极快的原因。
java
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// 默认初始容量
private static final int DEFAULT_CAPACITY = 10;
// 共享的空数组实例(空参构造使用)
private static final Object[] EMPTY_ELEMENTDATA = {};
// 实际存储元素的数组
transient Object[] elementData;
// 当前元素个数
private int size;
}
关键设计点:
- 实现了RandomAccess 接口(标记接口),表明支持快速随机访问。这个接口是一个标记接口 (没有任何方法),它的作用仅仅是"告诉JVM这个集合支持快速随机访问"------遍历时使用普通的
fori循环比使用迭代器更高效。这是一个面试中常被忽略的细节。 - 默认初始容量为10。这意味着一个空的
new ArrayList<>()实际上还没有分配内部数组(采用了延迟初始化),在第一次add时才会创建容量为10的数组。 - 扩容时容量变为原来的1.5倍。这个数值是经过深思熟虑的:太大浪费内存,太小频繁扩容。1.5倍在空间和时间之间取得了良好的平衡。
1.2 LinkedList的底层实现
LinkedList 底层是一个双向链表,每个节点(Node)包含前驱指针、后继指针和数据。双向链表的核心优势是:给定一个节点,你可以用O(1)代价找到它的上一个和下一个节点------这在需要频繁头部/尾部操作或双向遍历的场景中非常有用。但代价是每个节点需要额外存储两个引用(prev和next),内存开销大约是ArrayList的2-3倍。
java
public class LinkedList<E> extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable {
transient int size = 0;
transient Node<E> first; // 头节点
transient Node<E> last; // 尾节点
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;
}
}
}
关键设计点:
- 同时实现了List 和Deque接口,既可以当List使用,也可以当队列/栈使用
- 没有实现RandomAccess接口
二、核心操作源码分析
2.1 ArrayList的add()方法
java
public boolean add(E e) {
modCount++; // 结构性修改计数
add(e, elementData, size);
return true;
}
private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow(); // 容量不够,扩容
elementData[s] = e;
size = s + 1;
}
private Object[] grow() {
return grow(size + 1);
}
private Object[] grow(int minCapacity) {
int oldCapacity = elementData.length;
if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, // 最小增长
oldCapacity >> 1); // 首选增长:旧容量的50%
return elementData = Arrays.copyOf(elementData, newCapacity);
} else {
return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
}
}
从源码可看出:
- 添加前先检查容量,不够则扩容
- 扩容通过**Arrays.copyOf()**实现,实际调用
System.arraycopy()进行数组拷贝 - 新容量 = 旧容量 + 旧容量/2 ≈ 1.5倍
2.2 ArrayList在指定位置插入
java
public void add(int index, E element) {
rangeCheckForAdd(index);
modCount++;
final int s;
Object[] elementData;
if ((s = size) == (elementData = this.elementData).length)
elementData = grow();
System.arraycopy(elementData, index,
elementData, index + 1,
s - index); // 将index及之后的元素后移一位
elementData[index] = element;
size = s + 1;
}
System.arraycopy()是一个native方法,用C/C++实现的内存拷贝,效率极高。但当数据量很大时,移动大量元素的开销依然不小。
2.3 LinkedList的add()方法
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; // 原尾节点的next指向新节点
size++;
modCount++;
}
LinkedList的add操作只需要修改几个指针,不需要移动元素,时间复杂度为O(1)。
2.4 ArrayList和LinkedList的get对比
java
// ArrayList的get ------ O(1),直接通过数组下标访问
public E get(int index) {
Objects.checkIndex(index, size);
return elementData(index);
}
E elementData(int index) {
return (E) elementData[index];
}
// LinkedList的get ------ O(n),需要从头/尾遍历到目标位置
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
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的node()方法采用了二分优化,但时间复杂度仍为O(n/2)即O(n)。
三、性能对比实测
以下代码对两者的各项操作进行耗时对比:
java
import java.util.*;
public class PerformanceTest {
public static void main(String[] args) {
int dataSize = 100000;
int warmUpRounds = 3;
int testRounds = 5;
// 预热JVM
for (int i = 0; i < warmUpRounds; i++) {
runTests(dataSize);
}
System.out.println("======= 正式测试(" + dataSize + "条数据) =======");
for (int i = 0; i < testRounds; i++) {
System.out.println("第" + (i + 1) + "轮:");
runTests(dataSize);
System.out.println("----------");
}
}
private static void runTests(int size) {
// 1. 尾部追加
long start = System.currentTimeMillis();
List<Integer> arrayList = new ArrayList<>();
for (int i = 0; i < size; i++) {
arrayList.add(i);
}
long arrayAdd = System.currentTimeMillis() - start;
start = System.currentTimeMillis();
List<Integer> linkedList = new LinkedList<>();
for (int i = 0; i < size; i++) {
linkedList.add(i);
}
long linkedAdd = System.currentTimeMillis() - start;
System.out.println("尾部追加 - ArrayList: " + arrayAdd
+ "ms, LinkedList: " + linkedAdd + "ms");
// 2. 头部插入
start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
arrayList.add(0, i);
}
long arrayHead = System.currentTimeMillis() - start;
start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
linkedList.add(0, i);
}
long linkedHead = System.currentTimeMillis() - start;
System.out.println("头部插入 - ArrayList: " + arrayHead
+ "ms, LinkedList: " + linkedHead + "ms");
// 3. 随机访问
Random random = new Random();
start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
arrayList.get(random.nextInt(size));
}
long arrayGet = System.currentTimeMillis() - start;
start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
linkedList.get(random.nextInt(size));
}
long linkedGet = System.currentTimeMillis() - start;
System.out.println("随机访问 - ArrayList: " + arrayGet
+ "ms, LinkedList: " + linkedGet + "ms");
}
}
实测结论
以下表格总结了常见操作的时间复杂度,但需要注意的是,时间复杂度不等于实际耗时 。例如LinkedList的add()理论上是O(1),但由于每次都要new一个Node对象(包括对象头开销),在大量数据插入时可能比ArrayList的add()(O(1)均摊,通过数组索引直接赋值)更慢。这就是为什么实际测试中ArrayList在尾部追加往往比LinkedList略快。
| 操作 | ArrayList | LinkedList | 获胜方 |
|---|---|---|---|
| 尾部追加 | O(1)均摊 | O(1) | 基本持平,ArrayList略快 |
| 头部插入 | O(n) | O(1) | LinkedList |
| 中间插入 | O(n) | O(1)(遍历O(n)) | 实际接近,大数据量LinkedList稍好 |
| 随机访问 | O(1) | O(n) | ArrayList(巨大优势) |
| 删除元素 | O(n) | O(1)(找到后) | LinkedList |
重要提醒 :LinkedList的"中间插入O(1)"是有前提的------你已经拿到了那个位置的节点引用。但在实际使用中,你需要先遍历到目标位置(O(n)),因此总复杂度还是O(n)。这是很多技术文章容易误导人的地方。
四、内存占用分析
除了性能,内存占用也是选择集合时的重要考量。
java
// ArrayList的内存 = 数组引用 + 空位内存
// 若ArrayList当前capacity=100,但size=50,
// 则有50个位置的内存是浪费的(存放null引用)
// LinkedList的内存 = 每个节点的额外开销
// 每个Node对象包含:
// - 对象头(8或16字节)
// - item引用(4或8字节)
// - next引用(4或8字节)
// - prev引用(4或8字节)
// 总计每个节点额外消耗约24~40字节
结论:当元素数量很大时,ArrayList的内存利用率通常优于LinkedList,因为LinkedList的双向链表节点开销太大。
五、使用场景最佳实践
5.1 什么时候用ArrayList
- 需要频繁随机访问(
get(index)) - 主要在尾部追加数据
- 数据量较大,需要更好的内存利用率
- 遍历操作频繁
- 90%以上的业务场景首选ArrayList
5.2 什么时候用LinkedList
- 需要在头部频繁插入/删除
- 需要同时使用List和Deque的功能(既当列表又当队列)
- 删除操作远比查询操作频繁
5.3 实际代码示例
java
public class BestPractice {
public static void main(String[] args) {
// 场景1:订单列表 ------ 主要是遍历和展示,用ArrayList
List<Order> orders = new ArrayList<>();
orders.add(new Order("001", 99.9));
orders.add(new Order("002", 199.9));
// 场景2:消息队列 ------ 经常头部移除,用LinkedList
Deque<Message> messageQueue = new LinkedList<>();
messageQueue.offer(new Message("新消息1"));
messageQueue.offer(new Message("新消息2"));
Message processed = messageQueue.poll(); // 头部出队
// 场景3:不知道大小,但需要频繁随机访问 ------ 估算或直接用ArrayList
// 如果知道大概数量,可指定初始容量减少扩容开销
List<String> bigList = new ArrayList<>(10000);
}
static class Order {
String id;
double amount;
Order(String id, double amount) {
this.id = id;
this.amount = amount;
}
}
static class Message {
String content;
Message(String content) { this.content = content; }
}
}
六、常见面试陷阱
陷阱1:遍历LinkedList时使用for + get(i)
java
// 错误示范 ------ 每次get(i)都是O(n),总复杂度O(n²)
for (int i = 0; i < linkedList.size(); i++) {
System.out.println(linkedList.get(i));
}
// 正确示范 ------ 使用增强for或迭代器,总复杂度O(n)
for (String s : linkedList) {
System.out.println(s);
}
陷阱2:Arrays.asList的坑
java
// Arrays.asList返回的是Arrays内部类,不支持add/remove
List<String> fixedList = Arrays.asList("A", "B", "C");
// fixedList.add("D"); // 抛出UnsupportedOperationException!
// 如果需要可变的List,需要包装一下
List<String> mutableList = new ArrayList<>(Arrays.asList("A", "B", "C"));
mutableList.add("D"); // 正常运行
总结
ArrayList和LinkedList各有优劣,没有绝对的"谁更好",只有"谁更适合当前场景"。这篇文章的核心价值不在于记忆哪个操作O(1)还是O(n),而在于建立"根据数据结构和操作模式选择集合"的思维习惯。
理解它们的底层实现和性能特点后,你会知道:为什么遍历LinkedList要用增强for而不是get(i)(O(n^2)的陷阱)、为什么需要随机访问时必须选ArrayList、为什么消息队列场景LinkedList的pollFirst如此高效、为什么几乎所有项目中的List变量都初始化为ArrayList------这些都是知识内化为直觉后的自然反应。
对于绝大多数业务场景,ArrayList是默认的最优选择 。只有当你明确需要频繁在头部操作或者需要双向队列功能时,才考虑使用LinkedList。记住一个简单的选择口诀:查多用ArrayList,插删多用LinkedList;不确定就选ArrayList。这个结论适用于99%的实际场景。
✅ 亮点总结
- 底层数据结构的根本差异 :ArrayList →
Object[]数组,LinkedList → 双向链表节点(Node含prev/data/next),一张图看清数据的物理存储方式 - 增删改查时间复杂度全景表 :按位置随机访问
get(i),ArrayList O(1) vs LinkedList O(n);头部插入addFirst,ArrayList O(n) vs LinkedList O(1)------用数据说话 - ArrayList扩容机制的代价分析 :默认容量10,超出时1.5倍扩容,
Arrays.copyOf涉及全量数组拷贝,高频扩容是性能杀手------强调指定初始容量的重要性 - 遍历性能的关键差异 :ArrayList用
for下标或增强for效率相同;LinkedList必须用增强for或Iterator,使用for + get(i)会导致O(n²)灾难 - 常见坑点速查 :
Arrays.asList返回不可变List、subList修改影响原List、toArray()类型转换陷阱------三个隐藏坑一次讲清
适用场景
- 日常开发中不确定选什么时,直接选择
ArrayList------它在绝大多数场景下的综合性能最佳 - 实现消息队列或任务调度时使用
LinkedList,利用其双端操作的高效性(addFirst/pollFirst) - 大数据量场景下,预估元素数量并用
new ArrayList<>(expectedSize)初始化,避免中途多次扩容
扩展方向
- HashMap底层原理 :同样涉及底层数据结构和性能优化,推荐阅读 13_HashMap底层原理详解
- 并发安全集合 :了解
CopyOnWriteArrayList(读多写少场景)和Collections.synchronizedList的区别与适用场景 - 其他List实现类 :了解
Vector(古老的线程安全List,已过时)和Stack(基于Vector的后进先出栈)的历史和替代方案
下一篇:13_HashMap底层原理详解