ArrayList 整体认识

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 的解法非常优雅------它没有抛弃数组,而是保留数组作为底层存储,在数组之上封装一层"自动扩容"逻辑

  1. 内部维护一个 Object[] elementData 数组,真正存放数据;

  2. 每次添加元素前,先检查 elementData 是否还有空位;

  3. 有空位,直接存入;没空位,创建一个更大的新数组(JDK 8 默认扩到原来的 1.5 倍),把旧数组数据拷贝过去,让 elementData 指向新数组;

  4. 旧数组失去引用,等待 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 本章小结

本章三个关键结论:

  1. ArrayList 本质是数组的增强版:保留数组的随机访问与缓存亲和优势,用扩容机制解决定长问题。

  2. 底层选数组是权衡的结果:"查多改少"是最普遍的访问模式,数组 O(1) 查询 + 缓存亲和的收益,远大于偶尔扩容的拷贝成本。

  3. 动态数组思想:扩容的本质是"新数组替换旧数组",变的是引用指向,不是内存区域本身。

下一章,我们进入 ArrayList 源码内部,剖析 elementDatasize 等核心成员变量:为什么用 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],但这会破坏类型安全------你随时可以在这个数组里放 IntegerBoolean 等等。所以 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 序列化流程是这样的:

  1. 调用 writeExternal()writeObject()

  2. 循环写入实际有效的 size 个元素;

  3. 反序列化时读回 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 时再决定到底是多少"

为什么要有第二个空数组?因为我们需要区分两种情况:

  1. 用户显式创建了 new ArrayList(0)------明确表示"我就要空列表";

  2. 用户写了 new ArrayList()------表示"我懒得管,你按默认来"。

前者对应 EMPTY_ELEMENTDATA(永远不变,除非你 add);后者对应 DEFAULTCAPACITY_EMPTY_ELEMENTDATA(第一次 add 时扩到 10)。

设计意图:用最小的内存占用,换取最大的灵活性。不用的时候不占空间,用的时候才按需分配。这就是延迟初始化思想的体现(第三章详细拆解)。


第二章小结

通过本章分析,我们得出以下关键结论:

  1. Object[] 而非 E[] 是妥协的结果:Java 不允许创建泛型数组,加上反射可绕过的安全问题,只能用 Object[] + 强制转换 的组合拳。

  2. transient 是为了序列化性能优化:只序列化有效元素,避免浪费 IO。

  3. size 与 capacity 是两个独立概念:容量可以远超实际需求,提前规划能避免扩容开销。

  4. 空数组占位符的区分是为了支持延迟初始化DEFAULTCAPACITY_EMPTY_ELEMENTDATA 用于标记"待初始化"状态,EMPTY_ELEMENTDATA 用于标记"已明确为空"状态。

下一篇进入初始化源码的深入剖析,重点讲解 JDK 8 延迟初始化背后的权衡。下期见~

相关推荐
水无痕simon1 小时前
15 转账对接文档
java·区块链
西凉的悲伤1 小时前
Java 基于 MySQL 行锁实现高并发业务序列号生成器
java·mysql·springboot·序列号生成
lv__pf1 小时前
servlet与jsp
java·开发语言·servlet
wabs6661 小时前
关于哈希表【力扣1.两数之和的思考】
数据结构·算法·leetcode·散列表·图论
xieliyu.1 小时前
Java 文件IO :File 文件类、字节流、字符流讲解
java·ide·笔记·javaee
Hhy_11071 小时前
【从零开始学数据结构 ⑥】:二叉树之堆——打破规则的优先队列
c语言·开发语言·数据结构·学习·visual studio
云烟成雨TD1 小时前
Micrometer 系列【28】Spring Boot Actuator | 其他指标速览
java·spring boot·云原生
meilindehuzi_a1 小时前
深入理解 Linux 进程地址空间:从虚拟内存、页表映射到 fork 写时拷贝与 VMA 管理
java·linux·服务器
莫得感情 o2 小时前
设计模式 13 · 桥接模式
java·设计模式·桥接模式