首页 / Java 学习笔记 / 07

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

ArrayList 扩容机制与性能调优

中级高频#集合#源码#性能
第 1 站

100 万条数据导入引发的性能灾难

上周,同事找到我:"线上批量导入接口突然变慢了,以前 2 秒搞定,现在要 8 秒。" 排查后发现罪魁祸首只有一行代码:

BatchImport.java · 问题代码
// 从 CSV 读取 100 万条记录,逐条加入 ArrayList
List<Order> orders = new ArrayList<>();  // 默认容量 10(延迟分配)

for (String line : csvLines) {       // csvLines.size() ≈ 1_000_000
    orders.add(parseOrder(line));     // 第 11 次 add 触发第一次扩容
}

这行看似无害的 new ArrayList<>(),在 100 万条数据面前会触发29 次扩容。每一次扩容都意味着:申请新数组 → 拷贝全部旧元素 → 丢弃旧数组。累计拷贝的元素约 243 万次(约为插入量的 2.4 倍),还制造了大量短命对象等着 GC 回收。

把代码改成 new ArrayList<>(1_000_000),问题就消失了。但要做到"写对",你得理解 ArrayList 内部到底发生了什么。这一站,我们从源码级别拆解 ArrayList 的扩容机制。

为什么"少分配几个数组"能带来 4 倍的性能差距?扩容的真正代价是什么?

带着三个问题读完全文: 扩容到底执行了哪些方法、谁先谁后? 为什么 29 次扩容比预分配慢那么多——瓶颈在拷贝还是在 GC? 面试官追问 new ArrayList<>(0)new ArrayList<>() 的区别时,你能答出来吗?
第 2 站

ArrayList 内部结构:三个关键字段

打开 JDK 8 的 ArrayList 源码,整个类建立在三个核心字段之上:

ArrayList.java · JDK 8 核心字段
public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable {

    // 默认初始容量(无参构造首次 add 时的目标容量)
    private static final int DEFAULT_CAPACITY = 10;

    // 两个共享的空数组哨兵(见下文,是面试高频点)
    private static final Object[] EMPTY_ELEMENTDATA = {};
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

    // 底层存储数组——ArrayList 的全部秘密都在这里
    transient Object[] elementData;

    // 当前元素个数(不是数组长度!)
    private int size;
}

很多人混淆 sizecapacity,面试时被追问就露馅:

概念含义获取方式示例
size已存储的元素个数(逻辑长度)list.size()添加了 5 个元素 → size = 5
capacity底层数组的实际长度(物理容量)elementData.length默认构造 → capacity = 10

两个空数组哨兵:面试官最爱挖的细节

JDK 8 里有两个长得一模一样的空数组,用途完全不同:

字段由谁赋值首次 add 时的行为
EMPTY_ELEMENTDATAnew ArrayList<>(0)minCapacity 原样扩容,不享受默认 10
DEFAULTCAPACITY_EMPTY_ELEMENTDATAnew ArrayList<>()首次 add 时放大到 max(10, minCapacity),即 10

为什么要区分?因为 calculateCapacity() 需要知道"这个列表是不是无参构造创建的",是的话第一次扩容必须给足 10——这是 JDK 8 引入的延迟初始化策略:无参构造不直接 new Object[10],而是指向共享哨兵,真正分配推迟到第一次 add()。好处是创建大量空列表时不浪费内存。

ArrayList.java · JDK 8 三个构造器
public ArrayList() {                    // 无参:指向默认哨兵,容量 0
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        this.elementData = new Object[initialCapacity];  // 立即分配
    } else if (initialCapacity == 0) {
        this.elementData = EMPTY_ELEMENTDATA;           // 显式 0 容量
    } else {
        throw new IllegalArgumentException(
            "Illegal Capacity: " + initialCapacity);  // 负数直接拒绝
    }
}

public ArrayList(Collection<? extends E> c) {  // 复制构造:先 toArray 再判断类型
    elementData = c.toArray();
    if ((size = elementData.length) != 0) {
        // c.toArray() 可能返回 Object[] 以外的类型,需要重新拷贝
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
    } else {
        this.elementData = EMPTY_ELEMENTDATA;
    }
}
一句话点破

new ArrayList<>() 创建出来的列表,此刻容量是 0(指向空哨兵),不是 10。"默认容量 10" 说的是第一次 add 时的目标容量,而不是构造时的容量——这个细节几乎每次面试都会被问到。

第 3 站

一次 add() 的完整调用链:首次扩容如何到 10

add()grow() 的整条链路铺开看,是理解扩容机制的关键。JDK 8 的调用链是四层:

ArrayList.java · JDK 8 add → ensureCapacityInternal → calculateCapacity → ensureExplicitCapacity
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // ① 扩容检查:需要的最小容量 = size + 1
    elementData[size++] = e;             // ③ 写入元素并自增 size
    return true;
}

private void ensureCapacityInternal(int minCapacity) {
    ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}

// ② 关键:只有无参构造创建的列表(指向默认哨兵)才放大到 10
private static int calculateCapacity(Object[] elementData, int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        return Math.max(DEFAULT_CAPACITY, minCapacity);  // 首次 add → max(10, 1) = 10
    }
    return minCapacity;
}

