Lesson 07 · 集合框架源码
ArrayList 扩容机制与性能调优
100 万条数据导入引发的性能灾难
上周,同事找到我:"线上批量导入接口突然变慢了,以前 2 秒搞定,现在要 8 秒。" 排查后发现罪魁祸首只有一行代码:
// 从 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 的扩容机制。
ArrayList 内部结构:三个关键字段
打开 JDK 8 的 ArrayList 源码,整个类建立在三个核心字段之上:
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;
}
很多人混淆 size 和 capacity,面试时被追问就露馅:
| 概念 | 含义 | 获取方式 | 示例 |
|---|---|---|---|
| size | 已存储的元素个数 | list.size() | 添加了 5 个元素 → size = 5 |
| capacity | 底层数组的实际长度 | elementData.length | 默认构造 → capacity = 10 |
当你写 new ArrayList<>() 时,JDK 8 实际上把 elementData 指向一个空数组(EMPTY_ELEMENTDATA),真正的容量 10 要等到第一次 add() 时才分配——这是典型的延迟初始化策略,避免创建大量空列表时浪费内存。
// 无参构造:指向空数组哨兵,并非直接分配 10 个槽位
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
// 带初始容量构造:立即分配指定大小的数组
public ArrayList(int initialCapacity) {
this.elementData = new Object[initialCapacity];
}
1.5 倍扩容公式:grow() 源码解析
当 add() 发现 size >= elementData.length,就调用 grow() 扩容。JDK 8 的实现堪称经典:
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);
}
位运算
>> 1 等价于整除 2,但比除法更快(省去符号位判断)
从默认容量 10 出发,每一次扩容的容量变化如下:
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 是一个更偏向节省内存的折中。
System.arraycopy:扩容的真正代价
扩容的最后一步 Arrays.copyOf() 内部调用的就是 System.arraycopy——一个 native 方法,直接操作 JVM 的内存:
// 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 个元素,总共拷贝了多少次?
等比数列求和:S = 10 × (1.519 - 1) / (1.5 - 1) ≈ 2 × 106
也就是说,你插入了 100 万个元素,但底层实际做了 200 万次元素拷贝。
虽然单次扩容是 O(n) 的昂贵操作,但均摊到每次 add() 上仍然是 O(1)。证明思路:第 k 次扩容拷贝了 Ck 个元素,之后还能再插入 Ck/2 个元素才会再次扩容。把这 Ck 次拷贝分摊到后续的 Ck/2 次 add 上,每次 add 分摊到 2 个额外拷贝——常数级别。
关键洞察:扩容的代价不在于某一次拷贝有多慢,而在于扩容次数。预分配容量可以把扩容次数从 19 降到 0。
ensureCapacity 与预分配:一行代码消灭 19 次扩容
有两种方式避免扩容灾难:
// 方式 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 ms | 18 次 Young GC | ~16 MB |
new ArrayList<>(1_000_000) | 7.1 ms | 0 次 GC | ~8 MB |
| 提升倍数 | 4x 速度提升,内存减半,零 GC | ||
当你能预估数据量时,永远使用带容量的构造函数。常见场景:
- 从数据库查出 N 条记录转 List →
new ArrayList<>(N) - 复制另一个集合 →
new ArrayList<>(sourceCollection.size()) - 分页查询,已知 pageSize →
new ArrayList<>(pageSize)
性能全景:各种操作的实际复杂度
面试中常被问到"ArrayList 和 LinkedList 哪个快",但真正的答案远比"ArrayList 随机访问快、LinkedList 插入快"复杂。来看实际数据:
各操作复杂度速查:
| 操作 | ArrayList | LinkedList | 实际说明 |
|---|---|---|---|
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 读取内存时会预取相邻的缓存行(通常 64 字节)。ArrayList 的 Object[] 在堆上是连续内存,CPU 预取命中率极高。LinkedList 的每个 Node 分散在堆上,每次 node.next 可能触发一次 cache miss(L1 缓存命中约 1ns,主存访问约 100ns)。这就是为什么同样 O(n) 的遍历,ArrayList 比 LinkedList 快 5-10 倍的底层原因。
生产环境最佳实践与常见陷阱
写了十年 Java,以下是我总结的 ArrayList 使用守则:
原则一:默认用 ArrayList。除非你有明确的理由(比如需要一个双端队列),否则一律选 ArrayList。Joshua Bloch(《Effective Java》作者、集合框架设计者)本人也说过:"LinkedList 几乎不应该被使用。"
原则二:已知数据量就预分配。
// ✓ 从数据库查询结果转换
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() 的视图陷阱。
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()。
// 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() 释放多余内存。
ArrayList<String> buffer = new ArrayList<>(10000);
// ... 添加数据,最终只用了 200 个槽位
buffer.trimToSize(); // capacity 从 10000 缩为 200,释放多余内存
// 典型场景:长期存活的大列表,填充后不再修改
快速参考卡片
ArrayList 扩容机制速记
- 底层结构:
Object[] elementData,DEFAULT_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。"