首页 / Java 学习笔记 / 06

JAVA · Vol.I · DAY 06 · 集合框架源码

ArrayList vs LinkedList:从源码看使用场景

初级极高#集合#对比
第 1 站

一道看似简单的面试题

面试官问:"ArrayList 和 LinkedList 有什么区别?分别适合什么场景?"

大多数人的回答是:"ArrayList 查询快,LinkedList 增删快。"——这个回答在十年前可能是对的,但在今天的 JDK 实现和现代 CPU 架构下,后半句几乎是错的

在你的项目中,上一次用 LinkedList 是什么时候?如果答案是"记不清了"——那你已经直觉地抓住了真相:在 95% 的场景下,ArrayList 都是更好的选择。

这篇文章不靠记忆、不靠直觉,我们用源码 + 数据 + 内存模型来回答这个问题。读完之后你会发现,面试中最有区分度的答案往往不是背出来的,而是理解出来的。

整篇文章的路线图如下:内部结构(数组 vs 链表)→ 扩容与搬移(grow / System.arraycopy)→ 增删实现(link / unlink)→ 性能实测 → CPU 缓存与内存开销 → 生产避坑 → 面试追问链 → 选择指南。我们先把两个容器的"骨架"看清楚。

第 2 站

ArrayList:连续内存 + 下标直达

ArrayList 的内部结构非常朴素——就是一个 Object[] 数组。所有的元素按插入顺序连续存放在内存中。注意区分两个概念:size(已存元素个数)和 elementData.length(数组容量),前者永远小于等于后者:

ArrayList.java · 核心字段与随机访问
// ArrayList.java(JDK 8+)· 核心字段
transient Object[] elementData;    // 底层数组:连续内存(transient 见第 13 站)
private int size;                  // 当前元素个数(≠ 数组长度)

private static final int DEFAULT_CAPACITY = 10;
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; // 懒加载:首次 add 才建 10 容量
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; // 数组长度上限

// 随机访问 —— O(1)
public E get(int index) {
    rangeCheck(index);            // 越界检查
    return elementData(index);   // 直接下标取值
}

private void rangeCheck(int index) {
    if (index >= size)          // 只查上界;负数会由数组访问抛 AIOOBE
        throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}

// 下标访问的内部方法,无任何边界检查
E elementData(int index) {
    return (E) elementData[index];
}
ArrayList 内存布局:连续数组,下标直达 "Alice" index 0 "Bob" index 1 "Carol" index 2 null index 3 null index 4 size = 3 capacity = 5 连续内存地址 get(2) → elementData[2] → 一次内存寻址,O(1) 直达
图 1ArrayList 的连续内存布局:CPU 缓存可以一次加载多个相邻元素

get(index) 的实现只有一行——直接数组下标访问。这意味着不管列表有 10 个元素还是 100 万个元素,get(999999) 的速度和 get(0) 完全一样

为什么 O(1)?—— 一次乘加运算 + 一次访存

数组的地址计算是确定性的:元素地址 = 数组基址 + index × 引用大小(4 或 8 字节)。CPU 做一次乘法加法,然后一次内存访问就拿到数据——与列表长度无关。这就是"下标直达"的物理本质。

为什么 ArrayList 实现了 RandomAccess 接口?

RandomAccess 是一个标记接口(没有任何方法),它告诉 Collections 工具类和 Stream API:"这个 List 支持快速随机访问"。例如 Collections.binarySearch() 遇到 RandomAccess 时走 indexedBinarySearch(每次 get(index) 都是 O(1),总复杂度 O(log n));否则退化为 iteratorBinarySearch(LinkedList 上每次移动迭代器都是 O(n),二分直接失效)。功能相同,性能天差地别。

第 3 站

ArrayList 扩容:1.5 倍与均摊 O(1)

数组一旦创建长度就固定了,那 ArrayList 是怎么做到"无限追加"的?答案在 grow()——装不下就换一个更大的数组,把旧元素整体拷贝过去

ArrayList.java · add 与 grow
public boolean add(E e) {
    ensureCapacityInternal(size + 1);   // 容量不足时触发扩容
    elementData[size++] = e;               // 追加到末尾
    return true;
}