private void ensureExplicitCapacity(int minCapacity) {
    modCount++;  // 结构修改计数:迭代器/视图靠它做 fail-fast 校验
    if (minCapacity - elementData.length > 0)  // 容量不足才真正扩容
        grow(minCapacity);
}
add(e) 的扩容检查链路(JDK 8) add(e) 入口 ensureCapacityInternal (size + 1) calculateCapacity 首次 add → max(10, min) ensureExplicitCapacity modCount++;容量不足才继续 grow(minCapacity) 1.5 倍扩容 容量充足 → 直接写入元素 容量不足 → 扩容后回到 add elementData[size++] = e 容量检查通过后写入元素 Arrays.copyOf(...) 内部调用 System.arraycopy(native) 扩容完成后同样回到 add 写入
图 1一次 add(e) 要穿过四层检查。注意 ensureExplicitCapacity 里无论是否需要扩容都会 modCount++——这为迭代器的 fail-fast 机制埋下了伏笔。

三种构造方式,首次 add 后容量各是多少

构造方式elementData 指向首次 add 后容量
new ArrayList<>()DEFAULTCAPACITY_EMPTY_ELEMENTDATA10max(10, 1)
new ArrayList<>(0)EMPTY_ELEMENTDATA1(不享受默认 10,直接按 minCapacity=1
new ArrayList<>(16)长度为 16 的新数组16(无需扩容)

还有一个历史知识点:JDK 6/7 里 new ArrayList<>() 是直接 this(10) 分配 10 个槽位的;JDK 8 引入哨兵数组后改为延迟分配。所以如果你在 JDK 6/7 的代码里见过"无参构造立即分配 10",那是对的——只是版本不同。

为什么第一次 add 要直接给 10,而不是 1、5 或 16?

10 是经验值:多数小列表容量在 10 以内,一次给够可以避免前几次频繁扩容;同时 10 个引用只占 40 字节左右(压缩指针下),对绝大多数场景几乎无内存代价。给 1 会导致前几次 add 疯狂扩容(1→2→3→4→6→9→14...),给 16 又对"只存 3 个元素"的列表浪费。10 是"空间 vs 扩容次数"的工程折中。
第 4 站

1.5 倍扩容公式:grow() 与容量边界

ensureExplicitCapacity 发现 minCapacity > elementData.length,就调用 grow() 扩容。JDK 8 的实现堪称经典:

ArrayList.java · JDK 8 grow() 方法
private void grow(int minCapacity) {
    // overflow-conscious code:每一处比较都防溢出
    int oldCapacity = elementData.length;

    // ★ 核心公式:右移 1 位 = 整除 2(向下取整)
    // newCapacity = oldCapacity + oldCapacity / 2 = oldCapacity × 1.5
    int newCapacity = oldCapacity + (oldCapacity >> 1);

    // 如果 1.5 倍还不够(比如 addAll 一次要加很多),直接用请求的最小容量
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;

    // 防止溢出:接近 Integer.MAX_VALUE 时的安全处理
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);

    // Arrays.copyOf 内部调用 System.arraycopy → 真正的开销在这里
    elementData = Arrays.copyOf(elementData, newCapacity);
}

// 数组长度上限:给对象头留 8 个 int 的余量
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0)  // 溢出为负:说明请求容量超过了 int 上限
        throw new OutOfMemoryError("Required array size too large");
    return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
}
扩容公式:newCapacity = oldCapacity + (oldCapacity >> 1) = oldCapacity × 1.5
位运算 >> 1 等价于"整除 2 后向下取整",对正整数与 / 2 结果一致,且天然规避了负数除法向零取整的语义差异

从默认容量 10 出发,每一次扩容的容量变化如下(注意纵轴是对数刻度,容量呈指数起飞):

扩容容量轨迹(对数刻度):10 → 1,215,487,共 29 次扩容 第 N 次扩容(0 = 初始容量 10) 容量(对数刻度) 10 100 1,000 10,000 100,000 1,000,000(目标线) 29 次扩容 → 容量 1,215,487 0 5 10 15 20 25 29 第 28 次:810,325(仍小于 100 万)
图 2容量序列:10 → 15 → 22 → 33 → ... → 810,325 → 1,215,487。前 8 次看起来增长缓慢,后期才指数起飞;要装下 100 万元素,第 29 次扩容后容量 1,215,487 才首次达标。

grow() 的三个边界保护

边界场景行为保护逻辑
new ArrayList<>(-1)IllegalArgumentException构造器里的 else 分支显式拒绝
ensureCapacity(-1)静默返回,无异常minCapacity > elementData.length 不成立
1.5 倍后仍不够(addAll 大集合)直接用 minCapacitynewCapacity - minCapacity < 0 分支
容量逼近 Integer.MAX_VALUEhugeCapacity 兜底,溢出则 OOMoldCapacity + (oldCapacity >> 1) 溢出为负后,minCapacity < 0 检查

为什么 MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8?HotSpot 里数组有对象头(mark word + klass 指针),某些 JVM 上数组长度超过 MAX_ARRAY_SIZE 会直接抛 "Requested array size exceeds VM limit",所以预留 8 个 int 的余量。当然,真的扩到 Integer.MAX_VALUE 意味着一个约 8~16 GB 的数组——在绝大多数生产堆上必然 OOM,这只是理论边界。

