课本上那张表很好记:数组查询快、插入删除慢;链表查询慢、插入删除快。我照着这条规律选过几次容器,结果跑起来跟想的完全不是一回事。于是这次不按课本,直接测。

一、怎么测的

100 万个元素,六组操作,每组都跑完取"每次操作多少纳秒"。为了避免 JIT 预热影响,先跑几轮热身;为了不让编译器把循环优化掉,每次访问的结果都累加到一个变量里,最后打印出来。

有个坑必须提前说:LinkedList 的随机访问是 O(n),100 万次根本跑不完(要几十个小时)。所以这一项它只测 1000 次,再换算成每次耗时——换算过程在输出里写着,没藏。

二、结果

=== 1. 尾部追加 1000000 次 ===
int[]                        每次      6.9 ns
ArrayList           每次      9.4 ns
LinkedList          每次     74.0 ns=== 2. 随机 get 100 万次 ===
int[]                        每次      7.3 ns
ArrayList           每次     11.6 ns
LinkedList          每次 868905.8 ns   (换算自 1000 次)=== 3. 头部插入 10000 次 ===
ArrayList           每次   4702.3 ns
LinkedList          每次     63.1 ns=== 4. 中间插入 10000 次 ===
ArrayList           每次   2053.9 ns
LinkedList          每次  44760.3 ns=== 5. 遍历求和 100 万 ===
int[]                        每次      1.0 ns
ArrayList           每次      5.2 ns
LinkedList          每次     11.3 ns=== 6. 按值查找 200 次 ===
ArrayList           每次 447156.0 ns
LinkedList          每次 7066313.5 ns=== 7. 内存(100 万个 int)===
int[]                        3.9 MB
ArrayList          19.3 MB
LinkedList         38.2 MB

三、三个跟课本不一致的地方

第一,随机访问不是"慢一点",是慢七万多倍。

11.6 纳秒和 868905 纳秒,差了 7.5 万倍。0.0000116 毫秒对比 0.87 毫秒——一个是"感觉不到",一个是"肉眼可见地卡"。

原因不复杂:数组是连续内存,算个地址就取到了;链表要从头一个个跳,访问第 50 万个元素就要跳 50 万次。O(n) 这三个字平时看着没什么,乘上一百万就原形毕露。

第二,中间插入,链表反而慢 22 倍。

这才是真正让我意外的一条。LinkedList.add(size/2, x) 听起来是链表的强项,实测 44760 纳秒 vs 数组的 2053 纳秒。

原因是我一直忽略的那半句话:链表的插入是 O(1),指的是"已经拿到那个位置的节点之后"。而 add(index, x) 这个 API 自带一次"从头走到 index"的定位,那一步是 O(n)。所以你付的仍然是 O(n),还额外付了指针追逐的代价。

第三,链表唯一赢的地方是头部插入,赢了 75 倍。

add(0, x) 不需要定位——头插就是 O(1),63 纳秒。而数组要把它后面的元素全部往后挪一位,4702 纳秒。

所以"链表插入快"这句话不是错的,是缺了前提。前提是:插入位置你已经知道了。

四、为什么会差这么多

O 复杂度只说了"数据量变大时怎么涨",没说常数因子。而这里的常数因子差得非常离谱。

一个常见的解释是 CPU 缓存:数组的元素挨在一起,一次读入缓存行就能连带用好几个;链表每个节点都是堆上单独一块,地址乱跳,每次访问几乎都是缓存未命中。指针追逐(node = node.next)还会让 CPU 的分支预测和预取失效。

这条我没有专门测——要测需要做缓存命中率的实验,那是另一篇文章。但它和上面的数字是吻合的:同样是"走一百万步",数组那 7.3 纳秒看起来根本不像走了一百万步。

五、内存差十倍

3.9 MB、19.3 MB、38.2 MB。

int[] 就是 100 万个 4 字节,正好 3.9 MB。ArrayList 里除了一个 Object[],还有 100 万个 Integer 对象(对象头 16 字节 + 值),所以涨到 19.3 MB。LinkedList 每个节点还要多两个引用,38.2 MB。

也就是说,用 LinkedList 存 100 万个 int,比 int[] 多用十倍内存,然后每一项操作还都更慢。

(这个数是同类型分配两次取差值测的。第一次测出来 int[] 是负数,原因是 GC 把热身留下的垃圾也在那期间收掉了,差值算错。改成"分配两份、量第二份的增量"才对得上。)

六、结论

ArrayList 应该是默认选择。绝大多数场景下它都比 LinkedList 快,而且省内存。

LinkedList 值得用的场合只有一个:你确实要频繁在头部/尾部增删,并且把它当双端队列用。那用 ArrayDeque 通常还更快,所以严格说 LinkedList 在 Java 里已经很难找到非它不可的理由了。

那什么情况下链表真的快?当你已经持有那个位置的引用时——比如遍历的同时删除(用 Iterator.remove()),或者自己维护节点指针。这种情况下你付的不是 O(n),O(1) 的插入才真正兑现。

最后一句实话:这类基准数字有抖动。 我连跑两次,随机访问那一项从 52 万纳秒变成了 87 万纳秒,头部插入从 85 纳秒变成 63 纳秒。量级和结论是稳的,具体数值不必当真。

附:核心代码

static final int N = 1_000_000;// 随机访问:结果累加起来,防止 JIT 把循环整段优化掉
long sink = 0;
long t = System.nanoTime();
for (int i = 0; i < N; i++) sink += arr[idx[i]];
long d = System.nanoTime() - t;
System.out.println("每次 " + (double) d / N + " ns,sink=" + sink);// LinkedList 只测 1000 次,再换算
int small = 1000;
t = System.nanoTime();
for (int i = 0; i < small; i++) sink += ll.get(idx[i]);
d = System.nanoTime() - t;
System.out.println("每次 " + (double) d / small + " ns(换算)");// 内存:同类型分配两份,量第二份的增量
long t1 = used();
int[] q2 = new int[N];
long t2 = used();
System.out.println("int[] ≈ " + (t2 - t1) / 1024.0 / 1024.0 + " MB");static long used() {for (int i = 0; i < 3; i++) {System.gc();try { Thread.sleep(80); } catch (InterruptedException ignored) {}}Runtime rt = Runtime.getRuntime();return rt.totalMemory() - rt.freeMemory();
}