Lesson 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 万条数据面前会触发19 次扩容。每一次扩容都意味着:申请新数组 → 拷贝全部旧元素 → 丢弃旧数组。累计拷贝的元素总数超过 300 万,还制造了大量短命对象等着 GC 回收。

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

    // 默认初始容量
    private static final int DEFAULT_CAPACITY = 10;

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

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

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

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

当你写 new ArrayList<>() 时,JDK 8 实际上把 elementData 指向一个空数组EMPTY_ELEMENTDATA),真正的容量 10 要等到第一次 add() 时才分配——这是典型的延迟初始化策略,避免创建大量空列表时浪费内存。

ArrayList.java · 延迟初始化策略
// 无参构造:指向空数组哨兵,并非直接分配 10 个槽位
public ArrayList() {
    this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

// 带初始容量构造:立即分配指定大小的数组
public ArrayList(int initialCapacity) {
    this.elementData = new Object[initialCapacity];
}
第 3 站

1.5 倍扩容公式:grow() 源码解析

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

ArrayList.java · JDK 8 grow() 方法
private void grow(int minCapacity) {
    int oldCapacity = elementData.length;

    // ★ 核心公式:右移 1 位 = 除以 2
    // newCapacity = oldCapacity + oldCapacity / 2 = oldCapacity * 1.5
    int newCapacity = oldCapacity + (oldCapacity >> 1);

    // 如果 1.5 倍还不够,直接用请求的最小容量
    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);
}
扩容公式:newCapacity = oldCapacity + (oldCapacity >> 1) = oldCapacity × 1.5
位运算 >> 1 等价于整除 2,但比除法更快(省去符号位判断)

从默认容量 10 出发,每一次扩容的容量变化如下:

ArrayList 扩容容量增长轨迹(从 10 到 1,001,158) 第 N 次扩容 容量 10 0 15 1 22 2 33 3 49 4 73 5 109 6 163 7 244 8 366 9 549 10 823 11 1234 12 2776 13 4164 14 6246 15 134652 17 302967 18 1,001,158 19 1,000,000 目标
图 1从默认容量 10 开始,每次扩容 1.5 倍,需要 19 次扩容才能容纳 100 万元素。前 8 次看起来增长缓慢,后期才指数起飞。
为什么 ArrayList 选 1.5 倍而不是 2 倍(像 HashMap 那样翻倍)?

1.5 倍的优势:内存复用。假设当前容量为 C,扩容到 1.5C。再下一次扩容到 2.25C,此时 2.25C > C + 1.5C = 2.5C?不对——关键在于:经过几次扩容后,新数组的大小可能刚好能够容纳之前释放的所有旧数组的累计空间,从而复用被 GC 回收的内存块。2 倍扩容则永远无法复用(新数组 = 旧数组 + 旧数组,总是比之前所有旧数组加起来还大)。

均摊分析:无论 1.5 倍还是 2 倍,每次 add 的均摊时间都是 O(1)。2 倍扩容浪费约 50% 的空间(最差情况数组只用了 51%),1.5 倍浪费约 33%。在"空间浪费"和"扩容频率"之间,1.5 是一个更偏向节省内存的折中。
第 4 站

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                     // 要拷贝的元素个数
);

arraycopy 是 O(n) 操作——拷贝多少个元素,就做多少次内存写入。在 HotSpot JVM 中,它会被优化为 CPU 的 SIMD 指令(如 SSE/AVX),对基本类型数组尤其快。但对于引用数组(Object[]),JVM 必须逐一更新写屏障(Write Barrier)来维护 GC 的可达性信息,所以比基本类型慢一些。

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

总拷贝量 = 10 + 15 + 22 + 33 + 49 + 73 + ... + 667,438 ≈ 2,000,000 次

等比数列求和:S = 10 × (1.519 - 1) / (1.5 - 1) ≈ 2 × 106
也就是说,你插入了 100 万个元素,但底层实际做了 200 万次元素拷贝。
均摊分析(Amortized Analysis)

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

关键洞察:扩容的代价不在于某一次拷贝有多慢,而在于扩容次数。预分配容量可以把扩容次数从 19 降到 0。

第 5 站

ensureCapacity 与预分配:一行代码消灭 19 次扩容

有两种方式避免扩容灾难:

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 源码逻辑:
public void ensureCapacity(int minCapacity) {
    if (minCapacity > elementData.length) {
        // 不是恰好等于 minCapacity,而是走 grow() 的 1.5 倍逻辑
        grow(minCapacity);
    }
}

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)
  • 复制另一个集合 → new ArrayList<>(sourceCollection.size())
  • 分页查询,已知 pageSize → new ArrayList<>(pageSize)
第 6 站

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

面试中常被问到"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
图 2实测对比: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 缓存命中率差异巨大
CPU 缓存:ArrayList 的隐藏王牌

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

第 7 站

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

写了十年 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));
}

// ✓ 配合 Stream 使用时,先 collect 再处理
List<String> names = users.stream()
    .map(User::getName)
    .collect(Collectors.toList());  // 内部已预分配

原则三:警惕 subList() 的视图陷阱。

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));

原则四:分清 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,释放多余内存

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

快速参考卡片

ArrayList 扩容机制速记

  • 底层结构:Object[] elementDataDEFAULT_CAPACITY = 10,延迟初始化
  • 扩容公式:newCapacity = oldCapacity + (oldCapacity >> 1),即 1.5 倍增长
  • 拷贝开销:每次扩容调用 System.arraycopy,O(n) 操作,累计拷贝量约为 2N
  • 预分配:new ArrayList<>(expectedSize),将扩容次数降为 0
  • 1.5x vs 2x:1.5 倍空间浪费更少(~33% vs ~50%),且支持内存复用
场景推荐做法
日常 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()
面试应答模板

Q: ArrayList 的扩容机制?

"ArrayList 底层是 Object 数组,默认容量 10,延迟到首次 add 时分配。容量不足时调用 grow() 方法,按 1.5 倍扩容(右移一位实现除以 2),然后通过 Arrays.copyOf 调用 System.arraycopy 完成数据拷贝。1.5 倍相比 2 倍的优势在于空间浪费更少,且有机会复用之前释放的内存块。均摊分析下,每次 add 的时间复杂度仍为 O(1)。"

Q: 怎么优化 ArrayList 的性能?

"核心就是预分配。如果你知道要存多少数据,直接用 new ArrayList(size) 指定初始容量,把扩容次数降到零。在百万级数据量下,这个简单改动能带来 3-5 倍的性能提升,同时避免 Young GC。"