ArrayList 源码剖析
基于 JDK 8 源码,部分内容在网络上搜集而来
第一章:ArrayList 整体认识
1.1 ArrayList 是什么
ArrayList 是 Java 集合框架中基于 List 接口的动态数组实现,位于 java.util 包,完整声明如下:
java
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
这一行声明里,已经藏着大量设计信息:
-
继承 AbstractList :复用 List 接口的骨架代码(模板方法模式)。ArrayList 只需实现
get()、set()、size()等核心操作,其余如迭代器、子视图等通用逻辑由 AbstractList 基于这些基础操作自动实现。 -
实现 List:声明自己是"有序、可重复、可按索引访问"的集合。
-
实现 RandomAccess :这是一个没有任何方法的标记接口(Marker Interface)。它的作用只是告诉调用方:"我支持快速随机访问,遍历我时请优先用 for 循环 + 下标,而不是迭代器。"
Collections.binarySearch()内部正是通过这个接口判断该用索引遍历还是迭代器遍历的。 -
实现 Cloneable 和 Serializable :支持浅拷贝与自定义序列化。为什么
elementData要加transient却还能正常序列化?这正是 ArrayList 序列化设计的精妙之处,第二章详解。
剥开接口声明,ArrayList 的本质一句话概括:它是一个"增强版的数组"。
1.2 从数组的困境说起:为什么需要动态数组
要理解 ArrayList 为什么存在,必须先回到最基础的数据结构------数组。
数组有两个其他结构无法比拟的硬件级优势:
优势一:O(1) 随机访问。 数组元素在内存中连续存放,CPU 通过一次地址运算即可定位任意元素:
目标地址 = 数组首地址 + 索引 × 单个元素大小
注意,这是"算出来"而不是"找出来"------无论数组有多大,定位第 n 个元素永远是一次乘法加一次加法。这就是数组随机访问 O(1) 的来源。而链表必须从头节点开始逐个指针跳转,这就是 O(n) 的来源。
优势二:缓存亲和性(Cache Locality)。 现代 CPU 以缓存行(Cache Line,通常 64 字节)为单位读取内存。由于数组内存连续,加载一个元素时,后面若干个元素会被"顺带"装入缓存(空间局部性原理)。实际遍历数组时 CPU 缓存命中率极高,性能往往比逻辑复杂度同为 O(n) 的链表高出一个数量级。
但数组有一个致命问题:长度固定。
Java 数组在 JVM 分配内存后,长度即记录在对象头中,不可更改。于是我们写代码时永远面临两难:
// 数组创建时刻的灵魂拷问:到底开多长?
String[] arr = new String[???];
开小了,存不下,直接 ArrayIndexOutOfBoundsException;开大了,浪费内存,大数组还会增加 GC 压力。
矛盾的根源在于:容量必须在分配时确定,而实际数据量往往运行时才知道。这是数组的根本性缺陷。
1.3 动态数组思想:ArrayList 的解法
ArrayList 的解法非常优雅------它没有抛弃数组,而是保留数组作为底层存储,在数组之上封装一层"自动扩容"逻辑:
-
内部维护一个
Object[] elementData数组,真正存放数据; -
每次添加元素前,先检查
elementData是否还有空位; -
有空位,直接存入;没空位,创建一个更大的新数组(JDK 8 默认扩到原来的 1.5 倍),把旧数组数据拷贝过去,让
elementData指向新数组; -
旧数组失去引用,等待 GC 回收。
对调用方而言,这个容器"要多少装多少",完全不需要关心容量------这就是动态数组思想:静态数组 + 扩容机制 = 动态容量的错觉。
这里要特别注意"动态"二字的含义:扩容时改变的是内部数组引用的指向,而不是把原来那块内存"拉长"。底层数组依然是连续的、定长的,只是不够用了就换一块更大的。这个设计的精妙之处在于:数组的随机访问性能优势被 100% 保留,付出的代价只是偶尔一次扩容拷贝------而扩容频率随容量增大而指数级降低,均摊下来每次 add 的额外成本几乎可以忽略。
1.4 为什么 Java 需要 ArrayList
如果没有 ArrayList,每个开发者都得手写这样的样板代码:
java
// 手写版"动态数组",感受一下没有 ArrayList 的世界
Object[] arr = new Object[10];
int size = 0;
void add(Object e) {
if (size == arr.length) { // 装满了
Object[] newArr = new Object[arr.length + arr.length / 2];
System.arraycopy(arr, 0, newArr, 0, size); // 数据迁移
arr = newArr; // 切换引用
}
arr[size++] = e;
}
容量判断、扩容策略、数据拷贝、计数维护------这套逻辑完全通用,与业务无关。每个项目都重写一遍,既容易写错,也无从优化。Java 把它封装成 ArrayList 放进标准库,这正是集合框架的基本哲学:把重复的通用逻辑沉淀为经过千锤百炼的可复用组件。
而且 ArrayList 处理了许多你未必想得到、但工程上至关重要的细节:边界检查、序列化性能优化、fail-fast 迭代器、泛型类型安全......标准库组件存在的意义不只是"能用",而是"正确地用、高效地用、安全地用"。
1.5 ArrayList 与普通数组的区别
| 对比维度 | 普通数组 | ArrayList |
|---|---|---|
| 长度 | 固定,创建后不可变 | 动态,不足时自动扩容 |
| 元素类型 | 基本类型、引用类型均可 | 只能存引用类型(泛型不支持基本类型) |
| 元素计数 | 开发者自行维护 | 内部 size 字段自动维护 |
| 越界行为 | ArrayIndexOutOfBoundsException | IndexOutOfBoundsException,且检查时机更完整 |
| 类型安全 | Object\[\] 可混装任意类型 | 泛型编译期检查 |
| 适用场景 | 长度已知、极致性能场景 | 数据量未知、绝大多数通用业务场景 |
单独点出"元素类型"这一行,是因为 ArrayList 不能存 int,只能存 Integer------这是泛型擦除机制决定的,也是面试高频追问点,后文序列化部分会展开。
1.6 本章小结
本章三个关键结论:
-
ArrayList 本质是数组的增强版:保留数组的随机访问与缓存亲和优势,用扩容机制解决定长问题。
-
底层选数组是权衡的结果:"查多改少"是最普遍的访问模式,数组 O(1) 查询 + 缓存亲和的收益,远大于偶尔扩容的拷贝成本。
-
动态数组思想:扩容的本质是"新数组替换旧数组",变的是引用指向,不是内存区域本身。
下一章,我们进入 ArrayList 源码内部,剖析 elementData、size 等核心成员变量:为什么用 Object 数组而不是泛型数组?为什么 elementData 要加 transient?容量和元素数量到底有什么区别?
第二章:ArrayList 底层数据结构
深入分析 ArrayList 源码,首先看它的核心成员变量:
java
transient Object[] elementData; // 私有
private int size; // 私有
还有几个重要的静态常量:
java
static final int DEFAULT_CAPACITY = 10; // 默认容量
static final Object[] EMPTY_ELEMENTDATA = {}; // 空的共享实例
static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; // "未初始化"占位符
2.1 为什么使用 Object 数组而不是泛型数组
ArrayList 内部使用的不是 E[] elementData,而是 Object[] elementData。这看起来很奇怪,因为 Java 支持泛型,那为什么不直接用泛型数组呢?
原因有二:
原因一:Java 不允许创建泛型数组
java
// 编译错误!
List<String>[] listArray = new List<String>[10];
编译器拒绝的原因很简单:泛型是编译期的伪装的,运行时被擦除掉了 。new List<String>[10] 在运行时实际上等同于 new List[10],但这会破坏类型安全------你随时可以在这个数组里放 Integer、Boolean 等等。所以 Java 干脆禁止了泛型数组的创建。
既然不能创建 E[],就只能退而求其次使用 Object[]。ArrayList 内部通过类型转换 return (E) elementData[index] 把元素转回来,这种转换之所以能正常工作,依赖的是泛型的桥接方法 (bridge method)和协变返回类型的配合。
原因二:反射可以绕过泛型检查
即使允许创建 E[],反射也能突破限制。假设我们写成 E[] elementData,那么通过反射我们可以:
java
ArrayList<String> list = new ArrayList<>();
Field field = ArrayList.class.getDeclaredField("elementData");
field.setAccessible(true);
Object[] array = (Object[]) field.get(list);
array[0] = new Object(); // 硬塞了一个 Object!
String s = list.get(0); // ClassCastException 发生在这里
换成 Object[] elementData 反而更安全------因为 Object[] 本来就什么都能存,不会给你"误以为是泛型数组结果偷偷塞进不同类型"的心理陷阱。把风险显式化,比藏起来更安全。
2.2 transient 关键字为什么存在
elementData 被标记为 transient,意味着它在序列化时会被跳过。但 ArrayList 确实能被序列化啊!为什么还要加 transient?
答案是:为了性能优化。
ArrayList 序列化流程是这样的:
-
调用
writeExternal()或writeObject(); -
循环写入实际有效的
size个元素; -
反序列化时读回
size个元素重建elementData。
如果不加 transient,序列化的过程会变成:
-
自动把整个
elementData(包括 null 尾巴)全部序列化 → 浪费带宽和时间; -
反序列化时也要重建整个数组(含 null)。
有了 transient,ArrayList 可以手动控制序列化:只序列化有效元素,只重建需要的空间。这对大数组特别有意义。
举个例子:
java
ArrayList<String> list = new ArrayList<>(1000);
for (int i = 0; i < 10; i++) {
list.add("item-" + i);
}
此时 elementData.length == 1000,但 size == 10。如果不用 transient,序列化时会把这 1000 个位置的引用全写一遍(990 个是 null)------浪费 90% 的 IO 。加了 transient,只会序列化那 10 个有效元素。
2.3 size 代表什么,capacity 又是什么
这里有两个概念容易混淆:
-
size : 实际元素个数,即
list.size()返回的值; -
capacity : 内部数组长度,即
elementData.length。
它们的关系是:0 <= size <= capacity。
案例 1:初始化的瞬间
java
List<String> list = new ArrayList<>();
-
size = 0 -
elementData还没分配!JDK 8 采用延迟初始化策略(第三章详解)。
案例 2:加入第一个元素
java
list.add("a");
此时触发扩容:
-
size = 1 -
capacity = 10(首次扩容的默认值)。
案例 3:扩容后
java
list.add("b");
// ...
list.add("j"); // 第 10 个元素,size=10, capacity=10
list.add("k"); // 第 11 个元素,触发扩容,capacity 变成 15,size=11
工程影响:如果你在业务中明确知道要存多少数据,一定要提前指定容量:
java
List<User> users = new ArrayList<>(expectedCount);
否则频繁扩容带来的数组拷贝开销会让你付出真金白银的性能代价(第四章、第五章详述)。
2.4 三种空数组常量的设计意图
源码里有几个看起来很啰嗦的空数组:
java
static final Object[] EMPTY_ELEMENTDATA = {};
static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
abstract static boolean defaultCapacityElmptEmptyElementData = 0;
为什么要区分两个空数组?答案藏在延迟初始化的设计里。
JDK 7 的历史包袱
JDK 7 及以前,无参构造 new ArrayList<>() 会直接创建长度为 10 的数组:
java
public ArrayList() {
this.elementData = EMPTY_ELEMENTDATA; // 实际是长度为 10 的数组
}
这意味着:就算你从来不调用 add(),也白占了 10 个引用位的空间。
JDK 8 的优化方案
JDK 8 改成:
java
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
这个占位符的作用是:标记"我还未初始化,等你第一次 add 时再决定到底是多少"。
为什么要有第二个空数组?因为我们需要区分两种情况:
-
用户显式创建了
new ArrayList(0)------明确表示"我就要空列表"; -
用户写了
new ArrayList()------表示"我懒得管,你按默认来"。
前者对应 EMPTY_ELEMENTDATA(永远不变,除非你 add);后者对应 DEFAULTCAPACITY_EMPTY_ELEMENTDATA(第一次 add 时扩到 10)。
设计意图:用最小的内存占用,换取最大的灵活性。不用的时候不占空间,用的时候才按需分配。这就是延迟初始化思想的体现(第三章详细拆解)。
第二章小结
通过本章分析,我们得出以下关键结论:
-
用
Object[]而非E[]是妥协的结果:Java 不允许创建泛型数组,加上反射可绕过的安全问题,只能用Object[] + 强制转换的组合拳。 -
transient是为了序列化性能优化:只序列化有效元素,避免浪费 IO。 -
size 与 capacity 是两个独立概念:容量可以远超实际需求,提前规划能避免扩容开销。
-
空数组占位符的区分是为了支持延迟初始化 :
DEFAULTCAPACITY_EMPTY_ELEMENTDATA用于标记"待初始化"状态,EMPTY_ELEMENTDATA用于标记"已明确为空"状态。
下一篇进入初始化源码的深入剖析,重点讲解 JDK 8 延迟初始化背后的权衡。下期见~