为什么 ArrayList 选 1.5 倍而不是 2 倍(像 HashMap 那样翻倍)?

① 内存复用。1.5 倍序列是 C → 1.5C → 2.25C → 3.375C……第 k 次的新数组 ≈ 1.5kC,而之前所有旧数组累计 ≈ 2 × (1.5k − 1)C,从第 2 次扩容起新数组就能"装进"之前释放的连续空间,有机会直接复用(配合分代 GC 的大对象分配)。2 倍序列 C → 2C → 4C → 8C,新数组永远大于所有旧数组之和,永远无法复用。

② 空间浪费。扩容后数组至少用到 1/1.5 ≈ 66.7%,最差浪费约 33%;2 倍扩容最差浪费约 50%(刚翻倍就只装了一半多一点)。

③ 均摊复杂度相同。无论 1.5 倍还是 2 倍,add 的均摊时间都是 O(1),区别只在常数:1.5 倍总拷贝量约 2N,2 倍约 N——2 倍拷贝更少但空间更浪费。JDK 在"拷贝量"和"内存占用"之间选了更省内存的 1.5。
版本演进:JDK 17+ 的 grow()

从 JDK 17 起,grow() 被重构为调用 ArraysSupport.newLength(oldCapacity, minCapacity - oldCapacity, oldCapacity >> 1),其中第三个参数 preferred growth = oldCapacity >> 1 就是 1.5 倍,默认容量 10 与哨兵判断也被并进了 grow()。语义与 JDK 8 完全一致。面试直接答 JDK 8 版本即可,提一句"新版本抽到了 ArraysSupport,行为不变"能加分。

第 5 站

System.arraycopy:扩容的真正代价

扩容的最后一步 Arrays.copyOf() 内部调用的就是 System.arraycopy——一个 native 方法,直接操作 JVM 内存:

System.java · arraycopy 签名
// native 方法,由 JVM 用 C/C++ 实现
public static native void arraycopy(
    Object src,   int srcPos,    // 源数组 + 起始位置
    Object dest,  int destPos,   // 目标数组 + 起始位置
    int length                     // 要拷贝的元素个数
);
旧数组 elementData(容量 10,已满) e0 e1 e2 e3 e4 e5 e6 e7 e8 e9 一次性拷贝 10 个引用 (浅拷贝,不是复制对象) 新数组(容量 15:前 10 格被复制,后 5 格为 null) e0 e1 e2 e3 e4 e5 e6 e7 e8 e9 null null null null null 浅拷贝:数组里存的是引用,arraycopy 只复制引用本身;对引用数组,HotSpot 还会逐一维护 GC 写屏障(card marking),所以比基本类型数组略慢。
图 3扩容 = 申请新数组 + 一次 System.arraycopy + 旧数组交给 GC。拷的是引用不是对象,但引用数组的拷贝会触发写屏障维护。

arraycopy 是 O(n) 操作——拷贝多少个元素,就做多少次内存写入。在 HotSpot JVM 中它被 intrinsify(内建化)为 CPU 的 SIMD 指令(如 SSE/AVX),对基本类型数组尤其快;对引用数组(Object[]),JVM 还要维护写屏障(Write Barrier / card marking)来保证 GC 可达性信息正确,所以比基本类型慢一些。

来算一笔账——从容量 10 开始,插入 N = 1,000,000 个元素,总共拷贝了多少次?

总拷贝量 = 10 + 15 + 22 + 33 + ... + 540,217 + 810,325 ≈ 2,430,972 次(约 2.4N)

理想等比级数上界:S = 10 × (1.529 − 1) / (1.5 − 1) ≈ 2.56 × 106(实际逐项求和因向下取整略小)
也就是说,你插入了 100 万个元素,但底层实际做了约 243 万次元素拷贝。
均摊分析(Amortized Analysis)

虽然单次扩容是 O(n) 的昂贵操作,但均摊到每次 add() 上仍然是 O(1)。证明思路:第 k 次扩容拷贝了 Ck 个元素,之后还能再插入 Ck/2 个元素才会再次扩容。把这 Ck 次拷贝分摊到后续的 Ck/2 次 add 上,每次 add 分摊到约 2 个额外拷贝(1.5 倍扩容)——常数级别,与 2 倍扩容的均摊 1 个拷贝同阶。这就是为什么 ArrayList 的 add 能自称"O(1) 均摊"。

核心要点
  • 扩容的代价不在某一次拷贝有多慢,而在扩容次数 × 单次拷贝量,两者都会随数据量爆炸。
  • 预分配容量可以把扩容次数从 29 次降到 0 次,总拷贝量从 243 万降到 0。
  • arraycopy 是浅拷贝:拷贝引用、共享对象,所以扩容本身不复制业务对象,开销可控。
  • 批量场景(如 addAll)只触发一次扩容、一次拷贝,比逐条 add 高效得多(见第 6 站)。
第 6 站

ensureCapacity 与预分配:把扩容次数从 29 降到 0

有两种方式避免扩容灾难:构造时指定容量,或对已创建的列表调用 ensureCapacity

PreAllocate.java · 两种预分配方式
// 方式 1:构造时指定初始容量(最推荐)
List<Order> orders = new ArrayList<>(1_000_000);

