全文组织架构
ArrayList 的本质是一个由 Object[] 数组支撑、通过 System.arraycopy 完成元素迁移、在容量不足时自动 1.5 倍扩容的有序列表。O(1) 随机访问与连续内存带来的遍历性能是其核心优势,但操作的非原子性使其线程不安全,modCount 快照机制则让迭代能快速失败。实现层面,延迟分配、自定义序列化、fail-fast 迭代器等设计精巧地平衡了内存、性能与安全,而 subList 和 Arrays.asList 因共享底层数组暗藏陷阱。
- 存储与构造 :底层
Object[] elementData+int size确定逻辑边界;无参构造延迟分配,首次add才扩容至 10,节省空列表内存 - 扩容 :
oldCapacity + oldCapacity >> 1确定 1.5 倍新容量,Arrays.copyOf复制,上限Integer.MAX_VALUE - 8;可手动ensureCapacity减少扩容次数 - 线程安全 :
elementData[size++]非原子,并发写会丢数据或抛异常;替代方案有CopyOnWriteArrayList(读无锁写复制)、Vector(全同步)、Collections.synchronizedList(包装器) - fail-fast :迭代器持有
expectedModCount快照,遍历中任何结构性修改(除迭代器自身的remove)都会立即抛出ConcurrentModificationException - 序列化 :
elementData被transient,自定义writeObject/readObject只序列化实际元素,反序列化重建恰好容量的数组,省空间且隐藏内部实现 - subList 陷阱 :返回内部
SubList视图,与原列表共享数组,任一方结构性修改会导致另一方的迭代器快速失败 - Arrays.asList :返回固定大小的
Arrays内部列表,不支持增删,且与原数组联动修改 - 性能 :连续内存缓存局部性好,遍历远快于
LinkedList;频繁头部插入/删除是灾难,扩容临时数组会增加 GC 压力 - 实践要点 :预先指定初始容量、用迭代器或
removeIf安全删除、避免频繁头部操作、警惕subList和Arrays.asList的约束
以下架构图展示了本文的六大篇章及内部模块顺序,你可以据此按需阅读或快速定位感兴趣的章节。
- Part 1 基础认知篇:从定义、六大特性、适用场景与完整继承关系入手,为后续源码分析打下概念基础。
- Part 2 存储与构造篇 :重点剖析三种构造器的延迟分配思想,以及
elementData、size、modCount三大核心字段的职责与设计意图。 - Part 3 核心原理篇 :全文技术重心,独立讲解 1.5 倍扩容的算法与均摊分析,随后对增删改查进行源码级拆解,并覆盖
sort、clear、trimToSize等辅助方法。 - Part 4 迭代与序列化篇 :说明
Itr/ListItr如何借助expectedModCount实现 fail-fast,以及自定义序列化如何绕过transient实现省空间、解耦的序列化协议。 - Part 5 进阶与并发篇 :剖析
subList视图与Arrays.asList的内部实现及陷阱,对比三种线程安全方案,并从内存布局和 GC 角度对性能做出解释。 - Part 6 总结与面试篇:提炼最佳实践与正反例代码,集中攻克 10 道高频面试题,最后以时间复杂度表与选型建议收尾。
Part 1:基础认知篇
模块 1:定义、核心特性与适用场景
定义
java.util.ArrayList 是基于动态数组实现的 List 接口的可变大小有序集合。它维护一个 Object[] 内部数组,当元素数量超过数组长度时会自动进行容量扩展,从而在逻辑上提供"无限容量"的列表容器。
核心特性提炼
- 有序性 :元素按照
add调用的顺序存储,迭代顺序与插入顺序一致。 - 可重复性 :允许元素重复,通过
equals()判定,同一对象可出现多次。 - 随机访问 :实现了
RandomAccess标记接口,get(int index)直接以数组下标寻址,时间复杂度 O(1)。 - 动态扩容 :自动扩容,采用
oldCapacity + (oldCapacity >> 1)增长 1.5 倍,兼顾空间与拷贝开销。 - 非线程安全:所有 public 方法未加同步,多线程并发写需外部锁或选用线程安全替代品。
- 允许 null 元素 :底层
Object[]可以存放 null,且可多个 null。
适用场景与反例场景
- ✅ 需要频繁通过索引随机访问、修改元素,或大部分插入发生在尾部。
- ✅ 遍历操作占主导且期望利用 CPU 缓存行优势的场景。
- ❌ 频繁在列表头部或中部进行插入、删除,导致大规模数组复制。
- ❌ 多线程并发读写且未施加同步措施,将产生数据错乱甚至越界异常。
底层实现本质一句话
Object[] 数组 + 动态扩容 + System.arraycopy 元素迁移。
图表说明 :根据对随机访问的需求将链表与数组实现分开。如果需要快速随机访问,再检查线程安全要求。线程安全场景下,若写操作频率很低(如配置信息),CopyOnWriteArrayList 是最佳选择,因为写时复制成本高但读完全无锁;若并发度不高或仍需写操作较频繁,可选择 Vector 或 Collections.synchronizedList。如果不需要线程安全,ArrayList 是默认选择。该决策树帮助开发者在不同需求下做出合适的 List 实现选型。
模块 2:接口与继承体系
ArrayList 继承自 AbstractList,从而获得 List 接口的部分骨架实现,进而实现 RandomAccess、Cloneable 和 Serializable 接口。
图表说明:
Collection接口继承自Iterable,使得所有集合均可使用 for-each 循环。List接口在Collection基础上增加了按索引操作的方法,如get(int)、add(int, E)、listIterator()等。AbstractCollection提供了isEmpty()、contains()、toArray()等通用实现,AbstractList在此基础上进一步提供了indexOf、lastIndexOf、listIterator以及subList的骨架。ArrayList继承AbstractList后只需集中实现数组存储相关的核心操作,如add、get、remove、扩容等。RandomAccess是标记接口,表明此 List 支持快速随机访问,算法可根据list instanceof RandomAccess决定使用索引遍历还是迭代器遍历。Cloneable和Serializable使得 ArrayList 支持浅克隆与序列化。
Part 2:存储与构造篇
模块 3:构造方法与延迟分配(源码剖析)
ArrayList 提供了三个构造器,其核心设计思想是延迟分配底层数组------在未添加元素时,尽量复用内部已有的空数组常量,避免堆内存浪费。
3.1 无参构造器 ArrayList()
java
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
该构造器不分配任何数组,直接将 elementData 指向常量 DEFAULTCAPACITY_EMPTY_ELEMENTDATA,这是一个静态的、长度为 0 的 Object[]。它与另一个空数组常量 EMPTY_ELEMENTDATA 的区别在于用途:此常量用于标记"因无参构造而产生的空列表",从而在首次添加元素时触发特殊的扩容策略,将容量直接扩展至 DEFAULT_CAPACITY = 10。
3.2 指定初始容量构造器 ArrayList(int initialCapacity)
java
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);
}
}
当指定的容量大于 0 时,直接分配定长数组;当指定为 0 时,使用 EMPTY_ELEMENTDATA 共享常量。注意:使用 EMPTY_ELEMENTDATA 的列表在后续扩容时不会跳到 10,而是严格按 1.5 倍计算(0 的 1.5 倍还是 0,但 grow 会以 minCapacity 为准)。
3.3 集合构造器 ArrayList(Collection<? extends E> c)
java
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
this.elementData = EMPTY_ELEMENTDATA;
}
}
先将集合转为数组并赋值给 elementData,再更新 size。但某些集合的 toArray() 返回的数组运行时类型并非 Object[](如子类型数组),这会导致后续可直接存放任意对象的能力消失,因此需要通过 Arrays.copyOf 进行一次防御性拷贝,确保底层数组类型为 Object[]。如果传入集合为空,则使用 EMPTY_ELEMENTDATA,避免维护多余数组。
图表说明:
- 无参构造没有开辟新数组,直接复用常量
DEFAULTCAPACITY_EMPTY_ELEMENTDATA,这一空数组引用指向堆中唯一实例,节省内存。 - 指定初始容量 0 的构造使用另一个常量
EMPTY_ELEMENTDATA,虽然同样长度为 0,但在ensureCapacityInternal中不会触发跳至 10 的逻辑。 - 集合构造器首先要解决
toArray返回子类型数组的兼容问题,通过类型检查与Arrays.copyOf保证了底层数组为Object[],允许此后加入任意类型的对象。 - 三种构造器的不同路径最终都将影响首次添加时的扩容行为:无参构造会保证容量至少为 10;其他则仅保证至少容纳 1 个元素,并严格按照 1.5 倍步长增长。
模块 4:存储结构与核心字段(源码剖析)
java
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
private static final int DEFAULT_CAPACITY = 10;
private static final Object[] EMPTY_ELEMENTDATA = {};
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
transient Object[] elementData; // 实际存储数组
private int size; // 元素数量
protected transient int modCount; // 结构修改次数
}
elementData:被transient标记,默认序列化将忽略该字段。这是因为数组长度通常大于实际元素数目,默认序列化会浪费大量空间,同时暴露内部实现细节。ArrayList 通过自定义writeObject和readObject实现更紧凑的序列化。size:标记已存入的元素数量,与elementData.length无关。modCount:继承自AbstractList,任意导致数组结构变化(增、删、扩容)的操作均会使该计数器自增,迭代器依赖它与自身快照expectedModCount进行一致性校验,实现 fail-fast 机制。
空数组常量对比
EMPTY_ELEMENTDATA:用于显式指定容量为 0 的构造或空集合构造,扩容时不会激增至 10。DEFAULTCAPACITY_EMPTY_ELEMENTDATA:专门用于无参构造,首次扩容时保证容量至少 10,优化默认使用场景。
图表说明 :该简化类图突出了 ArrayList 的三大核心字段。elementData 是整个容器的物理存储,其长度代表当前容量。size 逻辑边界决定了迭代和访问的范围。modCount 是一个安全度量,凡是可能破坏迭代器一致性的写操作都会使其变化,任何对列表的并发修改探测都依赖于此字段。
Part 3:核心原理篇
模块 5:扩容机制深度剖析(独立模块,核心重点)
扩容机制是 ArrayList 实现"动态"的根本,也是理解其性能特征的关键。
5.1 扩容触发时机
add(E e)、add(int index, E element)、addAll(Collection c)等插入操作在写入前调用ensureCapacityInternal。readObject中反序列化完成元素读取后,若数组长度大于size,可能会进行缩容或调整。- 手动调用
ensureCapacity(int minCapacity)可预先扩容,避免批量添加时的多次数组拷贝。
5.2 源码调用链路
ensureCapacityInternal(size + 1) → ensureExplicitCapacity(minCapacity) → grow(minCapacity) → hugeCapacity(minCapacity)
java
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity);
}
5.3 新容量计算:1.5 倍的数学权衡
新容量 newCapacity = oldCapacity + (oldCapacity >> 1),即 1.5 倍。选取 1.5 倍而不是 2 倍,是时间与空间博弈的结果:
- 2 倍扩容意味着每次扩展后预留的未使用空间可能过多,造成堆内存长期占用过高。
- 1.5 倍能使得数组大小序列更快地收敛,相邻的释放数组空间更容易被内存分配器复用,减少内存碎片。
均摊分析下,1.5 倍扩容依然保证插入 n 个元素的拷贝总成本为 O(n),均摊每次插入为 O(1)。
5.4 数组复制成本
Arrays.copyOf 内部调用 System.arraycopy,这是一个 native 方法,实现整块内存拷贝,但其时间复杂度为 O(n)。扩容时的复制会产生一个临时旧数组,成为 GC 回收对象。
5.5 极限容量与 MAX_ARRAY_SIZE
MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8。Java 数组长度由 int 表示,理论上最大可为 Integer.MAX_VALUE,但某些 JVM 实现会在数组对象头中存储额外信息,留出 8 字节空间可降低 OOM 风险。若所需容量超过 MAX_ARRAY_SIZE,hugeCapacity 方法会尝试返回 Integer.MAX_VALUE(但很可能抛出 OOM)。
5.6 手动扩容 ensureCapacity
该方法允许用户提前将数组扩容至指定最小容量,避免在大量 add 时多次触发 incremental 扩容及多次数组拷贝。
5.7 与 C++ vector 对比
C++ 标准库中 std::vector 通常使用 2 倍增长。相较之下,Java 1.5 倍在内存效率上更优,但可能导致稍多的扩容次数。Java 的选择偏向节省内存,符合托管运行时对 GC 压力敏感的设计哲学。
图表说明:
- 任何添加操作首先调用
ensureCapacityInternal,将当前size+1作为最小容量需求传递。 - 若
elementData为无参构造专属空数组,则最小容量至少为 10,这一步实现了默认容量 10 的语义。 ensureExplicitCapacity判断当前数组长度是否小于最小需求,若小于则扩容;任何时候进入该方法都会递增modCount,因为扩容也属于结构性修改。grow首先记录旧容量,然后移位相加得到 1.5 倍新容量。若该新容量仍不满足要求(例如一次性addAll大量元素),则以minCapacity为准。- 新容量与
MAX_ARRAY_SIZE比较,超限则调用hugeCapacity处理,可能抛出 OutOfMemoryError。 - 最终通过
Arrays.copyOf创建新数组、复制旧元素,并将引用赋给elementData,旧数组将失去引用而被垃圾回收。
模块 6:核心操作------插入 add(结合源码)
6.1 尾部追加 add(E e)
java
public boolean add(E e) {
ensureCapacityInternal(size + 1);
elementData[size++] = e;
return true;
}
检查容量后直接在 size 位置赋值并自增。在多线程环境下,elementData[size++] 分解为读、存、写三步,非原子操作,可能导致数据覆盖。
6.2 指定位置插入 add(int index, E element)
java
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1);
System.arraycopy(elementData, index, elementData, index + 1, size - index);
elementData[index] = element;
size++;
}
先检查索引合法性(0 ≤ index ≤ size),然后扩容确认,接着将 index 开始的所有元素整体后移一位,再插入新元素。移动的元素数量为 size - index,平均复杂度 O(n)。
6.3 addAll(Collection<? extends E> c)
实现类似,先转换为数组,一次扩容到位,然后通过两次 System.arraycopy(或一次合并)将集合元素插入尾部或指定位置。
Demo 代码
java
List<String> list = new ArrayList<>();
list.add("A"); // 尾部追加
list.add("B");
list.add(1, "C"); // 在索引1处插入,B 后移
System.out.println(list); // [A, C, B]
List<String> another = Arrays.asList("X", "Y");
list.addAll(0, another); // 头部批量插入
System.out.println(list); // [X, Y, A, C, B]
图表说明 :尾部追加代码路径极短,只涉及容量检查、赋值与 size 自增,性能最佳。中间插入则额外调用 rangeCheckForAdd 检查 index 是否在 [0, size] 区间内,随后必须通过 System.arraycopy 移动内存块;移动规模与插入位置成反比,越靠近头部成本越高,极端情况下可能导致 O(n) 级的复制开销,这也是不推荐在 ArrayList 头部频繁插入的原因。
模块 7:核心操作------删除 remove(结合源码)
7.1 按索引删除 remove(int index)
java
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;
return oldValue;
}
rangeCheck 验证 index 小于 size,随后自增 modCount,计算需要前移的元素个数,若大于 0 则执行数组复制将后续元素覆盖上来,最后把原本最后一个有效位置(现 size-1)置为 null 以断开引用,让 GC 可以回收。
7.2 按对象删除 remove(Object o)
java
public boolean remove(Object o) {
if (o == null) {
for (int index = 0; index < size; index++)
if (elementData[index] == null) {
fastRemove(index);
return true;
}
} else {
for (int index = 0; index < size; index++)
if (o.equals(elementData[index])) {
fastRemove(index);
return true;
}
}
return false;
}
区分 null 与非 null 情况,线性遍历查找第一个匹配元素,然后调用 fastRemove(跳过索引范围检查的私有方法)执行实际的删除和移动。时间复杂度 O(n)。
Demo 代码
java
List<String> list = new ArrayList<>(Arrays.asList("A","B","C","D"));
list.remove(2); // 删除索引 2 的 "C"
list.remove("A"); // 删除对象 "A"
System.out.println(list); // [B, D]
图表说明 :删除操作的第一步进行索引上限检查,合法的 index 必须小于 size。modCount 自增标志着列表结构变化,可能导致并发迭代器立即失败。接着计算需要移动的元素数,若待删除元素不是最后一个,则调用 native 方法 System.arraycopy 完成数组块平移,最后将抛弃的尾部引用置为 null,帮助 GC 回收不再需要的对象。这一细节防止了因 ArrayList 长期持有对象引用而导致的内存泄漏。
模块 8:核心操作------查询 get 与修改 set
8.1 get(int index)
java
public E get(int index) {
rangeCheck(index);
return elementData(index);
}
E elementData(int index) {
return (E) elementData[index];
}
8.2 set(int index, E element)
java
public E set(int index, E element) {
rangeCheck(index);
E oldValue = elementData(index);
elementData[index] = element;
return oldValue;
}
两者均先调用 rangeCheck 确认索引在 [0, size) 范围内,然后直接通过数组下标访问目标槽位。因为数组在内存中连续,可在 O(1) 时间内完成。
Demo 代码
java
List<Integer> list = new ArrayList<>(Arrays.asList(10, 20, 30));
int val = list.get(1); // 20
list.set(1, 99); // 将索引1处修改为99
System.out.println(list); // [10, 99, 30]
图表说明 :这两个操作极其简单,全程只有索引检查和数组寻址,没有任何循环或复制。这正是 RandomAccess 接口所承诺的快速随机访问能力。在列表遍历时,若已知集合为 ArrayList,应优先使用 for 索引循环而非迭代器,从而充分利用此 O(1) 特性。
模块 9:其他重要方法(源码简析)
clear():循环将elementData[0..size-1]置 null,size归零,modCount++,数组容量保持不变。contains(Object o)/indexOf/lastIndexOf:顺序扫描数组并调用equals比较,定位第一个或最后一个匹配元素。trimToSize():如果size < elementData.length,则用Arrays.copyOf创建一个长度精确等于size的新数组并将元素拷贝进去,用于释放预留空间。通常用在列表不再增长的情况下。sort(Comparator<? super E> c):直接委托Arrays.sort(elementData, 0, size, c),排序算法为 TimSort(针对部分有序数据的优化归并)。排序执行完毕后,modCount++以维持 fail-fast 语义。forEach(Consumer<? super E> action):内部使用final int expectedModCount = modCount构建迭代器,对每个元素调用action.accept,期间若发现并发修改立即抛出异常。
图表说明 :sort 方法的调用链展示了 ArrayList 如何将排序能力委托给 java.util.Arrays。当数据量超过阈值(默认 32)时,Arrays.sort 会启用 TimSort 算法,该算法先找出数组中已经有序的片段(run),然后利用归并思想合并这些片段,对较小 run 使用二分插入排序。排序过程完全发生在 elementData 内部,仅在必要时产生临时数组。排序完成后 modCount 自增,保证任何正在迭代中的迭代器立刻检测到结构变化并抛出 ConcurrentModificationException。
Part 4:迭代与序列化篇
模块 10:迭代器深度剖析------fail-fast 的完整实现
ArrayList 的迭代器由内部类 Itr 实现 Iterator 接口,ListItr 继承 Itr 并实现 ListIterator 接口,支持双向遍历与元素替换。
10.1 Itr 核心字段
int cursor:下一次next()要返回的元素的索引,初始为 0。int lastRet = -1:最近一次通过next()或previous()返回的元素索引,-1 表示没有。int expectedModCount = modCount:创建迭代器时记录的修改计数快照。
10.2 next() 与 checkForComodification
java
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
public E next() {
checkForComodification();
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];
}
每次调用 next 先验证 modCount 的一致性,然后检查游标是否越界,最后返回当前元素并前移 cursor 与设置 lastRet。
10.3 remove() 的协同工作
java
public void remove() {
if (lastRet < 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
必须先调用 next()(lastRet ≥ 0)才可删除。删除先委托外部类 remove(lastRet),随后将 cursor 调整为 lastRet,使得下次 next() 返回原本的下一个元素;lastRet 重置为 -1 防止重复删除。最关键的一步:更新 expectedModCount = modCount,同步外部修改计数,这样迭代器在删除后依然有效。
10.4 ListItr 的增强
ListItr 扩展了 Itr,提供 previous()、set(E e)、add(E e) 等方法。add 方法通过调用外层 ArrayList.this.add(cursor, e) 并同步 expectedModCount,而 set 直接赋值并检测 lastRet。
图表说明:
- 在
next流程中,第一步的checkForComodification确保自迭代器创建或上次同步后,外部列表没有发生结构性变化。随后判断游标是否已经达到 size,防止越界。取元素后,cursor 指向下一个待返回索引,lastRet 记录本次返回索引,为 remove 操作提供依据。 remove流程中,首先检查 lastRet 合法性,确保之前调用过 next 且未重复 remove。然后再次检查并发修改,通过外层ArrayList.remove删除元素;删除会触发数组移动,并更新 modCount。迭代器通过将 cursor 回退至 lastRet,并重置 lastRet 为 -1,最后将 expectedModCount 刷新为最新的 modCount,保证后续遍历不会误判并发修改。这就是迭代器对"结构性修改"的安全窗口机制。
模块 11:序列化与 transient 的设计智慧(源码解读)
elementData 声明为 transient,若依赖默认序列化将丢失全部元素数据。ArrayList 通过自定义 writeObject 与 readObject 实现了更优的序列化方案。
11.1 writeObject(java.io.ObjectOutputStream s)
java
private void writeObject(java.io.ObjectOutputStream s) throws IOException {
int expectedModCount = modCount;
s.defaultWriteObject(); // 写入非 transient 字段(size等)
s.writeInt(elementData.length); // 写入当前容量,便于反序列化预分配
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}
先调用 defaultWriteObject 保存 size 等非 transient 数据,然后将数组 length 写入流用于预分配,再循环仅序列化 size 个有效元素,跳过空槽位。尾部再次校验并发修改。
11.2 readObject(java.io.ObjectInputStream s)
java
private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException {
elementData = EMPTY_ELEMENTDATA;
s.defaultReadObject(); // 读取 size 等
int capacity = s.readInt();
if (capacity > 0) {
ensureCapacityInternal(size); // 扩容至至少容纳 size 个元素
Object[] a = elementData;
for (int i=0; i<size; i++) {
a[i] = s.readObject();
}
}
}
先重置数组为空,读取 size,然后根据之前写出的容量申请相应的内部数组,并逐一读入元素放入数组。这样可以保证反序列化后的 elementData 长度恰为 size(或稍大但无浪费),避免序列化垃圾数据。
图表说明:
- 序列化阶段,ArrayList 首先把元数据(size、modCount 拷贝)通过默认协议写出,然后特意写出当前数组容量以便反序列化预分配;关键步骤是循环写出实际元素,而尾部的 null 空槽全部忽略,大大减小序列化产物体积。
- 反序列化时,通过先读 size,再根据容量字段一次性分配足够大的数组,然后逐个读回元素。这种方法使得即使原 ArrayList 预留了大量空闲容量,序列化流中也仅包含实际数据。反序列化后的容量可能比原实例小,但元素完整保留。
- 整个过程中,任何并发的结构修改都会导致
modCount变化并被捕获,抛出异常保证序列化数据一致性。
Part 5:进阶与并发篇
模块 12:subList 视图陷阱与 Arrays.asList 差异(源码剖析)
12.1 subList 的内部实现
ArrayList 的 subList(int fromIndex, int toIndex) 返回一个内部类 SubList 实例,该实例不复制数据,而是直接持有外部 ArrayList 的引用,并通过 offset 和 size 映射视图范围。任何对 SubList 的非结构性操作(如 get、set)都会被转换为对外部列表对应索引的操作;结构性修改(如 add、remove)也会反映到外部列表。
关键源码结构
java
private class SubList extends AbstractList<E> implements RandomAccess {
private final AbstractList<E> parent;
private final int parentOffset;
private int size;
...
}
由于 SubList 和原列表共享同一个 elementData,JDK 文档明确要求:在通过 SubList 修改列表后,不能再直接修改原列表的结构,否则后续对 SubList 的操作将引发 ConcurrentModificationException 。这是因为 SubList 内部记录 modCount 快照,任何一方的结构性修改都会使另一方的迭代器或方法检查失败。
12.2 Arrays.asList 的陷阱
Arrays.asList(T... a) 返回的是 Arrays 的一个私有内部类 ArrayList,它并非 java.util.ArrayList。该内部类也基于原始数组,但不支持结构性修改 (即 add、remove 会直接抛出 UnsupportedOperationException)。同时,由于它直接引用传入的数组,对列表的 set 操作会影响原数组,反之亦然。
Demo 代码:两条陷阱重现
java
// 陷阱1:subList 与原列表互相干扰
List<String> list = new ArrayList<>(Arrays.asList("A","B","C","D"));
List<String> sub = list.subList(1, 3);
list.add("E"); // 结构性修改原列表
// sub.get(0); // 可能抛出 ConcurrentModificationException
// sub.add("X"); // 也会异常
// 陷阱2:Arrays.asList 固定大小
List<Integer> fixedList = Arrays.asList(1, 2, 3);
fixedList.set(0, 99); // OK
// fixedList.add(4); // 抛出 UnsupportedOperationException
安全使用原则
- 若要通过 subList 操作原列表,期间不要直接对原列表进行结构性修改。
- 使用
new ArrayList<>(Arrays.asList(...))将固定列表转换为真正的可变 ArrayList。 - 理解 subList 只是一个视图,需要独立列表时应当新建 ArrayList 拷贝。
模块 13:并发问题与线程安全方案对比(结合源码与并发分析)
13.1 非线程安全根源
add(E e)中的elementData[size++] = e不是原子操作:先读取 size,然后存值,再写回 size+1。多线程可能导致覆盖或数组越界。- 扩容期间,线程 A 触发 grow,重新分配数组并拷贝数据,此时线程 B 可能仍在旧数组上操作,造成数据丢失或 NPE。
modCount的更新没有同步,迭代器可能在并发修改时得到不确定行为而不是可靠的 fail-fast。
以下是线程不安全的简易演示:
java
List<String> list = new ArrayList<>();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) list.add("x");
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(list.size()); // 结果往往小于 2000,甚至抛出异常
13.2 线程安全方案对比
-
Vector
所有 public 方法均使用
synchronized关键字修饰,锁粒度是整个方法,性能较差。 -
Collections.synchronizedList
返回一个包装器
SynchronizedRandomAccessList,内部使用mutex对象同步,每个方法调用都会加锁。迭代时仍需开发者手动同步。 -
CopyOnWriteArrayList
写入时加
ReentrantLock,创建底层数组的新副本,在新副本上修改,然后通过volatile将数组引用指向新数组。读操作无锁,直接访问当前数组,适合读多写少的场景。
图表说明:
synchronizedList在每次调用时都会竞争同一把锁,读操作也被阻塞,并发度低。CopyOnWriteArrayList写操作加锁并复制整个数组,内存开销大但读操作完全无锁且极其快速;写操作完成后数组引用通过 volatile 更新,读线程即刻可见。该方案在迭代过程中不抛ConcurrentModificationException,因为迭代器持有的是快照数组引用。- 性能权衡:写多场景下 CopyOnWriteArrayList 拷贝开销巨大,不适合;低并发整体同步可用 Vector 或同步包装器;高并发读多写少选 CopyOnWriteArrayList;大量写操作应考虑并发队列或显式锁分段。
模块 14:内存占用与 GC 影响分析
对象内存布局
一个 ArrayList 实例本身占用对象头(Mark Word + Class Pointer),加上 elementData 引用、size、modCount 等字段,典型 64 位 JVM 开启压缩指针下约 24 字节。存储的主体是 elementData 数组,其内存为连续分配的数组对象头 + 容量 * 引用大小。
扩容带来的临时数组
每次扩容都会创建新数组并将旧数组内容复制过去,旧数组失去引用后成为垃圾,导致 GC 压力。若频繁尾部追加并触发多次扩容,会生成多个不同大小的临时数组,有可能触发 Full GC。预先指定足够容量可显著缓解。
与 LinkedList 的内存对比
LinkedList 每个节点为一个独立 Node 对象(对象头 + 前驱后继指针 + 数据引用),占用内存分散,内存碎片较多,且遍历时缓存不友好。ArrayList 连续内存布局使得迭代时 CPU 缓存行命中率高,大多数元素可在同一缓存行内取得,性能优势明显。
缩容操作
trimToSize() 可以将预留空间释放,但会产生一次数组复制和旧数组回收,建议在列表不再变化时使用。
Part 6:总结与面试篇
模块 15:注意事项与最佳实践
-
基本类型需装箱
ArrayList 不能直接存储
int、long等基本类型,必须使用包装类,自动装箱会带来性能与内存开销。
正例 :List<Integer> list = new ArrayList<>(); list.add(42);
反例 :试图利用new ArrayList<int>()编译错误。 -
foreach 删除陷阱
使用增强 for 循环内部调用
list.remove(obj)会触发ConcurrentModificationException,因为迭代器内部 modCount 被意外修改。
错误示例:javafor (String s : list) { if (s.equals("del")) list.remove(s); } // 异常正确示例 :使用迭代器的
remove()或removeIfjavaIterator<String> it = list.iterator(); while (it.hasNext()) { if (it.next().equals("del")) it.remove(); } // 或 Java 8+ list.removeIf(s -> s.equals("del")); -
频繁头部插入性能灾难
每次头部插入会导致整个数组后移,O(n) 开销。
反例 :list.add(0, e)在循环内导致平方级复杂度。
正确 :改用LinkedList或ArrayDeque,或最后反转列表。 -
预估容量
若事先能估算元素数量,使用
new ArrayList<>(expectedSize)避免多次扩容。
示例 :处理 10000 条数据,直接new ArrayList<>(10000)可减少约十余次数组复制。 -
subList 不可与原列表同时结构性修改
如模块 12 所述,获得 subList 后,原列表的增减操作会使 subList 失效抛出异常。
-
返回不可变视图
公共 API 返回列表时,若不想被外部修改,使用
Collections.unmodifiableList(list)包装。 -
null 元素的存在与处理
ArrayList 允许 null,但
sort时若 Comparator 未处理 null 可能导致 NPE;indexOf(null)可正确找到 null 位置。
模块 16:面试高频专题(独立且极尽详细)
以下 10 个面试必考问题,覆盖所有核心知识点。
1. ArrayList 的默认容量与扩容机制?
标准回答 :无参构造创建的 ArrayList 在首次添加元素时,容量会从 0 直接扩展为 10;后续扩容采用 1.5 倍递推(oldCapacity + (oldCapacity >> 1))。若一次性添加大量元素,新容量可能变为 minCapacity。最大容量受限于 Integer.MAX_VALUE - 8。
追问 1 :为什么是 1.5 倍而不是 2 倍?
回答:2 倍扩容会导致内存迅速膨胀,未使用的预留空间浪费严重;1.5 倍较好地平衡了拷贝次数和空间浪费,且增长序列在内存分配器中更易复用。
追问 2 :MAX_ARRAY_SIZE 为何减 8?
回答:部分 JVM 在数组对象头中保留了 8 字节额外信息,直接申请 Integer.MAX_VALUE 容易导致 OOM,减 8 作为安全余量。
加分回答 :结合均摊分析,证明 n 次插入的复制次数上界为 2n,故均摊 O(1)。另外可提及 ensureCapacity 手动扩容的优化。
2. elementData 为何用 transient,序列化如何保证元素不丢失?
标准回答 :elementData 长度通常大于 size,默认序列化会存储大量空引用导致空间浪费并暴露内部数组大小。ArrayList 通过自定义 writeObject 和 readObject 仅序列化 size 个实际元素,并在反序列化时重建合适容量的数组。
追问 1 :writeObject 中为什么还要写 elementData.length?
回答:用于反序列化时预分配正确的容量,避免多次 incremental 扩容。
追问 2 :序列化过程中出现并发修改会怎样?
回答:tail 部分的 modCount 检查将抛出 ConcurrentModificationException,保证序列化快照一致性。
加分回答:这种外部方法实现序列化是 Serializable 接口的一种扩展机制,ObjectOutputStream 通过反射调用 private 方法,是框架设计的经典案例。
3. ArrayList 与 LinkedList 的性能全面对比?
标准回答 :ArrayList 随机访问 O(1),尾部追加均摊 O(1),插入/删除需移动元素 O(n);LinkedList 随机访问 O(n),头尾插入删除 O(1),节点插入删除仅需修改引用,但遍历时内存跳跃,缓存不友好。
追问 1 :缓存行对两者遍历性能的实际影响?
回答:ArrayList 连续内存可充分利用 CPU 缓存行预取,遍历速度远快于指针跳转的 LinkedList。
追问 2 :内存占用差异?
回答:ArrayList 仅需一个数组及预留容量;LinkedList 每个节点有两个指针开销,内存占用远大于 ArrayList,特别是在存储小对象时。
加分回答:可以举实际 JMH 基准测试数字,说明遍历 ArrayList 通常比 LinkedList 快 3~5 倍。
4. subList 陷阱及内部 SubList 源码机制?
标准回答 :subList 返回内部类 SubList,持有原列表引用,修改视图会直接影响原列表,反之亦然。结构性修改将导致双方 modCount 不一致,继续访问抛出 ConcurrentModificationException。
追问 1 :为什么 SubList 没有独立复制数据?
回答:节省内存并保持视图实时性,这是典型的代理/视图设计模式。
追问 2 :如何获得一个独立的子列表?
回答:new ArrayList<>(list.subList(from, to))。
加分回答:可提及 SubList 自身的 modCount 检查是如何基于父列表实现的,以及为什么它的 add、remove 方法都会调用父列表内部方法。
5. Arrays.asList 返回的 List 与 new ArrayList 的区别?
标准回答 :Arrays.asList 返回的是 java.util.Arrays 的私有内部类 ArrayList,固定大小、不支持增删,且与原数组共享存储,对 set 操作会反映在原数组上。new ArrayList 则是真正可变、自动扩容的独立列表。
追问 1 :为什么 Arrays.asList 不能 add 或 remove?
回答:其内部没有实现 add/remove 方法,继承自 AbstractList 的这两个方法默认抛 UnsupportedOperationException。
追问 2 :如何将数组安全转为可变 List?
回答:new ArrayList<>(Arrays.asList(array))。
加分回答 :解释自动装箱问题------Arrays.asList(1,2,3) 会被推断为 List<int[]> 而非 List<Integer>,因为泛型不支持基本类型,可能产生意想不到的结果。
6. modCount 如何实现 fail-fast,遍历时安全删除方式?
标准回答 :迭代器创建时 snapshot expectedModCount,任何结构性修改使 modCount 自增,下一次 next/remove 检测到不等即抛异常。安全删除须用迭代器自身的 remove 或 removeIf。
追问 1 :modCount 为什么被标记为 protected transient?
回答:protected 允许子类复用 fail-fast 机制;transient 避免序列化无意义的状态值。
追问 2 :如果在迭代时使用迭代器的 add(ListIterator)会怎样?
回答:ListIterator.add 会同步 expectedModCount,因此是安全的。
加分回答 :可谈论 modCount 溢出问题------理论上 int 溢出后取负值可能导致检查失效,但在有生之年不太可能发生。
7. ArrayList 线程不安全的原因及多种安全方案对比?
标准回答 :elementData[size++] 非原子、扩容竞争、modCount 无同步等导致并发问题。方案包括 Vector(全方法 synchronized)、Collections.synchronizedList(同步包装器)、CopyOnWriteArrayList(写时复制,读无锁)。
追问 1 :同步包装器迭代时为什么需要手动加锁?
回答:包装器提供的 iterator() 返回的迭代器自身不是线程安全的,文档要求用户必须在迭代时同步在包装器对象上。
追问 2 :CopyOnWriteArrayList 适合什么场景?为什么不适合频繁写?
回答:适合读多写少,如缓存配置;写时复制整个数组开销巨大,频繁写会产生大量复制和 GC 压力。
加分回答:可从内存屏障、volatile 语义和锁升级角度深入对比。
8. remove 为何要移动元素并末尾置 null?
标准回答 :移动元素是为了保持数组连续性,保证索引 O(1) 访问;末尾置 null 是为了断开引用,让 GC 能够回收被删对象,防止内存泄漏。
追问 1 :如果不置 null 会怎样?
回答:若列表长期持有该引用,即使业务上已删除,对象仍可达,造成逻辑上的内存泄漏。
追问 2 :clear() 方法里也是类似逻辑吗?
回答:clear() 会遍历所有位置置 null,但容量维持不变。
加分回答:可以联系 Effective Java 中"消除过期的对象引用"条目。
9. System.arraycopy 和 Arrays.copyOf 在源码中的使用区别?
标准回答 :System.arraycopy 是 native 方法,需要自行管理目标数组的创建;Arrays.copyOf 内部调用 System.arraycopy,但自动创建新数组并返回,常用于扩容。在 ArrayList 中,grow 用 Arrays.copyOf,而 add(index)、remove 使用 System.arraycopy 在同一个数组内部移位。
追问 1 :为什么不统一使用一个?
回答:Arrays.copyOf 每次新建数组,适用于扩容;内部移位用 arraycopy 避免产生新数组对象。
追问 2 :System.arraycopy 支持重叠复制吗?
回答:是的,其实现保证了源与目标区域重叠时的正确性。
加分回答 :提及 Arrays.copyOf 的类型安全转换特性,如 copyOf(U[], int, Class<? extends T[]>)。
10. 构造器为什么要用延迟分配(DEFAULTCAPACITY_EMPTY_ELEMENTDATA)?
标准回答 :避免空 ArrayList 实例立刻占用至少 10 个引用大小的内存。大量空列表在延迟分配下共享同一个空数组常量,有效节省堆空间。首次添加才分配,实现懒初始化。
追问 1 :为什么使用两个不同的空数组常量?
回答:区分无参构造和显式 0 容量,使无参构造能实现首次扩容至 10 的约定,而后者严格按 1.5 倍扩容。
追问 2 :这种设计会有性能代价吗?
回答:每次添加都需要检查是否 == DEFAULTCAPACITY_EMPTY_ELEMENTDATA,但只是一次引用比较,开销极小。
加分回答:可以提到这种设计在 Java 7/8 后引入,早期版本无参构造直接分配 10 长度数组,改进后显著减少内存占用。
模块 17:性能总结与选型建议
核心操作时间复杂度
| 操作 | 时间复杂度 | 备注 |
|---|---|---|
| add(E) 尾部 | 均摊 O(1) | 扩容时 O(n) 但均摊 O(1) |
| add(int, E) | O(n) | 需要移动 n/2 个元素平均 |
| get(int) | O(1) | 纯数组寻址 |
| set(int, E) | O(1) | 同上 |
| remove(int) | O(n) | 移动元素 |
| remove(Object) | O(n) | 查找 O(n) + 移动 O(n) |
| contains / indexOf | O(n) | 线性扫描 |
| iteration | O(n) | 顺序访问,缓存友好 |
| sort | O(n log n) | TimSort,可能附带临时数组 |
缓存友好性
ArrayList 的连续内存布局使得每次迭代都能充分利用 CPU 缓存行,相比链表结构在大数据量遍历场景优势显著。
选型建议
- 必须使用 ArrayList:需要频繁索引随机访问、尾部追加为主、一次性遍历处理,且能预估容量避免频繁扩容。
- 果断放弃 ArrayList :频繁头部或中部插入删除成为性能瓶颈,或需要双向队列操作时应使用
LinkedList或ArrayDeque;高并发读写环境需换成CopyOnWriteArrayList或并发队列。 - 内存敏感场景 :列表不再增长后调用
trimToSize()释放预留空间,或用new ArrayList<>(0)避免默认 10 的首次扩容。