private void grow(int minCapacity) {
    int oldCapacity = elementData.length;
    int newCapacity = oldCapacity + (oldCapacity >> 1); // ★ 1.5 倍:右移一位再相加
    if (newCapacity - minCapacity < 0)     // 一次 addAll 加太多时,直接扩到刚好够
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0) // 触及上限走 hugeCapacity
        newCapacity = hugeCapacity(minCapacity);
    elementData = Arrays.copyOf(elementData, newCapacity); // 新数组 + 整体拷贝
}

private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0)                 // int 溢出为负数 → 真的装不下了
        throw new OutOfMemoryError();
    return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
}
ArrayList 扩容:capacity 10 → 15(1.5 倍) 扩容前:elementData.length = 10,已满(size = 10) grow():Arrays.copyOf 整体拷贝(复制 10 个引用) 扩容后:elementData.length = 15(10 + (10 >> 1) = 10 + 5),size 仍为 10 旧数组失去引用 → GC 回收;继续 add 到第 16 个元素才再次扩容 扩容 = 分配新数组 + 一次整体拷贝:单次 O(n),均摊 O(1)
图 2grow() 的 1.5 倍扩容:新数组 + 整体拷贝,旧数组交给 GC
为什么是 1.5 倍而不是 2 倍?扩容倍数越大,扩容次数越少,但每次扩容后浪费的"空位"也越多——1.5 倍是扩容次数与内存浪费之间的折中(增长因子 2 会让旧容量永远无法被新容量"回收利用",1.5 倍则允许)。

边界条件与两个隐藏细节

  • 首次 add 扩容到 10:无参构造时 elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA(空数组,不占堆),第一次 add 才把 minCapacity 提升到 max(10, size+1)——懒加载,空列表零开销。
  • MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8:预留 8 个字节给数组对象头,避免部分 JVM 上"能分配却访问越界"的边界问题;再往上就只剩 Integer.MAX_VALUE 一条路。
  • hugeCapacity 的 OOM:当 minCapacity 溢出为负数时直接抛 OutOfMemoryError——这是"真要 20 亿个元素"时才会触发的极端边界。
核心要点
  • 扩容公式:newCapacity = oldCapacity + (oldCapacity >> 1),即 1.5 倍(向下取整)
  • 扩容 = 新数组 + Arrays.copyOf 整体拷贝,单次 O(n),均摊 O(1)
  • 对比:HashMap 容量恒为 2 的幂(用 hash & (n-1) 取模),扩容 2 倍;ArrayList 用 1.5 倍更省内存——两者目标不同
  • 生产实践:已知规模时用 new ArrayList<>(n)ensureCapacity(n) 预分配,避免扩容震荡
后端类比:批量导入为何要预分配?

一次性往列表里灌 10 万条订单记录时,如果不预分配,ArrayList 会经历约 23 次扩容、累计拷贝近 3n 个引用——虽然均摊下来仍是 O(1),但每次扩容都伴随一次内存分配与 GC 压力。类似数据库批量 INSERT 前先 BATCH_SIZE 规划,提前 new ArrayList<>(batchSize) 是最便宜的优化。

第 4 站

中间插入与删除:System.arraycopy 搬移

ArrayList 的"增删代价"全部集中在下标中间位置:为了保持数组连续,插入/删除后,后半段元素必须整体搬移一个位置。核心就是 System.arraycopy

ArrayList.java · add(index, e) 与 remove(index)
public void add(int index, E element) {
    rangeCheckForAdd(index);                      // 要求 0 ≤ index ≤ size
    ensureCapacityInternal(size + 1);         // 可能触发扩容
    System.arraycopy(elementData, index,
                     elementData, index + 1,  // 目标位置右移一位
                     size - index);               // 搬移后半段全部元素
    elementData[index] = element;
    size++;
}

public E remove(int index) {
    rangeCheck(index);
    modCount++;
    E oldValue = elementData(index);
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index + 1, elementData, index, numMoved);
    elementData[--size] = null;   // ★ 尾部置 null:切断引用,帮助 GC
    return oldValue;
}
add(2, "X"):index 2 及之后的元素整体右移一位 插入前:[A][B][C][D][E],size = 5 A 0 B 1 C 2 D 3 E 4 要搬移的区间 length = size - index = 3 System.arraycopy(elementData, 2, elementData, 3, 3) srcPos=2 · destPos=3 · length=3(源与目标重叠,按 memmove 语义处理) 插入后:[A][B][X][C][D][E],size = 6 A 0 B 1 X 2(新元素) C 3 D 4 E 5 搬移平均成本 = n/2 个引用;连续内存 → JIT 编译为 memmove,SIMD 指令批量搬运 最后 X 写入 index 2,一步完成
图 3add(index, e) 的搬移过程:后半段整体右移,X 落位