// 方式 2:对已创建的 ArrayList 调用 ensureCapacity
ArrayList<Order> orders2 = new ArrayList<>();
orders2.ensureCapacity(1_000_000);

// ensureCapacity 源码(JDK 8):不是恰好扩到 minCapacity,而是走 grow() 的 1.5 倍逻辑
public void ensureCapacity(int minCapacity) {
    if (minCapacity > elementData.length) {
        ensureExplicitCapacity(minCapacity);  // modCount++ 后调用 grow(minCapacity)
    }
}

// 若 minCapacity 远大于 1.5 倍结果,grow() 里的兜底分支会直接用 minCapacity

一个容易忽略的细节:ensureCapacity(1_000_000) 之后容量不一定恰好是 1,000,000——如果当前容量是 900,000,1.5 倍后是 1,350,000,就会直接采用 1,350,000。所以 ensureCapacity 的语义是"保证至少能装下这么多",而不是"精确扩容到这么多"。

批量添加:addAll 的"一次扩容"红利

AddAll.java · 批量 vs 逐条
// addAll 内部:先算好总容量,只扩容一次、只拷贝一次
public boolean addAll(Collection<? extends E> c) {
    Object[] a = c.toArray();
    int numNew = a.length;
    ensureCapacityInternal(size + numNew);   // ★ 一次性把容量给够
    System.arraycopy(a, 0, elementData, size, numNew);
    size += numNew;
    return numNew != 0;
}

// 对比:100 万条逐条 add → 29 次扩容、243 万次拷贝
// 100 万条一次 addAll → 0 次扩容、100 万次拷贝(只 copy 一次)

这也是为什么"从库里查出 100 万行再拼进 List"这类场景,先 collect 成数组/集合再 addAll,或者直接用 new ArrayList<>(collection) 复制构造,性能远超逐条 add。

实测数据(量级仅供参考,硬件/JDK 不同结果不同)

JMH 基准测试结果(插入 100 万个 Integer,JDK 17,单线程,预热 5 轮):

场景平均耗时GC 次数峰值内存
new ArrayList<>()(默认容量 10)28.3 ms18 次 Young GC~16 MB
new ArrayList<>(1_000_000)7.1 ms0 次 GC~8 MB
提升倍数约 4x 速度提升,内存减半,零 GC
最佳实践

当你能预估数据量时,永远使用带容量的构造/预分配。常见场景:

  • 从数据库查出 N 条记录转 List → new ArrayList<>(N),或先 List<T> list = new ArrayList<>(result.size())
  • 复制另一个集合 → new ArrayList<>(sourceCollection.size())
  • 分页查询,已知 pageSize → new ArrayList<>(pageSize)
  • 预估不了精确值?给个数量级也行——ensureCapacity 是"保底容量",多给一点没有副作用(除非大到 OOM)
后端类比

预分配容量就像数据库连接池的 initialSize:连接池建好后按 initialSize 一次性建好连接,而不是每次请求都新建/销毁。ArrayList 的扩容 = 每次请求都重建连接池,预分配 = 一次到位。凡是"容量可预估、生命周期内只增不改"的数据结构,都应该预分配。

第 7 站

只增不减:remove 之后容量不会变小,何时该 drain

扩容是"只增不减"的——remove() 只会减小 sizeelementData.length 纹丝不动。看 JDK 8 的 remove(int)

ArrayList.java · JDK 8 remove(int)
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);
        // 删除中间元素:把后面的元素整体前移一格(又是 O(n))

    elementData[--size] = null;  // 尾部槽位置 null:帮 GC 回收(JDK 8 修复)
    return oldValue;
}

注意:删除后容量 elementData.length 不会收缩。如果你删掉 90 万个元素,数组还是那么大,只是尾巴上多了 90 万个 null 槽位。

一个被 JDK 8 修复的经典内存泄漏

JDK 8 之前(比如 JDK 6/7),remove()clear()不置 null:数组里仍强引用着被删除的对象。如果这个 ArrayList 长期存活(比如缓存、静态字段、长生命周期对象),而被删除的元素在别处已无引用,它们就永远无法被 GC 回收——Effective Java 第 2 版 Item 7 讲的就是这个"过时引用"(obsolete reference)泄漏。JDK 8 起 remove() 把尾部槽位置 null,clear() 也改为逐槽置 null:

ArrayList.java · JDK 8 clear()
public void clear() {
    modCount++;
    // clear to let GC do its work
    for (int i = 0; i < size; i++)
        elementData[i] = null;   // 逐槽置 null,size 清零
    size = 0;
}

drain 时机:什么时候主动回收容量

"drain"在这里指把列表的容量/引用排掉:删光元素 ≠ 释放容量,容量回收需要显式动作。JDK 8 提供了 trimToSize()

