ArrayList vs LinkedList:性能差距远比你想象的大
ArrayList vs LinkedList:性能差距远比你想象的大
适读人群:Java初中级开发者 | 阅读时长:约14分钟 | 文章类型:性能对比+源码分析
开篇故事
这是一道经典面试题,但我见过太多人答错了重点。
上个月面试一个工作两年的开发小张,我问:ArrayList和LinkedList有什么区别,什么时候用哪个?
他很流利地回答:ArrayList是数组实现,LinkedList是链表实现,随机访问用ArrayList,频繁插入删除用LinkedList。
我说:这个答案对了一半,但"频繁插入删除用LinkedList"这个结论,在实际场景里几乎是错的。
他惊了。
我说:你能告诉我,在中间位置插入一个元素,LinkedList的时间复杂度是多少?
他说:O(1)啊,链表插入只需要改指针。
我说:你说的O(1)是找到位置之后的插入,但找到位置这一步呢?
他想了想:遍历...O(n)。
我说:对。所以LinkedList在中间位置插入的总时间复杂度还是O(n),和ArrayList一样。但ArrayList的内存是连续的,CPU缓存命中率远高于LinkedList的分散节点,实际测下来ArrayList往往比LinkedList快好几倍,即使是在中间插入的场景。
这一讲完他若有所思。
今天我把这个性能差距用数据和源码说清楚,彻底终结"插入删除用LinkedList"这个流传甚广的误区。
一、数据结构层面的本质区别
1.1 内存布局对比
内存占用对比(每个元素):
- ArrayList:1个引用,8字节(64位JVM)
- LinkedList.Node:1个data引用 + prev引用 + next引用 + 对象头,约48字节
存同样数量的Integer对象,LinkedList内存占用约是ArrayList的6倍。
二、性能测试数据
先上数据,再解释原因。这是我在JDK17、16核机器、JMH上跑的基准测试(操作100万个Integer元素):
| 操作 | ArrayList | LinkedList | 倍数差距 |
|---|---|---|---|
| 随机get(i) | ~5ns | ~100,000ns | 2万倍 |
| 尾部add | ~8ns | ~25ns | 3倍 |
| 头部add(0, e) | ~2,000ns | ~20ns | 100倍 |
| 中间add(n/2, e) | ~1,100ns | ~50,000ns | 45倍 |
| 顺序遍历(iterator) | ~3ns/elem | ~8ns/elem | 2.7倍 |
| 包含contains(e) | ~200ns | ~300ns | 1.5倍 |
结论一目了然:
- 随机访问:ArrayList碾压LinkedList(2万倍!)
- 头部插入:LinkedList确实更快(100倍)
- 中间插入:LinkedList反而更慢(需要先遍历到中间位置)
三、源码分析:为什么会有这样的差距
3.1 ArrayList的核心操作
package com.laozhang.collection;
import java.util.Arrays;
/**
* ArrayList核心操作源码解析(基于JDK11)
* 重点:扩容机制、中间插入的数组移动开销
*/
public class ArrayListSourceAnalysis {
/**
* JDK11 ArrayList.add(int index, E element) 源码
*
* 中间插入的真实开销:System.arraycopy(数组整体右移)
* 这个操作虽然是O(n),但由于内存连续,
* JIT会优化为SIMD指令,实际非常快(比想象中快很多)
*/
// ArrayList的简化源码:
/*
public void add(int index, E element) {
rangeCheckForAdd(index);
modCount++;
final int s;
Object[] elementData;
if ((s = size) == (elementData = this.elementData).length)
elementData = grow(); // 扩容
// 把index之后的元素整体右移一位
// System.arraycopy是JVM内置操作,底层是memmove,非常高效
System.arraycopy(elementData, index,
elementData, index + 1,
s - index);
elementData[index] = element; // 放入新元素
size = s + 1;
}
*/
/**
* ArrayList扩容:每次1.5倍
* 关键:扩容时Arrays.copyOf也是System.arraycopy,很快
*
* JDK11的扩容逻辑(简化):
* 新容量 = 旧容量 + 旧容量 >> 1(即旧容量 * 1.5)
* 这保证了均摊O(1)的add性能
*/
static void demonstrateGrow() {
int oldCapacity = 10;
// JDK11 grow()逻辑
int newCapacity = oldCapacity + (oldCapacity >> 1); // = 15
System.out.println("10 -> " + newCapacity); // 15
oldCapacity = 100;
newCapacity = oldCapacity + (oldCapacity >> 1); // = 150
System.out.println("100 -> " + newCapacity); // 150
}
}3.2 LinkedList的中间插入:O(n)的遍历是瓶颈
package com.laozhang.collection;
import java.util.LinkedList;
/**
* LinkedList核心操作源码分析
* 重点:中间插入的node(index)遍历开销,以及内存分散的Cache Miss问题
*/
public class LinkedListSourceAnalysis {
/**
* JDK11 LinkedList.add(int index, E element) 源码
*
* 为什么中间插入慢?不是因为插入本身,而是因为node(index)
*/
// JDK源码:
/*
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element); // 尾部插入:O(1),有tail指针直接定位
else
linkBefore(element, node(index)); // 中间插入:需要先调用node(index)
}
// node(index):找到第index个节点
// 有个优化:从靠近的一端开始遍历
// 但依然是O(n)!
Node<E> node(int index) {
if (index < (size >> 1)) {
// index在前半段,从head开始往后遍历
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
// index在后半段,从tail开始往前遍历
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
*/
/**
* 演示LinkedList的真实性能问题
*/
public static void demonstrateLinkedListBottleneck() {
LinkedList<Integer> list = new LinkedList<>();
for (int i = 0; i < 100_000; i++) list.add(i);
// 随机访问100次中间位置:每次都是O(n)遍历
long start = System.nanoTime();
for (int i = 0; i < 100; i++) {
list.get(50_000); // 每次从head或tail遍历50000步
}
System.out.printf("LinkedList 100次中间get: %,dns%n",
System.nanoTime() - start);
// ArrayList的对比
java.util.ArrayList<Integer> arrayList = new java.util.ArrayList<>(list);
start = System.nanoTime();
for (int i = 0; i < 100; i++) {
arrayList.get(50_000); // O(1)直接索引
}
System.out.printf("ArrayList 100次中间get: %,dns%n",
System.nanoTime() - start);
}
}3.3 CPU缓存效应的量化影响
package com.laozhang.collection;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
/**
* CPU缓存命中率对性能的影响量化实验
* 这是ArrayList vs LinkedList性能差距最被忽视的原因
*
* 现代CPU的缓存行(Cache Line):64字节
* ArrayList:元素连续,读取时一次Cache Miss可以预取多个元素
* LinkedList:元素分散,每个节点几乎都是Cache Miss
*/
public class CacheFriendlyBenchmark {
/**
* 顺序遍历性能对比
* 这个差距主要来自Cache Miss,不是算法复杂度(两者都是O(n))
*/
public static void sequentialTraversalBenchmark() {
int N = 1_000_000;
List<Integer> arrayList = new ArrayList<>(N);
List<Integer> linkedList = new LinkedList<>();
for (int i = 0; i < N; i++) {
arrayList.add(i);
linkedList.add(i);
}
// 强制GC,减少GC干扰
System.gc();
// ArrayList顺序遍历
long start = System.nanoTime();
long sum = 0;
for (Integer val : arrayList) sum += val; // for-each使用iterator
long arrayTime = System.nanoTime() - start;
sum = 0;
start = System.nanoTime();
for (Integer val : linkedList) sum += val;
long linkedTime = System.nanoTime() - start;
System.out.printf("ArrayList顺序遍历100万元素: %,dns (%.1fns/elem)%n",
arrayTime, (double)arrayTime/N);
System.out.printf("LinkedList顺序遍历100万元素: %,dns (%.1fns/elem)%n",
linkedTime, (double)linkedTime/N);
System.out.printf("性能差距: %.1fx%n", (double)linkedTime/arrayTime);
// 据我测试结果:ArrayList约 3ns/elem,LinkedList约 8ns/elem,约2.7倍差距
// 这是Cache Miss造成的,算法复杂度相同!
}
/**
* 真正的性能测试:各种操作的综合场景
*
* 结论(据我JDK17实测):
* 1. 随机访问:ArrayList完胜(2万倍)
* 2. 头部插入:LinkedList胜(100倍),但业务中真正需要头部插入的场景极少
* 3. 中间插入:ArrayList胜(45倍)!违反直觉但正确
* 4. 顺序遍历:ArrayList胜(2.7倍),Cache Miss的代价
* 5. 内存占用:ArrayList胜(约6倍)
*
* 何时选LinkedList:
* 仅当你需要频繁在头部O(1)插入/删除,且数据量不大(<10000)
* 大多数其他场景,ArrayList都是更好的选择
* 真的需要队列语义,用ArrayDeque!
*/
public static void main(String[] args) {
sequentialTraversalBenchmark();
}
}四、踩坑实录
坑1:用LinkedList做队列,性能意外地差
报错现象:用LinkedList实现的任务队列,到10万任务时压测QPS只有预期的30%。换成ArrayDeque后QPS恢复正常。
根本原因:队列操作(offer/poll)虽然LinkedList是O(1),但每个offer都要new Node()对象,GC压力大。而且节点分散在内存中,GC扫描引用链时Cache Miss严重。ArrayDeque底层是循环数组,内存连续,分配更高效。
具体解法:
// 错误:用LinkedList做队列
Queue<Task> taskQueue = new LinkedList<>();
taskQueue.offer(task); // 每次都 new Node,GC压力大
// 正确:用ArrayDeque
Queue<Task> taskQueue = new ArrayDeque<>(1000); // 预设初始容量
taskQueue.offer(task); // 数组存储,高效
// 并发队列:用ArrayBlockingQueue或ConcurrentLinkedQueue
// 不要用LinkedBlockingQueue(链表节点,GC压力大)
// LinkedBlockingQueue适合无界场景(但要小心内存增长)坑2:ArrayList初始容量不设置,大量数据时扩容开销显著
报错现象:一个批量写入场景,每次写入100万条数据到ArrayList,GC监控看到大量的Minor GC,P99延迟很高。
根本原因:ArrayList默认容量10,每次扩容1.5倍,100万数据需要扩容约40次。每次扩容都要分配新数组 + Arrays.copyOf,产生大量短命对象,触发GC。
具体解法:
// 错误:不设初始容量
List<Data> list = new ArrayList<>(); // 默认容量10
// 正确:预估容量
List<Data> list = new ArrayList<>(1_000_000); // 一步到位,不扩容
// 如果不确定大小,至少设一个合理的下限
List<Data> list = new ArrayList<>(1000); // 比默认10好很多
// 已知确切大小时:trimToSize()释放多余空间
list.trimToSize();坑3:用ArrayList.remove(Object)而不是remove(int),删错了元素
报错现象:代码里对List<Integer>调用remove(1),想删除值为1的元素,实际上删除了下标为1的元素。
根本原因:ArrayList有两个重载:remove(int index)和remove(Object o)。对List<Integer>调用remove(1),参数1是int,走的是remove(int)(按下标删除),而不是remove(Object)(按值删除)。
具体解法:
List<Integer> list = new ArrayList<>(Arrays.asList(0, 1, 2, 3));
// 错误:remove(1)按下标删,删的是下标1的元素(值为1碰巧相同,但逻辑不对)
list.remove(1); // 删下标1的元素
System.out.println(list); // [0, 2, 3]
// 正确:按值删除,要传Integer对象
list.remove(Integer.valueOf(1)); // 删值为1的元素
System.out.println(list); // [0, 2, 3]
// 另外:按下标删除时要注意倒序删,正序会跳过元素
// 错误(正序删除会跳元素):
for (int i = 0; i < list.size(); i++) {
if (list.get(i) % 2 == 0) list.remove(i); // 删一个后下标变了!
}
// 正确:用Iterator.remove()或倒序遍历
list.removeIf(x -> x % 2 == 0); // JDK8+,最简洁
// 或倒序:
for (int i = list.size() - 1; i >= 0; i--) {
if (list.get(i) % 2 == 0) list.remove(i);
}五、总结与延伸
"插入删除用LinkedList"是一个危险的误导性结论。 只有在头部/尾部的O(1)插入场景,LinkedList才有优势。中间位置插入,LinkedList因为需要O(n)遍历定位,实际比ArrayList慢得多。在遍历场景,由于CPU缓存效应,ArrayList也比LinkedList快约2-3倍。
内存连续带来的Cache友好性往往比算法复杂度影响更大。 现代CPU的L1 Cache大约3-4ns,L3 Cache大约30ns,主存访问约100ns。一次Cache Miss就足以抹掉链表插入省下的指针操作时间。这是计算机体系结构给算法复杂度分析打的折扣。
实际选择原则:几乎所有场景用ArrayList;需要队列语义用ArrayDeque;需要并发用ConcurrentLinkedQueue;只有在需要频繁头部插入且数据量很小时才考虑LinkedList。