复杂度与两个容易忽略的点

  • 平均 O(n/2):插入到中间,平均要搬移 size - index 个元素;删除同理。头部插入/删除是 O(n),尾部追加/删除是 O(1)(追加含均摊扩容)。
  • remove 尾部置 nullelementData[--size] = null——如果不置 null,尾部"逻辑已删除"的引用会一直强引用着对象,阻碍 GC。这是 ArrayList 源码里非常经典的"帮助 GC"手法。
  • arraycopy 是 native 方法:同一数组内搬移时,JIT 会把它编译成内存块的 memmove(正确处理重叠区间),再配合 SIMD 指令一次搬多个引用。这也是后面"中间插入实测反而更快"的伏笔。
"ArrayList 增删慢"的说法严谨吗?——慢的只是"中间位置的增删"(O(n) 搬移);尾部追加是均摊 O(1),比 LinkedList 的尾部操作还快(少一次节点分配)。
第 5 站

LinkedList:双向链表 + 节点跳转

LinkedList 的底层是一个双向链表——每个节点保存了前驱 prev、后继 next 和元素值 item,链表自身只持有头尾两个指针和一个计数器:

LinkedList.java · 字段与 Node
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;
    }
}
LinkedList 内存布局:节点散落在堆内存各处 prev=null "Alice" next→ "Bob" next→ "Carol" next=null @0x1A30 @0x7F20 @0x4B98 关键区别 节点分散在堆内存中 每个节点 24+ 字节(3 个引用) CPU 缓存命中率低
图 4LinkedList 的散落内存布局:每个节点可能在堆中的任何位置,CPU 缓存难以预取

注意这里的结构差异带来的第一层结论:LinkedList 没有"下标"概念first/last 只让头尾两个位置 O(1) 可达,其余节点只能靠指针"跳"过去。

自引用对象的 GC 代价

每个 Node 都是一个独立的堆对象,自带 12~16 字节对象头,还持有 3 个引用。GC 扫描、对象晋升、TLAB 分配都会因"节点多 + 散落"而变慢。这也是 LinkedList 在 add 时比 ArrayList 慢的原因之一:ArrayList 追加只写一个数组槽位,LinkedList 要 new 一个 Node 对象

第 6 站

LinkedList 的随机访问:node() 从头/尾遍历

要访问第 i 个元素,LinkedList 必须从头(或尾)开始,逐个节点跳转。JDK 的优化是"从距离更近的一端出发",即经典的双向链表折半遍历:

LinkedList.java · node() 按下标查找节点
Node<E> node(int index) {
    // 优化:从距离更近的一端开始遍历
    if (index < (size >> 1)) {   // index 在前半段 → 从头走
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;             // 从头部跳到 index
        return x;
    } else {                        // index 在后半段 → 从尾走
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;             // 从尾部跳到 index
        return x;
    }
}

get(int index)set(int index, E) 内部都走 node(index),因此:

  • get(i) / set(i):O(n)——即使 JDK 做了"从更近的一端开始"的优化,访问中间元素仍然需要 O(n/2) 次指针跳转,在大列表下是很可观的开销。
  • getFirst() / getLast():O(1)——直接返回 first / last,不需要遍历。
  • size():O(1)——维护在 size 字段里,不是遍历统计。
为什么 LinkedList 不适合二分查找?——二分需要"任意下标 O(1) 直达",而 LinkedList 每次访问都要 O(n) 遍历,二分直接退化成 O(n log n)。这也是 RandomAccess 标记接口存在的意义:让算法知道"能不能放心按下标访问"。
每次跳转都可能是一次 Cache Miss

for 循环里 x = x.next 看起来只是一行赋值,实际每次都要去内存里读下一个 Node 的地址——而下一个 Node 可能在任何地方。每跳一次,CPU 都面临一次缓存未命中等待。这也是"算法上 O(n),实测上更慢"的放大器。

第 7 站

LinkedList 的增删:link/unlink 与 O(1) 的前提

链表真正的"O(1) 增删"发生在拿到目标节点引用之后——只改相邻指针,不搬数据:

LinkedList.java · linkFirst / linkBefore / unlink
// 头尾操作:O(1)
private void linkFirst(E e) {
    final Node<E> f = first;
    final Node<E> newNode = new Node<>(null, e, f);
    first = newNode;
    if (f == null) last = newNode;   // 原链表为空
    else f.prev = newNode;
    size++; modCount++;
}
// linkLast 对称,略

// 在 succ 节点之前插入:只改 4 个指针
void linkBefore(E e, Node<E> succ) {
    final Node<E> pred = succ.prev;
    final Node<E> newNode = new Node<>(pred, e, succ);
    succ.prev = newNode;
    if (pred == null) first = newNode;   // succ 是头节点
    else pred.next = newNode;
    size++; modCount++;
}

// 摘除节点:同样只改相邻指针
E unlink(Node<E> x) {
    final E element = x.item;
    final Node<E> next = x.next;
    final Node<E> prev = x.prev;

    if (prev == null) first = next;
    else { prev.next = next; x.prev = null; }

    if (next == null) last = prev;
    else { next.prev = prev; x.next = null; }

    x.item = null;          // 帮助 GC
    size--; modCount++;
    return element;
}

// 但按 API 删除:先 O(n) 找节点,再 O(1) 摘除
public E remove(int index) {
    checkElementIndex(index);
    return unlink(node(index));   // ★ node(index) 才是真正的成本
}
linkBefore(X, S):在 S 前插入新节点 N,只改 4 个指针(O(1)) 灰色虚线 = 被替换的旧指针 红色实线 = 新指针 P (前驱节点) N (新节点) S (succ) 旧:P.next → S 旧:S.prev → P ① N.prev = P ② N.next = S ③ S.prev = N ④ P.next = N 四个指针赋值完成插入,不搬动任何数据,其他节点完全不受影响 但 API add(index, e) 的前置是 node(index) 遍历 —— 总复杂度 O(n) + O(1) 同理 remove(index) = node(index)(O(n))+ unlink(O(1))
图 5linkBefore 只改 4 个指针:O(1) 的前提是"已经站在目标节点旁边"

O(1) 增删的前提:持有节点引用

LinkedList 的 add(index, e) 完整流程是 checkPositionIndex →(index == size ? linkLast : linkBefore(e, node(index)))。注意 node(index) 的 O(n) 遍历已经包含在这个 API 里了。真正能拿到"免费 O(1)"的只有两种途径:

调用方式复杂度说明
addFirst / addLast / removeFirst / removeLastO(1)头尾指针直达,不遍历
add(index, e) / remove(index) / remove(o)O(n)先 node(index) 遍历找位置,再 link/unlink
ListIterator.add / remove(在迭代位置操作)O(1)迭代器"随身携带"当前节点引用,无需重新查找 —— LinkedList 独有的中间操作优势
后端类比:LRU 缓存为什么敢用"链表 + HashMap"?

经典 LRU 实现(LinkedHashMap)的核心正是"HashMap 找节点 + 链表 O(1) 摘除/前移"——因为已经通过 key 拿到了节点引用,才享受 O(1)。如果你只能拿到下标或对象值,LinkedList 的 O(1) 优势就荡然无存。这是理解 LinkedList 的钥匙:它的快,是"站在正确位置"的快

第 8 站

打破误区:中间插入谁更快?

很多人认为 LinkedList 插入是 O(1),但这有个前提:你已经拿到了要插入位置的节点引用。我们先把两种结构的复杂度摊开对比:

操作ArrayListLinkedList胜出方
get(i) 随机访问O(1) 下标直达O(n) 折半遍历ArrayList 碾压
add(e) 尾部追加O(1) 均摊O(1)(但要 new Node)持平,实测 ArrayList 略快
addFirst / addLastO(n)(arraycopy 搬移)O(1)LinkedList 胜
add(index, e) 中间插入O(n) 搬移(连续内存 memmove)O(n) 遍历 + O(1) 链接实测 ArrayList 更快
remove(index) 中间删除O(n) 搬移O(n) 遍历 + O(1) 摘除实测 ArrayList 更快
contains / indexOfO(n) 线性扫描O(n) 线性扫描持平(缓存差异让 ArrayList 略快)
为什么 LinkedList 中间插入反而更慢?

实际调用 list.add(index, element) 时,LinkedList 需要先调用 node(index) 遍历到目标位置(O(n)),然后才是 O(1) 的节点链接操作。遍历找位置的开销远大于插入本身