ArrayList.java · trimToSize() 源码
public void trimToSize() {
    modCount++;
    if (size < elementData.length) {
        elementData = (size == 0)
            ? EMPTY_ELEMENTDATA                    // 空列表:回到共享哨兵
            : Arrays.copyOf(elementData, size);  // 缩到恰好 size
    }
}
场景推荐动作原因
大列表删掉大部分元素后仍长期驻留(缓存/配置/字典)trimToSize()(一次性操作)容量只增不减,不缩就会一直占着大数组
批量清空列表clear()JDK 8 起自动逐槽置 null,帮助 GC
列表用完后不再需要直接丢掉引用(list = null 或缩短作用域)整数组随对象一起被回收,比 trimToSize 更彻底
循环中删除元素iterator.remove() / removeIf()避免索引错位与 CME(见第 11 站)
作为缓存容器的列表考虑弱引用 / 定期 trimToSize强引用数组会钉住所有元素
后端类比:容量与使用量是两回事

这就像线程池:corePoolSize 是容量,当前线程数是使用量,任务跑完了线程并不会全部销毁;也像 BlockingQueue.drainTo()——"取走元素"不等于"释放底层存储"。ArrayList 的 trimToSize() 就是你的手动"回收动作",和连接池的 idle 回收一个道理:别指望系统自动帮你,驻留内存的结构必须显式治理。

核心要点
  • 扩容只增不减:remove() 只动 size,不动 elementData.length
  • JDK 8 之前 remove/clear 不置 null 是内存泄漏源头,JDK 8 已修复(面试答"置 null 帮 GC")。
  • trimToSize() 本身是一次 Arrays.copyOf(O(n)),只适合"一次性、低频"调用,别放进高频路径。
  • 判断"该不该 drain"的标准:列表是否长期存活 + 容量远大于实际使用量,两者同时成立才值得缩。
第 8 站

性能全景:各种操作的实际复杂度

面试中常被问到"ArrayList 和 LinkedList 哪个快",但真正的答案远比"ArrayList 随机访问快、LinkedList 插入快"复杂。来看实际数据(量级仅供参考):

ArrayList vs LinkedList 各操作耗时对比(10 万元素,量级仅供参考) 耗时(ns,越低越好) get(50000) add(尾部) add(中间) 遍历迭代 2 ns 3 ns (均摊) ~80,000 ns (搬移后半数组) ~1.2 ms ~105,000 ns (遍历半个链表) 3 ns 2 ns (但定位要 O(n)!) ~6.3 ms (缓存不友好) ArrayList LinkedList
图 4实测对比:ArrayList 在遍历和随机访问上碾压 LinkedList。LinkedList 的"中间插入 O(1)"需要先 O(n) 找到位置,实际优势几乎不存在。

各操作复杂度速查:

操作ArrayListLinkedList实际说明
get(i)O(1)O(n)ArrayList 直接数组索引;LinkedList 从头/尾遍历
add(e) 尾部O(1) 均摊O(1)两者都是常数级,ArrayList 扩容时 O(n) 但均摊后 O(1)
add(i, e) 中间O(n)O(n)LinkedList 定位 O(n) + 插入 O(1);ArrayList 搬移 O(n)
remove(i)O(n)O(n)同上,LinkedList 也需要先遍历到位置
contains(o)O(n)O(n)两者都需要线性扫描
for-each 遍历O(n)O(n)ArrayList 快 5-10 倍,CPU 缓存命中率差异巨大
内存占用数组 + 最多 1/3 稀疏槽每元素一个 Node(额外 ~24-40 B)LinkedList 每个元素还要多存两个引用
CPU 缓存:ArrayList 的隐藏王牌

现代 CPU 读取内存时会预取相邻的缓存行(通常 64 字节)。ArrayList 的 Object[] 在堆上是连续内存,CPU 预取命中率极高。LinkedList 的每个 Node 分散在堆上,每次 node.next 可能触发一次 cache miss(L1 缓存命中约 1ns,主存访问约 100ns)。这就是为什么同样 O(n) 的遍历,ArrayList 比 LinkedList 快 5-10 倍的底层原因。

第 9 站

LinkedList 源码解剖:为什么"插入快"是个谎言

先看 LinkedList 的底层——它压根不是"List 的链表版"这么简单,而是一个双向链表

LinkedList.java · JDK 8 核心结构
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;
        }
    }
}
LinkedList 内部:双向链表,每个元素一个 Node 对象 prev E1 next prev E2 next prev E3 next first last null prev prev null null 每个 Node 额外占用约 24~40 字节(对象头 + 两个引用),缓存不友好 node(index) 从头/尾双向遍历定位,定位本身就要 O(n/2)
图 5LinkedList 的每个元素都是一个独立的 Node 对象,分散在堆上;first/last 只持有首尾引用。

再看"中间插入 O(1)"是怎么来的——先看 add(int index, E)

LinkedList.java · 定位 + 插入
public void add(int index, E element) {
    checkPositionIndex(index);
    if (index == size)
        linkLast(element);              // 尾部:O(1)
    else
        linkBefore(element, node(index));  // 中间:先 O(n) 定位,再 O(1) 挂接
}

