JAVA · Vol.I · DAY 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 万条数据面前会触发29 次扩容。每一次扩容都意味着:申请新数组 → 拷贝全部旧元素 → 丢弃旧数组。累计拷贝的元素约 243 万次(约为插入量的 2.4 倍),还制造了大量短命对象等着 GC 回收。
把代码改成 new ArrayList<>(1_000_000),问题就消失了。但要做到"写对",你得理解 ArrayList 内部到底发生了什么。这一站,我们从源码级别拆解 ArrayList 的扩容机制。
带着三个问题读完全文:① 扩容到底执行了哪些方法、谁先谁后?② 为什么 29 次扩容比预分配慢那么多——瓶颈在拷贝还是在 GC?③ 面试官追问
new ArrayList<>(0) 和 new ArrayList<>() 的区别时,你能答出来吗?ArrayList 内部结构:三个关键字段
打开 JDK 8 的 ArrayList 源码,整个类建立在三个核心字段之上:
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;
}
很多人混淆 size 和 capacity,面试时被追问就露馅:
| 概念 | 含义 | 获取方式 | 示例 |
|---|---|---|---|
| size | 已存储的元素个数(逻辑长度) | list.size() | 添加了 5 个元素 → size = 5 |
| capacity | 底层数组的实际长度(物理容量) | elementData.length | 默认构造 → capacity = 10 |
两个空数组哨兵:面试官最爱挖的细节
JDK 8 里有两个长得一模一样的空数组,用途完全不同:
| 字段 | 由谁赋值 | 首次 add 时的行为 |
|---|---|---|
EMPTY_ELEMENTDATA | new ArrayList<>(0) | 按 minCapacity 原样扩容,不享受默认 10 |
DEFAULTCAPACITY_EMPTY_ELEMENTDATA | new ArrayList<>() | 首次 add 时放大到 max(10, minCapacity),即 10 |
为什么要区分?因为 calculateCapacity() 需要知道"这个列表是不是无参构造创建的",是的话第一次扩容必须给足 10——这是 JDK 8 引入的延迟初始化策略:无参构造不直接 new Object[10],而是指向共享哨兵,真正分配推迟到第一次 add()。好处是创建大量空列表时不浪费内存。
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 时的目标容量,而不是构造时的容量——这个细节几乎每次面试都会被问到。
一次 add() 的完整调用链:首次扩容如何到 10
把 add() 到 grow() 的整条链路铺开看,是理解扩容机制的关键。JDK 8 的调用链是四层:
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) 要穿过四层检查。注意 ensureExplicitCapacity 里无论是否需要扩容都会 modCount++——这为迭代器的 fail-fast 机制埋下了伏笔。三种构造方式,首次 add 后容量各是多少
| 构造方式 | elementData 指向 | 首次 add 后容量 |
|---|---|---|
new ArrayList<>() | DEFAULTCAPACITY_EMPTY_ELEMENTDATA | 10(max(10, 1)) |
new ArrayList<>(0) | EMPTY_ELEMENTDATA | 1(不享受默认 10,直接按 minCapacity=1) |
new ArrayList<>(16) | 长度为 16 的新数组 | 16(无需扩容) |
还有一个历史知识点:JDK 6/7 里 new ArrayList<>() 是直接 this(10) 分配 10 个槽位的;JDK 8 引入哨兵数组后改为延迟分配。所以如果你在 JDK 6/7 的代码里见过"无参构造立即分配 10",那是对的——只是版本不同。
10 是经验值:多数小列表容量在 10 以内,一次给够可以避免前几次频繁扩容;同时 10 个引用只占 40 字节左右(压缩指针下),对绝大多数场景几乎无内存代价。给 1 会导致前几次 add 疯狂扩容(1→2→3→4→6→9→14...),给 16 又对"只存 3 个元素"的列表浪费。10 是"空间 vs 扩容次数"的工程折中。
1.5 倍扩容公式:grow() 与容量边界
当 ensureExplicitCapacity 发现 minCapacity > elementData.length,就调用 grow() 扩容。JDK 8 的实现堪称经典:
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;
}
位运算
>> 1 等价于"整除 2 后向下取整",对正整数与 / 2 结果一致,且天然规避了负数除法向零取整的语义差异
从默认容量 10 出发,每一次扩容的容量变化如下(注意纵轴是对数刻度,容量呈指数起飞):
grow() 的三个边界保护
| 边界场景 | 行为 | 保护逻辑 |
|---|---|---|
new ArrayList<>(-1) | IllegalArgumentException | 构造器里的 else 分支显式拒绝 |
ensureCapacity(-1) | 静默返回,无异常 | minCapacity > elementData.length 不成立 |
| 1.5 倍后仍不够(addAll 大集合) | 直接用 minCapacity | newCapacity - minCapacity < 0 分支 |
容量逼近 Integer.MAX_VALUE | hugeCapacity 兜底,溢出则 OOM | oldCapacity + (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,这只是理论边界。
① 内存复用。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() 被重构为调用 ArraysSupport.newLength(oldCapacity, minCapacity - oldCapacity, oldCapacity >> 1),其中第三个参数 preferred growth = oldCapacity >> 1 就是 1.5 倍,默认容量 10 与哨兵判断也被并进了 grow()。语义与 JDK 8 完全一致。面试直接答 JDK 8 版本即可,提一句"新版本抽到了 ArraysSupport,行为不变"能加分。
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 // 要拷贝的元素个数
);
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 个元素,总共拷贝了多少次?
理想等比级数上界:S = 10 × (1.529 − 1) / (1.5 − 1) ≈ 2.56 × 106(实际逐项求和因向下取整略小)
也就是说,你插入了 100 万个元素,但底层实际做了约 243 万次元素拷贝。
虽然单次扩容是 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 站)。
ensureCapacity 与预分配:把扩容次数从 29 降到 0
有两种方式避免扩容灾难:构造时指定容量,或对已创建的列表调用 ensureCapacity。
// 方式 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 内部:先算好总容量,只扩容一次、只拷贝一次
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 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),或先List<T> list = new ArrayList<>(result.size()) - 复制另一个集合 →
new ArrayList<>(sourceCollection.size()) - 分页查询,已知 pageSize →
new ArrayList<>(pageSize) - 预估不了精确值?给个数量级也行——
ensureCapacity是"保底容量",多给一点没有副作用(除非大到 OOM)
预分配容量就像数据库连接池的 initialSize:连接池建好后按 initialSize 一次性建好连接,而不是每次请求都新建/销毁。ArrayList 的扩容 = 每次请求都重建连接池,预分配 = 一次到位。凡是"容量可预估、生命周期内只增不改"的数据结构,都应该预分配。
只增不减:remove 之后容量不会变小,何时该 drain
扩容是"只增不减"的——remove() 只会减小 size,elementData.length 纹丝不动。看 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:
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():
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"的标准:列表是否长期存活 + 容量远大于实际使用量,两者同时成立才值得缩。
性能全景:各种操作的实际复杂度
面试中常被问到"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 缓存命中率差异巨大 |
| 内存占用 | 数组 + 最多 1/3 稀疏槽 | 每元素一个 Node(额外 ~24-40 B) | LinkedList 每个元素还要多存两个引用 |
现代 CPU 读取内存时会预取相邻的缓存行(通常 64 字节)。ArrayList 的 Object[] 在堆上是连续内存,CPU 预取命中率极高。LinkedList 的每个 Node 分散在堆上,每次 node.next 可能触发一次 cache miss(L1 缓存命中约 1ns,主存访问约 100ns)。这就是为什么同样 O(n) 的遍历,ArrayList 比 LinkedList 快 5-10 倍的底层原因。
LinkedList 源码解剖:为什么"插入快"是个谎言
先看 LinkedList 的底层——它压根不是"List 的链表版"这么简单,而是一个双向链表:
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;
}
}
}
Node 对象,分散在堆上;first/last 只持有首尾引用。再看"中间插入 O(1)"是怎么来的——先看 add(int index, E):
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 的搬移成本一个量级,却多了缓存不友好和内存开销两个劣势。
| 维度 | ArrayList | LinkedList | ArrayDeque |
|---|---|---|---|
| 底层结构 | 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 几乎不应该被使用。"
for / for-each / stream:三种遍历的真实差异
同样是"遍历一遍",三种写法在底层是三条完全不同的代码路径:
// ① 索引 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,无迭代器对象
方法级源码:三种遍历各自的"检查机制"
// 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 都校验,并发修改几乎立刻抛 ConcurrentModificationException;list.forEach 每次循环条件校验,暴露也很早;stream().forEach 走 forEachRemaining,整段遍历完才校验,异常会"延迟暴露"(tryAdvance 单步路径则会逐元素校验)。
相对性能与能力对比
| 维度 | 索引 for | for-each | list.forEach | stream().forEach |
|---|---|---|---|---|
| 底层机制 | elementData[i] | ArrayList.Itr | ArrayList 重写的索引循环 | 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)。
生产环境最佳实践与常见陷阱
写了十年 Java,以下是我总结的 ArrayList 使用守则:
原则一:默认用 ArrayList。除非你有明确的理由(比如需要一个双端队列),否则一律选 ArrayList。Joshua Bloch(《Effective Java》作者、集合框架设计者)本人也说过:"LinkedList 几乎不应该被使用。"
原则二:已知数据量就预分配。
// ✓ 从数据库查询结果转换
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——父列表任何结构修改都会让子列表立刻失效:
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()。
// 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,释放多余内存
// 典型场景:长期存活的大列表,填充后不再修改
原则六:不要在循环里正序 remove。
// ✗ 正序按索引删:删除后元素前移,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 是不是长期存活、又删过元素?
快速参考卡片与高频追问
ArrayList 扩容机制速记
- 底层结构:
Object[] elementData,DEFAULT_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 - 8,hugeCapacity兜底溢出,负数容量构造抛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() |
高频追问速答
// 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 · 评论