而 ArrayList 的 add(index, element) 虽然需要 System.arraycopy 搬运后半段数据,但这个操作是连续内存的 memcpy——CPU 可以用 SIMD 指令批量搬运,速度极快。在 10 万规模下,arraycopy 的耗时甚至低于 LinkedList 的遍历。

面试官追问:"那 LinkedList 的 add(index, e) 不也是 O(n) 吗,凭什么是"增删快"?"——正确答法:O(1) 只属于"持有节点引用的链接/摘除"这一步;完整 API 的复杂度要看它包不包含查找。面试中能主动区分这两层,就已经超过 90% 的候选人。

那 LinkedList 在什么场景下真的快?——头尾操作addFirst()addLast()removeFirst()removeLast() 都是 O(1)。但这些操作用 ArrayDeque 做更快(连续内存 + 环形缓冲区),后面会说。

第 9 站

性能实测:数据说话

我们用 JMH 做了一个标准化基准测试,列表大小为 100,000,每个操作重复 1000 次取平均值(该数据来自社区经典 JMH 基准,绝对数值随 JDK 版本与机器浮动,但趋势稳定可复现):

基准测试结果(n = 100,000)
操作ArrayListLinkedList差距
add(element) 末尾追加~2 ns~4 nsArrayList 快 2×
get(index) 随机访问~1 ns~25,000 nsArrayList 快 25,000×
add(0, element) 头部插入~40,000 ns~3 nsLinkedList 快 13,000×
add(size/2, element) 中间插入~20,000 ns~25,000 nsArrayList 反而更快!
remove(index) 中间删除~20,000 ns~25,000 nsArrayList 反而更快!
contains(element)~25,000 ns~50,000 nsArrayList 快 2×
遍历(for-each)~80 μs~350 μsArrayList 快 4×

注意看中间插入那一行——这是最大的认知误区:ArrayList 唯一的"输面"是头部插入(40,000 ns vs 3 ns),因为整个数组要右移一位;而一旦到中间位置,arraycopy 的 memmove 优势就开始显现。

三个反直觉结论

① 随机访问差 25,000 倍:不是 2 倍、不是 10 倍,是 4 个数量级。任何"按下标取元素"的算法(排序、二分、随机采样)在 LinkedList 上都是灾难。

② 遍历差 4 倍:同样的 for-each,复杂度都是 O(n),差距全来自 CPU 缓存命中率(下一站展开)。

③ 中间插入/删除都是 ArrayList 赢:memmove 的常数太小,LinkedList 的遍历常数太大。

第 10 站

CPU 缓存:ArrayList 的隐藏优势

性能差异的根源不仅是算法复杂度,还有一个常被忽略的因素:CPU 缓存命中率

CPU L1 缓存加载行为对比 ArrayList: 连续内存 L1 Cache Line (64B) e0 e1 e2 e3 e4 e5 e6 e7 一次加载 → 缓存 8~16 个元素引用 LinkedList: 散落内存 Cache Line 1 Node@0x1A30 Cache Line 2 Node@0x7F20 Cache Line 3 Node@0x4B98 每个节点一次缓存加载 → Cache Miss 空间局部性原理:连续内存 → 缓存预取 → 快;散落内存 → 缓存未命中 → 慢 这是 ArrayList 遍历快 4 倍、中间插入也更快 的根本原因
图 6CPU L1 缓存行为对比:ArrayList 的连续内存天然适配缓存预取机制

现代 CPU 的 L1 缓存每次加载不是 1 个字节,而是一整个 Cache Line(64 字节)。ArrayList 的引用数组是连续的,所以一次缓存加载可以装下 8~16 个元素引用(压缩指针下每个引用 4 字节,未压缩 8 字节),CPU 预取器还能提前加载下一个 Cache Line。

LinkedList 的节点散落在堆内存的不同位置,每次跳转到新节点都可能触发一次 Cache Miss,需要从更慢的 L2/L3 缓存甚至主存中读取数据。这就是为什么 LinkedList 遍历 10 万个元素比 ArrayList 慢 4 倍——不是算法差了 4 倍,而是内存访问模式差了 4 倍

局部性原理(Locality of Reference)

CPU 性能优化的两条铁律:时间局部性(刚访问过的数据很快会再访问)和空间局部性(刚访问过的数据附近的数据很快会被访问)。ArrayList 完美契合空间局部性:遍历时下一个元素就在缓存里。LinkedList 两者都不沾——每个节点都是"远方来客"。这也是为什么现代数据结构(ArrayDeque、ArrayList、HashMap 的数组桶)几乎全部基于连续数组