// 双向二分定位:从头还是从尾走,取决于 index 离哪头近
Node<E> node(int index) {
    if (index < (size >> 1)) {          // index < size/2:从头走
        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 的"O(1) 插入"只在持有 Node 引用时成立(比如 ListIterator 遍历到某个位置后插入,或队列场景的 addFirst/addLast)。一旦你走的是 add(i, e) / remove(i) 这种带索引的 API,定位就要 O(n/2),整体还是 O(n)——和 ArrayList 的搬移成本一个量级,却多了缓存不友好和内存开销两个劣势。

维度ArrayListLinkedListArrayDeque
底层结构Object[] 连续数组Node 双向链表Object[] 环形数组
get(i)O(1)O(n)无索引 API
add 尾部O(1) 均摊O(1)O(1) 均摊
addFirst 头部O(n)(整体搬移)O(1)O(1)
缓存友好
每元素额外内存几乎为 0~24-40 B(Node 对象)几乎为 0
推荐场景默认选择几乎不用队列/双端队列
一句话点破

需要"头部插入/双端操作"?用 ArrayDeque,它既是环形数组、缓存友好,又保证 O(1) 双端操作——把 LinkedList 最后一块遮羞布也扯掉了。Joshua Bloch(集合框架设计者)的原话是:"LinkedList 几乎不应该被使用。"

第 10 站

for / for-each / stream:三种遍历的真实差异

同样是"遍历一遍",三种写法在底层是三条完全不同的代码路径:

Traverse.java · 三种写法
// ① 索引 for:直接数组下标访问,零迭代器对象
for (int i = 0; i < list.size(); i++) {
    String s = list.get(i);      // 编译后就是 elementData[i](RandomAccess)
}

// ② for-each:编译成 Iterator.hasNext()/next()
for (String s : list) {        // 创建 ArrayList.Itr,每次 next 都校验 modCount
}

// ③ stream().forEach:走 Spliterator + 方法句柄
list.stream().forEach(s -> { ... });  // ArrayListSpliterator 按索引区间批量消费

// ④ 容易被忽略的第 4 种:list.forEach(Iterable 默认方法被 ArrayList 重写)
list.forEach(s -> { ... });           // 索引循环 + lambda,无迭代器对象

方法级源码:三种遍历各自的"检查机制"

ArrayList.java · Itr(for-each 的幕后)与 forEach 重写
// for-each 编译产物用的迭代器:每次 next() 都做 fail-fast 校验
public E next() {
    checkForComodification();        // ① 先校验 modCount == expectedModCount
    int i = cursor;
    if (i >= size) throw new NoSuchElementException();
    Object[] elementData = ArrayList.this.elementData;
    if (i >= elementData.length) throw new ConcurrentModificationException();
    cursor = i + 1;
    return (E) elementData[lastRet = i];
}

// list.forEach(JDK 8 重写):索引循环,每次迭代条件里都带 modCount 校验
public void forEach(Consumer<? super E> action) {
    final int expectedModCount = modCount;
    final int size = this.size;
    for (int i = 0; modCount == expectedModCount && i < size; i++) {
        action.accept(elementData[i]);
    }
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

// stream().forEach 的 Spliterator:先记录 expectedModCount,整段遍历后统一校验
// (ArrayListSpliterator.forEachRemaining:循环里只做索引推进,结束后才检查 modCount)

三种遍历的 fail-fast 暴露时机完全不同:for-each(迭代器)每次 next 都校验,并发修改几乎立刻抛 ConcurrentModificationExceptionlist.forEach 每次循环条件校验,暴露也很早;stream().forEachforEachRemaining整段遍历完才校验,异常会"延迟暴露"(tryAdvance 单步路径则会逐元素校验)。

相对性能与能力对比

遍历 100 万 Integer 的相对耗时(索引 for = 1.0) 1.0 索引 for ~1.2 for-each ~1.3 list.forEach ~1.8 stream().forEach ~0.45 parallel(4核) 量级仅供参考:小数据量下 parallel 反而更慢;数据量 ≥ 数十万且多核才可能胜出
图 6四种遍历相对耗时(以索引 for 为基准)。顺序遍历差距在 2 倍以内,parallelStream 只在"数据量大 + 多核"时划算。
维度索引 forfor-eachlist.forEachstream().forEach
底层机制elementData[i]ArrayList.ItrArrayList 重写的索引循环ArrayListSpliterator
迭代器对象有(每遍历一次创建一个)有(Spliterator)
modCount 校验不校验(快,但并发修改时可能读到脏数据)每次 next 立即校验每次循环条件校验整段遍历后统一校验(延迟暴露)
break / return 提前退出✘(lambda 内 return 只退出本次回调)
遍历中删除元素需手动 i--iterator.remove() 可,直接 list.remove 抛 CME不推荐不推荐
局部变量捕获随意修改随意修改lambda 要求 effectively final同左
并行能力✔(parallelStream)
生产建议

纯遍历 + 能确认无并发修改 → 索引 for(最快且能 break); 需要过滤/转换/聚合 → stream 的 map/filter/collect(表达力优先,性能差距在 2 倍内); 并发修改遍历 → 别用裸 for,改用 CopyOnWriteArrayList 或迭代器 remove; parallelStream 只在 数据量 ≥ 数十万多核 时用,且要注意 forEach 不保证相遇顺序(要保序用 forEachOrdered)。

第 11 站

生产环境最佳实践与常见陷阱

写了十年 Java,以下是我总结的 ArrayList 使用守则:

原则一:默认用 ArrayList。除非你有明确的理由(比如需要一个双端队列),否则一律选 ArrayList。Joshua Bloch(《Effective Java》作者、集合框架设计者)本人也说过:"LinkedList 几乎不应该被使用。"

原则二:已知数据量就预分配。

BestPractice.java · 预分配模式
// ✓ 从数据库查询结果转换
List<UserVO> voList = new ArrayList<>(entityList.size());
for (UserEntity e : entityList) {
    voList.add(toVO(e));
}

// ⚠ 注意:Collectors.toList() 内部只是 new ArrayList<>(),并没有预分配!
List<String> names = users.stream()
    .map(User::getName)
    .collect(Collectors.toList());   // 数据量大时仍会经历多次扩容

// ✓ 想要预分配,用 toCollection 指定容量
List<String> names2 = users.stream()
    .map(User::getName)
    .collect(Collectors.toCollection(() -> new ArrayList<>(expectedSize)));

原则三:警惕 subList() 的视图陷阱。它返回的是视图而非副本,且持有父列表的 modCount——父列表任何结构修改都会让子列表立刻失效:

SubListTrap.java · subList 不是深拷贝!
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "d"));
List<String> sub = list.subList(1, 3);  // [b, c] — 这是视图,不是副本

sub.set(0, "B");
System.out.println(list);  // [a, B, c, d] ← 原列表也被修改了!

list.add("e");
sub.get(0);  // ✗ 抛出 ConcurrentModificationException
// 原因:subList 持有原列表的 modCount,原列表结构变化导致不一致

// ✓ 安全做法:需要独立副本就 new 一份
List<String> copy = new ArrayList<>(list.subList(1, 3));

顺带一提:subList 视图还持有父列表的引用,如果你对一个超大的列表做 subList 后长期保留子列表,父列表的整个 elementData 都会被钉住无法回收——用完即弃,或复制出来。

原则四:分清 Arrays.asList() 和 List.of()。

ListFactory.java · 三种工厂方法对比
// Arrays.asList() → 返回 Arrays$ArrayList(固定大小,不能 add/remove)
List<String> fixed = Arrays.asList("a", "b");
fixed.add("c");   // ✗ UnsupportedOperationException
fixed.set(0, "A"); // ✓ 可以修改元素(底层就是原数组的包装)

// List.of() (Java 9+) → 真正的不可变列表
List<String> immutable = List.of("a", "b");
immutable.add("c");   // ✗ UnsupportedOperationException
immutable.set(0, "A"); // ✗ UnsupportedOperationException
// List.of() 还不允许 null 元素!

// new ArrayList<>(Arrays.asList(...)) → 可变副本
List<String> mutable = new ArrayList<>(Arrays.asList("a", "b"));
mutable.add("c");   // ✓ 完全自由

原则五:trimToSize() 释放多余内存。

TrimToSize.java · 用完即收
ArrayList<String> buffer = new ArrayList<>(10000);
// ... 添加数据,最终只用了 200 个槽位
buffer.trimToSize();  // capacity 从 10000 缩为 200,释放多余内存

// 典型场景:长期存活的大列表,填充后不再修改

原则六:不要在循环里正序 remove。

RemoveInLoop.java · 删除元素的三板斧
// ✗ 正序按索引删:删除后元素前移,i 自增会跳过下一个 → 漏删/越界
for (int i = 0; i < list.size(); i++) {
    if (matches(list.get(i))) list.remove(i);
}

// ✓ 方案 1:倒序删(元素前移不影响已扫描部分)
for (int i = list.size() - 1; i >= 0; i--) {
    if (matches(list.get(i))) list.remove(i);
}

// ✓ 方案 2:removeIf(JDK 8+,内部用迭代器,最优雅)
list.removeIf(this::matches);

// ✓ 方案 3:迭代器 remove(需要遍历过程中做别的判断时)
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (matches(it.next())) it.remove();
}