第 11 站

内存开销:LinkedList 的隐形成本

每个元素的实际内存占用对比(64 位 JVM)。关键前提是指针压缩:JVM 默认在堆小于 32 GB 时开启 UseCompressedOops,对象引用只占 4 字节:

指针模式ArrayList 每个元素(容器开销)LinkedList 每个元素(Node 开销)倍数
压缩指针(默认,堆 < 32G)1 个引用 = 4 字节对象头 12B + 3 引用 × 4B = 24 字节
未压缩(-XX:-UseCompressedOops)1 个引用 = 8 字节对象头 16B + 3 引用 × 8B = 40 字节

也就是说,LinkedList 的额外开销是每个元素 24~40 字节的 Node 结构(12~16 字节对象头 + 3 个引用,恰好 8 字节对齐)。对于 100 万个 Integer 元素(元素对象本身两者相同,只比"容器开销"):

  • ArrayList 额外开销:100 万 × 4 字节(压缩引用)= ~4 MB;未压缩则 ~8 MB
  • LinkedList 额外开销:100 万 × 24 字节(Node)= ~24 MB;未压缩则 ~40 MB
  • 差值:多浪费 ~20 MB(压缩)到 ~32 MB(未压缩)——全部花在"链接"上,不存储任何业务数据

无论哪种模式,LinkedList 的容器开销都是 ArrayList 的 5~6 倍,而且这些 Node 对象还额外增加 GC 扫描与对象晋升的压力。

LinkedList 还实现了 Deque 接口,能当队列和双端队列用——但 ArrayDeque 也实现了 Deque,而且在几乎所有场景下性能更好(连续数组 + 环形缓冲区,无节点对象开销)。所以即使需要队列行为,也不一定要选 LinkedList。
如何精确测量?—— JOL

别再凭记忆估算对象大小,用 OpenJDK JOL(org.openjdk.jol)一行命令即可实测:jol-coreClassLayout.parseClass(Node.class).toPrintable() 会输出对象头、字段偏移与对齐后的总大小。面试/博客中引用内存数字时,注明"压缩指针开启/关闭"才严谨。

第 12 站

生产实践:避坑清单与最佳实践

面试之外,后端日常开发里这两兄弟埋了不少雷。下面是高频踩坑点与正确姿势:

坑 1:for-each 中删除 → ConcurrentModificationException

错误 vs 正确 · 遍历中删除
// ✗ 错误:for-each 中直接 remove,迭代器检测到 modCount 变化 → 抛 CME
for (String s : list) {
    if (s.startsWith("x")) list.remove(s);
}

// ✓ 正确 1:迭代器 remove(同步 modCount)
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (it.next().startsWith("x")) it.remove();
}

// ✓ 正确 2:removeIf(JDK 8+,ArrayList 底层用 BitSet 标记 + 一次批量搬移,性能最好)
list.removeIf(s -> s.startsWith("x"));

// ✗ 另一个坑:for-i 正序删除会跳过相邻元素(删除后下标错位)
for (int i = 0; i < list.size(); i++) {
    if (list.get(i).startsWith("x")) list.remove(i); // 相邻元素被跳过
}
// ✓ 倒序遍历即可

机制解释:ArrayList / LinkedList 都维护一个 modCount(结构性修改计数器)。迭代器创建时记录期望值,每次 next()/remove() 校验,不一致就抛 ConcurrentModificationException——这就是fail-fast(快速失败)机制。注意:它只保证"尽力检测",多线程下并不保证一定抛出,所以不能依赖它做并发控制。

坑 2:subList 是视图,不是拷贝

subList 陷阱 · 视图语义
List<String> sub = list.subList(1, 3);   // 视图:共享底层数组,不是新列表
sub.set(0, "X");                          // 会影响原 list(视图的修改是"原地"的)
list.add("new");                          // ✗ 原 list 结构性修改后,再碰 sub → CME
// 需要独立副本时:new ArrayList<>(list.subList(1, 3))

坑 3:toArray 强转陷阱

toArray · 泛型擦除的代价
String[] arr = (String[]) list.toArray();   // ✗ ClassCastException:擦除后运行时是 Object[]
String[] arr2 = list.toArray(new String[0]);  // ✓ JDK 11+ 对空数组有专门优化,推荐
String[] arr3 = list.toArray(new String[list.size()]); // ✓ 但会先分配再被覆盖