原则七:ArrayList 不是线程安全的。多线程写同一个 ArrayList 轻则丢数据,重则数组越界崩溃。选型顺序:Collections.synchronizedList(粗锁,简单场景)→ CopyOnWriteArrayList(读多写少)→ 并发写场景直接用 ConcurrentLinkedQueue 或自行加锁。别指望 Vector——全方法加锁的"上古遗物",性能差且过时。

原则八:预分配也要防呆。new ArrayList<>(2_000_000_000) 会直接 OOM——别拿估算值乱乘。容量估算宁准勿大,拿不准就给个数量级,最多多占点内存,不会错。

一个真实的生产坑

曾经有同事把"查询结果"直接塞进静态 List 做缓存,一个月后 Full GC 频繁。原因正是第 7 站的"过时引用":缓存列表用 removeAll 清数据,但旧版代码里数组槽位还强引用着业务对象。修法就一行——clear()trimToSize(),或者直接换 ConcurrentHashMap 做缓存。排查内存问题时,先问一句:这个 List 是不是长期存活、又删过元素?

第 12 站

快速参考卡片与高频追问

ArrayList 扩容机制速记

  • 底层结构:Object[] elementDataDEFAULT_CAPACITY = 10,无参构造指向 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 哨兵、延迟初始化
  • 首次 add:ensureCapacityInternal → calculateCapacity → ensureExplicitCapacity,容量放大到 max(10, minCapacity) = 10
  • 扩容公式:newCapacity = oldCapacity + (oldCapacity >> 1),即 1.5 倍增长;不足时用 minCapacity 兜底
  • 拷贝开销:每次扩容调用 System.arraycopy,O(n);100 万元素累计约 243 万次拷贝(≈2.4N),预分配可降为 0
  • 边界保护:MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8hugeCapacity 兜底溢出,负数容量构造抛 IllegalArgumentException
  • 容量只增不减:remove() 不缩容;JDK 8 起尾部槽位置 null 帮 GC;要回收容量用 trimToSize()
  • 1.5x vs 2x:1.5 倍空间浪费更少(≤33% vs ≤50%),且支持内存复用;均摊复杂度同为 O(1)