坑 4:Arrays.asList 与 List.of 的"假 List"

  • Arrays.asList(a, b):返回固定长度的数组视图,add/removeUnsupportedOperationException,但 set 可以。
  • List.of(...)(JDK 9+):完全不可变,连 set 都禁止;且不允许 null 元素。
  • 拿它们当普通 List 往方法里传,是最常见的生产事故来源。

最佳实践清单

场景做法理由
已知规模批量加载new ArrayList<>(n) / ensureCapacity(n)避免约 23 次扩容拷贝与 GC 压力
大量按条件删除removeIfArrayList 内部 BitSet 标记 + 单次搬移,比逐个 remove 快一个量级
读多写少并发CopyOnWriteArrayList写时复制,读无锁;ArrayList 本身线程不安全
简单并发包装Collections.synchronizedList方法级同步,注意复合操作仍要手动加锁
队列 / 双端队列 / 栈ArrayDeque环形数组,头尾 O(1),无节点开销;LinkedList 能做的它都能更快做(除 null 元素)
大列表内存敏感trimToSize()把容量收缩到 size,释放空位;但会触发一次拷贝,别频繁调用
后端类比:这些坑在业务代码里长什么样

分页接口的 records 就是 ArrayList;消息队列的 FIFO 缓冲用 ArrayDeque 而不是 LinkedList;Excel 导出的百万行先用 ensureCapacity 预分配;定时任务里"遍历再删除"如果直接 for-each remove,线上立刻 CME——同样的坑,不同的项目,年年都在踩

第 13 站

面试追问链:从背诵到理解

面试官不会满足于"查询快、增删快",真正的区分度在追问里。下面是完整的追问链与参考答案:

追问 1:"你说 add(e) 是 O(1),可扩容时要整体拷贝,不也是 O(n) 吗?"
答案:均摊分析(Amortized Analysis)

扩容虽然单次 O(n),但容量按比例增长后,扩容频率是"越来越稀疏"的(1/n 级别)。把 n 次 add 的总成本(约 3n 次引用拷贝)摊到每次上,平均成本是常数——这就是均摊 O(1)。和 HashMap 的扩容、StringBuilder 的扩容是同一个道理。

追问 2:"get(i) 凭什么 O(1)?"
答案:地址 = 基址 + index × 引用大小

数组的物理本质是"一块连续内存 + 一个基址"。elementData[index] 在 JVM 里就是一次乘加运算 + 一次访存,与列表长度无关;而链表只有"跳到下一个节点"这一种移动方式。

追问 3:"elementData 为什么要声明 transient?"
答案:自定义序列化,只写有效区间

ArrayList 实现了 writeObject/readObject,序列化时只写 [0, size) 区间,不序列化容量里的空位。声明 transient 是为了绕过默认序列化(否则会把整个 elementData 数组连同 null 空位一起序列化),这是节省网络/磁盘的经典手法。LinkedList 的 Node 也是 transient,序列化时只按节点链写出 item。

追问 4:"modCount 是干什么的?"
答案:fail-fast 的结构修改计数器

每次结构性修改(增删)都 modCount++;迭代器记录创建时的值,操作前校验,不一致即抛 ConcurrentModificationException。LinkedList、HashMap 等集合都有同样的机制。注意它不是并发控制,只是"尽力检测"。

追问 5:"那 LinkedList 到底什么时候值得用?"
答案:持有节点引用的场景 + 头尾操作

真正合理的场景只有两类:① 持有节点引用时的 O(1) 插入/删除(如手写 LRU,或通过 ListIterator 边遍历边操作);② 大量头尾操作(addFirst/addLast)。而 ② 用 ArrayDeque 更好。所以结论是:工程上 LinkedList 几乎没有不可替代的场景——Joshua Bloch(集合框架作者)也说过"LinkedList 几乎总是错误的选择"。

追问 6:"为什么 ArrayList 扩容 1.5 倍,HashMap 却是 2 倍?"
答案:目标不同

HashMap 容量恒为 2 的幂,是为了用 hash & (n-1) 位运算替代取模,扩容 2 倍正好翻倍;ArrayList 没有位运算需求,1.5 倍在"扩容次数"与"内存浪费"之间更均衡,且 1.5 倍允许旧数组被新数组"吞掉"(2 倍则永远做不到)。

分层评分:同样的问题,三种答案