场景推荐做法
日常 List 使用new ArrayList<>(),90% 场景首选
已知数据量new ArrayList<>(size),避免扩容
需要队列/双端队列ArrayDeque,不要用 LinkedList
需要不可变列表Java 9+ 用 List.of(),Java 8 用 Collections.unmodifiableList()
需要 subList 独立副本new ArrayList<>(list.subList(from, to))
频繁头部插入ArrayDeque.addFirst()
长期列表内存优化数据填充完毕后调用 trimToSize()

高频追问速答

Interview.java · 追问拆解
// Q1: new ArrayList<>(0) 和 new ArrayList<>() 有区别吗?
// A: 有。前者指向 EMPTY_ELEMENTDATA,首次 add 只扩到 1;
//    后者指向 DEFAULTCAPACITY_EMPTY_ELEMENTDATA,首次 add 扩到 10。

// Q2: ArrayList 最多能装多少元素?
// A: 理论上 Integer.MAX_VALUE,但 MAX_ARRAY_SIZE = MAX_VALUE - 8,
//    实际受堆大小限制,接近上限必然 OOM。

// Q3: remove() 之后内存会变小吗?
// A: 不会,elementData.length 不变;JDK 8 起尾部槽位置 null 帮助 GC。
//    要释放容量必须显式 trimToSize()。

// Q4: for-each 里 list.remove(x) 会怎样?
// A: 抛 ConcurrentModificationException(modCount 不匹配);
//    iterator.remove() 可以,它会同步 expectedModCount。

// Q5: stream().forEach 和 list.forEach 有什么区别?
// A: 前者走 Spliterator(可并行、fail-fast 延迟暴露、不保序 in parallel),
//    后者是 ArrayList 重写的索引循环(每次迭代校验 modCount)。
面试应答模板

Q: ArrayList 的扩容机制?

"ArrayList 底层是 Object 数组,无参构造默认容量 10 但延迟分配(JDK 8 指向 DEFAULTCAPACITY_EMPTY_ELEMENTDATA 哨兵,首次 add 时经 calculateCapacity 放大到 10)。容量不足时 add 会走 ensureCapacityInternal → ensureExplicitCapacity → grow(),按 1.5 倍扩容(右移一位实现除以 2),然后通过 Arrays.copyOf 调用 System.arraycopy 完成数据拷贝。1.5 倍相比 2 倍的优势在于空间浪费更少(≤33% vs ≤50%),且有机会复用之前释放的内存块。均摊分析下,每次 add 的时间复杂度仍为 O(1)。"

Q: 怎么优化 ArrayList 的性能?

"核心就是预分配。如果你知道要存多少数据,直接用 new ArrayList(size) 指定初始容量,把扩容次数降到零;批量添加用 addAll 只扩容一次。在百万级数据量下,这个简单改动能带来 3-5 倍的性能提升,同时避免大量短命数组引发的 Young GC。另外注意遍历选型:纯遍历用索引 for,需要过滤转换用 stream,并发修改用迭代器 remove 或 CopyOnWriteArrayList。"

总结

这一篇你掌握了什么

核心知识点回顾

  • 数据结构:ArrayList 底层是 Object[] elementData,size 是逻辑长度、capacity 是数组长度;无参构造延迟初始化,首次 add 扩容到 10。
  • 调用链:add → ensureCapacityInternal → calculateCapacity → ensureExplicitCapacity → grow,每层职责单一,modCount 贯穿始终。
  • 扩容公式:oldCapacity + (oldCapacity >> 1) 的 1.5 倍策略,配合 minCapacity 兜底与 hugeCapacity 溢出保护。
  • 真正代价:System.arraycopy 的 O(n) 拷贝(浅拷贝 + 写屏障),100 万元素累计约 243 万次拷贝;预分配 + addAll 批量添加可把拷贝降到 1 次。
  • drain 时机:remove/clear 不缩容,JDK 8 起置 null 帮 GC;长期驻留的大列表用 trimToSize() 显式回收容量。
  • 遍历差异:索引 for(零对象、不校验)、for-each(迭代器、逐次校验)、list.forEach(索引循环 + 每次校验)、stream(Spliterator、批量校验、可并行);各有能力边界与性能取舍。
  • 对比结论:LinkedList 的"O(1) 插入"只在持有 Node 时成立,普通 API 是 O(n) 且缓存不友好;队列场景用 ArrayDeque。
一句话串讲

扩容的本质是"用拷贝换空间":1.5 倍 + 预分配 + 批量添加 + 及时 drain,四板斧用对,ArrayList 就能在 100 万级数据下保持"写入 O(1) 均摊、读取 O(1)"的理想表现——这也是面试官想听到的"能落地"的答案。

Comments · 评论