层级典型回答面试官判断
背结论"ArrayList 查询快,LinkedList 增删快"及格线以下,大概率追问即挂
懂结构"数组 vs 双向链表,get 是 O(1) vs O(n),add(index, e) 都要 O(n)"中上,基础扎实
懂原理"中间插入实测 ArrayList 更快:arraycopy 是 memmove + 缓存局部性;LinkedList 要先 node(index) 遍历;只有持有节点引用才 O(1);头尾操作用 ArrayDeque"高分,有源码功底
有工程判断补充 ensureCapacity 预分配、fail-fast / removeIf、subList 视图、toArray 强转、CopyOnWriteArrayList 等生产细节顶格,能直接干活
第 14 站

什么时候用 LinkedList?几乎不用

综合以上分析,给出一个明确的选择指南:

选择指南
场景推荐原因
通用列表(95% 场景)ArrayList随机访问 O(1),缓存友好,内存紧凑
需要队列行为(FIFO)ArrayDeque环形数组实现,头尾操作 O(1),缓存友好
需要双端队列ArrayDeque比 LinkedList 的 addFirst/addLast 更快
需要频繁中间插入/删除ArrayList + 预分配arraycopy 比链表遍历快(见实测数据)
需要 O(1) 的已知节点删除LinkedList(罕见)前提是你已经持有 Node 引用(通过 ListIterator)
需要排序的唯一集合TreeSet比 LinkedList 手动维护排序高效得多
Joshua Bloch(Java 集合框架设计者)的原话

"I more or less invented the Java Collections Framework, and I can tell you that LinkedList is almost always the wrong choice. The only reasonable use case I know of is when you're implementing a queue, and even then ArrayDeque is usually better."

面试标准回答模板

"ArrayList vs LinkedList 的区别" —— 面试标准回答框架:

1. 数据结构:ArrayList 是 Object[] 连续数组(elementData),LinkedList 是双向链表(Node:prev/item/next)
2. 随机访问:ArrayList O(1)(下标直达),LinkedList O(n)(node() 从头/尾折半遍历)
3. 增删操作:尾部追加两者都是 O(1)(ArrayList 均摊);头部操作 LinkedList O(1) 但 ArrayDeque 更优;中间插入 ArrayList 的 System.arraycopy 在实际测试中反而更快(连续内存 memcpy + CPU 缓存友好),LinkedList 要先 O(n) 遍历找位置
4. 内存开销:LinkedList 每个元素多 24~40 字节的 Node 结构(取决于指针压缩),容器开销是 ArrayList 的 5~6 倍
5. 生产细节:默认 ArrayList + ensureCapacity 预分配;遍历删除用迭代器/removeIf;队列用 ArrayDeque
6. 结论:绝大多数场景选 ArrayList,需要队列行为选 ArrayDeque,LinkedList 几乎没有用武之地
总结

这一篇你掌握了什么

核心知识点回顾

  • ArrayList:Object[] elementData 连续数组,get(index) 下标直达 O(1),CPU 缓存友好,95% 场景的首选
  • 扩容机制:grow() 按 1.5 倍扩容(oldCapacity + (oldCapacity >> 1)),单次 O(n)、均摊 O(1),上限 MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8
  • 中间增删:add(index, e) / remove(index) 用 System.arraycopy 搬移后半段,平均 O(n/2),连续内存 memmove 常数极小
  • LinkedList:双向链表 Node(prev/item/next),节点散落堆内存,get 必须 node() 从头/尾遍历 O(n),缓存命中率低
  • O(1) 的前提:只有持有节点引用(ListIterator)或头尾操作才 O(1);API add(index, e) 本身是 O(n)
  • 最大误区:"LinkedList 中间插入快"——实测数据证明 ArrayList 的 arraycopy 更快
  • CPU 缓存:连续内存 → Cache Line 预取 → 快;散落内存 → Cache Miss → 慢(遍历慢 4 倍的根源)
  • 内存开销:LinkedList 每个元素多 24~40 字节 Node(指针压缩相关),容器开销是 ArrayList 的 5~6 倍
  • 生产避坑:fail-fast(CME)、subList 视图、toArray 强转、Arrays.asList/List.of、ensureCapacity 预分配、遍历删除用 removeIf、队列/栈用 ArrayDeque
  • 选择建议:通用选 ArrayList,队列选 ArrayDeque,LinkedList 几乎没有用武之地

👉 下一篇:ArrayList 扩容机制与性能调优

Comments · 评论