ArrayList 源码解析:动态数组 + 扩容机制,为什么查快插慢?
哈喽,各位程序员伙伴~ 👋
上一篇我们拆解了 LinkedList 的双向链表结构,搞懂了它「头尾增删快、查询慢」的核心原因,也厘清了它和 ArrayList 的场景适配差异。而作为 Java 集合中使用频率最高的实现类,ArrayList 几乎是后端开发的「必修课」—— 日常开发的列表查询、数据展示,九成以上的场景都会用到它。
但你真的懂 ArrayList 吗?
-
它的「动态数组」到底怎么实现的?为什么能实现下标随机访问? -
扩容机制的底层逻辑是什么?每次扩容会带来哪些性能开销? -
为什么同样是 O (n),ArrayList 中间增删比 LinkedList 更慢? -
看似万能的 ArrayList,藏着哪些容易踩坑的性能陷阱?
这篇文章,我们延续「痛点开篇 + 通俗类比 + 源码精读 + 实战避坑」的风格,从底层结构到核心操作,从扩容机制到实战选型,彻底拆解 ArrayList(基于 JDK8 源码),搞懂它「查快插慢」的本质,以及那些被忽略的性能优化点和高频坑点。
一、开篇痛点:为什么 ArrayList 是「默认首选」,却也最容易用错?
ArrayList 能成为集合框架的「顶流」,核心原因是契合绝大多数业务场景—— 我们日常开发中,「查询多、增删少、尾部增删为主」的场景占比超 90%,而这正是 ArrayList 的「主场」。
但看似简单的 ArrayList,却藏着很多容易被忽略的问题:
-
新手无脑用 add(int index, E element)做中间插入,导致系统性能瓶颈; -
不知道 ArrayList 初始化容量的重要性,频繁扩容触发数组拷贝,浪费内存和 CPU; -
误以为「ArrayList 增删都是 O (n),随便用」,却忽略了扩容和元素移动的隐性开销; -
用 ArrayList 存储百万级数据,却没发现底层数组的冗余空间导致的内存浪费。
先看一组同场景性能对比数据(基于 JDK8,10 万次操作,数据量 10 万),直观感受 ArrayList 的优势与短板:
|
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
核心结论:ArrayList 的「快」是有边界的,它的优势集中在随机查询、尾部增删、连续遍历,而中间 / 头部增删是天生短板 —— 这一切的根源,都在于它的底层结构:动态数组。
二、核心结构:动态数组的底层实现(源码拆解 + 通俗类比)
ArrayList 的本质是对数组的封装与增强—— 普通数组是「固定长度」的,一旦创建无法扩容,而 ArrayList 实现了「动态扩容」,让数组能根据元素数量自动调整长度,这也是它和普通数组的核心区别。
1. 通俗类比:可弹性扩容的「储物柜」
把 ArrayList 想象成一组带编号的「储物柜」:
-
每个储物柜有唯一编号(对应数组下标),从 0 开始依次递增,支持直接按编号找柜子(随机访问); -
储物柜的总数量就是「数组容量」,初始时会规划一定数量的柜子(初始容量); -
当储物柜被放满时,管理员会找一个更大的空间,把所有物品从旧柜子搬到新的更大的储物柜组(扩容 + 数组拷贝),这个过程会有额外开销; -
往储物柜中间放 / 拿物品时,需要把后面的物品全部往后 / 往前挪一位(中间增删的元素移动),物品越多,挪动越耗时。
这个类比完美对应 ArrayList 的核心特性:下标随机访问(快)、扩容拷贝(隐性开销)、中间增删元素移动(慢)。
2. 源码精读:ArrayList 的核心属性与底层数组
基于 JDK8 源码,ArrayList 的核心属性仅有几个,却决定了它的所有核心行为,我们逐行拆解:
public class ArrayList<E> extends AbstractList<E>implements List<E>, RandomAccess, Cloneable, java.io.Serializable {// 序列化IDprivate static final long serialVersionUID = 8683452581122892189L;// 默认初始容量:创建无参构造的ArrayList时,初始底层数组为EMPTY_ELEMENTDATA,首次添加元素时扩容为10private static final int DEFAULT_CAPACITY = 10;// 空数组常量:无参构造时的初始数组,区别于DEFAULTCAPACITY_EMPTY_ELEMENTDATA,用于区分首次扩容逻辑private static final Object[] EMPTY_ELEMENTDATA = {};// 默认空数组常量:带初始容量为0的构造方法时使用private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};// 核心:ArrayList的底层存储容器——真正存储元素的数组,transient表示不参与序列化transient Object[] elementData;// 实际存储的元素个数,注意:size ≤ 数组容量(elementData.length)private int size;// 带初始容量的构造方法:指定初始数组大小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);}}// 无参构造方法:初始数组为DEFAULTCAPACITY_EMPTY_ELEMENTDATA,首次add时扩容为10public ArrayList() {this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;}}
关键核心结论(必须吃透)
- 底层核心是 Object 数组
elementData是真正存储元素的容器,所有增删查操作最终都是对这个数组的操作; - size ≠ 数组容量
size是实际元素个数,elementData.length是数组总容量,二者的差值就是「数组冗余空间」; - 默认初始容量的坑
无参构造的 ArrayList,初始数组是空的,并非直接创建容量为 10 的数组,而是首次调用 add 方法时才会扩容为 10(懒加载优化); - 标记接口的意义
实现 RandomAccess接口 —— 这是一个标记接口,没有任何方法,仅表示该集合「支持快速随机访问」,遍历工具类(如 Collections)会根据该接口选择最优遍历方式(下标遍历 / 迭代器遍历)。
三、核心操作:为什么查快插慢?(源码 + 时间复杂度分析)
ArrayList 所有核心操作的性能差异,都源于数组的固有特性:支持下标随机访问(O (1)),但增删需要移动元素(O (n))。我们拆解最常用的「查、增、删」操作,结合源码讲清原理和性能。
1. 查询操作:O (1) 快到极致,随机访问的天花板
ArrayList 的查询是所有线性集合中效率最高的,核心原因是底层数组的下标随机访问特性—— 通过下标可以直接定位到数组的内存地址,无需任何遍历,时间复杂度稳定 O (1)。
核心查询方法 get(int index) 源码(JDK8 精简版):
public E get(int index) {rangeCheck(index); // 校验下标合法性:index < size,否则抛IndexOutOfBoundsExceptionreturn elementData(index); // 直接通过下标获取数组元素}// 私有方法:直接返回数组指定下标元素,真正的O(1)操作@SuppressWarnings("unchecked")E elementData(int index) {return (E) elementData[index];}// 下标校验:仅校验是否越界,不校验是否为负数(负数会直接触发数组本身的ArrayIndexOutOfBoundsException)private void rangeCheck(int index) {if (index >= size)throw new IndexOutOfBoundsException(outOfBoundsMsg(index));}
关键细节
-
校验仅判断 index >= size,因为数组本身会对负数下标抛异常,无需额外处理,减少性能开销; -
无任何遍历、循环操作,直接通过「数组名 [下标]」获取元素,这是最底层的内存访问方式,效率拉满; -
实现 RandomAccess标记接口后,工具类会优先选择下标遍历,而非迭代器遍历,进一步提升效率。
对比 LinkedList:LinkedList 的 get(int index) 需遍历链表(即使优化了头尾遍历),时间复杂度 O (n),在大数据量下,查询效率和 ArrayList 天差地别。
2. 插入操作:尾部 O (1) 真香,中间 / 头部 O (n) 拉胯
ArrayList 的插入操作分尾部插入和指定下标插入,二者的性能天差地别,核心差异在于「是否需要移动元素」。
(1)尾部插入:add (E e)(理想情况 O (1),扩容时 O (n))
这是 ArrayList 最常用、性能最好的插入方式,源码逻辑(精简版):
public boolean add(E e) {ensureCapacityInternal(size + 1); // 确保数组容量足够,不足则扩容elementData[size++] = e; // 直接将元素放入数组尾部的空闲位置,O(1)return true;}
核心两步:
- 容量校验
调用 ensureCapacityInternal检查数组是否有空闲空间,若size+1 > elementData.length,则触发扩容机制; - 元素赋值
直接将元素赋值给 elementData[size],然后size++,无任何元素移动,纯 O (1) 操作。
隐性开销:如果触发扩容,会执行「新数组创建 + 旧数组元素拷贝」,这个过程是 O (n) 时间复杂度,这也是为什么建议提前指定 ArrayList 初始容量的核心原因。
(2)指定下标插入:add (int index, E element)(固定 O (n))
这是 ArrayList 的性能短板,也是最容易用错的方法,源码逻辑(精简版):
public void add(int index, E element) {rangeCheckForAdd(index); // 校验下标合法性:0 ≤ index ≤ sizeensureCapacityInternal(size + 1); // 容量校验,不足则扩容// 核心开销:数组拷贝,移动元素——将index后的所有元素往后挪一位System.arraycopy(elementData, index, elementData, index + 1, size - index);elementData[index] = element; // 插入新元素size++; // 元素个数+1}
性能核心:System.arraycopy 是本地方法(native),虽然效率比手动循环高,但本质还是移动元素—— 需要把下标 index 后的 size-index 个元素全部往后移动一位,腾出位置给新元素。
-
头部插入(index=0):需要移动全部 size 个元素,是最耗时的插入方式,时间复杂度 O (n); -
中间插入(index=size/2):需要移动一半元素,时间复杂度仍为 O (n); -
尾部插入(index=size):等价于 add(E e),无需移动元素,O (1)。
对比 LinkedList:二者中间插入均为 O (n),但 ArrayList 是「移动元素」,LinkedList 是「遍历找节点」,在大数据量下,ArrayList 中间插入的耗时通常更高(元素移动的开销 > 链表遍历的开销)。
3. 删除操作:和插入同理,中间 / 头部删除必移动元素(O (n))
删除操作的性能逻辑和插入完全对称,核心开销仍是元素移动,源码以 remove(int index) 为例(精简版):
public E remove(int index) {rangeCheck(index); // 下标校验modCount++; // 结构修改次数,用于迭代器并发修改校验E oldValue = elementData(index); // 获取待删除元素// 计算需要移动的元素个数:index后的元素个数int numMoved = size - index - 1;if (numMoved > 0)// 核心开销:将index后的元素往前挪一位,覆盖待删除元素System.arraycopy(elementData, index+1, elementData, index, numMoved);// 置空最后一个元素,让GC回收,避免内存泄漏elementData[--size] = null;return oldValue;}
关键细节
- 元素移动
numMoved > 0表示待删除元素不是最后一个,需要将后续元素往前移动一位,覆盖待删除元素,时间复杂度 O (n); - 内存优化
删除后会将 elementData[--size]置为null,让垃圾回收器能回收该对象,避免「数组持有对象引用导致的内存泄漏」; - 快速失败
修改 modCount,如果迭代器遍历过程中发生删除操作,会触发ConcurrentModificationException,避免并发修改导致的数据错乱。
核心结论:ArrayList 的删除操作,只有尾部删除是真正的 O (1),其余位置的删除都需要移动元素,耗时随数据量增加而飙升。
四、核心机制:ArrayList 的扩容逻辑(源码精读,避坑关键)
扩容机制是 ArrayList 的核心灵魂,也是最容易被忽略的性能点 —— 频繁扩容会导致大量的数组拷贝和内存申请,严重影响性能。理解扩容逻辑,才能从根源上避免相关坑点。
1. 扩容的核心触发条件
当执行 add 系列方法时,会先调用 ensureCapacityInternal(size + 1) 校验容量,触发扩容的唯一条件:
size + 1 > elementData.length
即「添加新元素后,实际元素个数会超过数组当前容量」,此时必须扩容,否则会触发数组下标越界异常。
2. 完整扩容流程(源码拆解,从触发到完成)
我们以无参构造的 ArrayList 首次添加元素为例,拆解从容量校验到扩容完成的完整流程,核心方法调用链:
add(E e) →
ensureCapacityInternal(int minCapacity) →
calculateCapacity(Object[] elementData, int minCapacity) →
ensureExplicitCapacity(int minCapacity) →
grow(int minCapacity)(真正的扩容方法)
步骤 1:计算最小需要的容量(calculateCapacity)
核心是处理「无参构造的空数组首次扩容」的特殊逻辑,源码:
private static int calculateCapacity(Object[] elementData, int minCapacity) {// 若数组是无参构造的默认空数组,最小容量取DEFAULT_CAPACITY(10)和minCapacity的最大值if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {return Math.max(DEFAULT_CAPACITY, minCapacity);}return minCapacity;}
关键:无参构造的 ArrayList,首次添加元素时,minCapacity 是 1,会被修正为 10,这就是「默认初始容量为 10」的底层逻辑。
步骤 2:判断是否需要扩容(ensureExplicitCapacity)
privatevoidensureExplicitCapacity(int minCapacity) {modCount++; // 结构修改次数+1// 最小需要的容量 > 数组当前容量 → 触发扩容if (minCapacity - elementData.length > 0)grow(minCapacity);}
步骤 3:真正的扩容实现(grow)—— ArrayList 扩容的核心
这是扩容的最终执行方法,包含新容量计算和数组拷贝两个核心步骤,源码(JDK8 精简版):
privatevoidgrow(int minCapacity) {// 旧容量:数组当前的容量int oldCapacity = elementData.length;// 核心:新容量 = 旧容量 + 旧容量/2 → 即扩容1.5倍(位运算更高效,oldCapacity >> 1 等价于 oldCapacity/2)int newCapacity = oldCapacity + (oldCapacity >> 1);// 处理特殊情况:新容量 < 最小需要的容量(如旧容量为0时)if (newCapacity - minCapacity < 0)newCapacity = minCapacity;// 处理超大容量:新容量超过Integer.MAX_VALUE - 8,则扩容为Integer.MAX_VALUEif (newCapacity - MAX_ARRAY_SIZE > 0)newCapacity = hugeCapacity(minCapacity);// 核心:数组拷贝——创建新容量的数组,将旧数组元素拷贝到新数组elementData = Arrays.copyOf(elementData, newCapacity);}// 处理超大容量的情况,避免数组容量超过JVM的限制privatestaticinthugeCapacity(int minCapacity) {if (minCapacity < 0) // 溢出throw new OutOfMemoryError();return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;}
扩容核心结论(必须记住)
- 默认扩容倍数
1.5 倍(通过位运算 oldCapacity >> 1实现,比直接除法更高效); - 首次扩容特殊
无参构造的 ArrayList,首次添加元素时,空数组会直接扩容为容量 10; - 扩容本质
创建新的更大容量的数组,并通过 Arrays.copyOf完成旧数组元素到新数组的拷贝,这个过程是O (n) 时间复杂度,且会申请新的内存空间; - 超大容量限制
数组最大容量为 Integer.MAX_VALUE(约 21 亿),避免超出 JVM 的内存管理限制。
3. 手动扩容优化:ensureCapacity (int minCapacity)
既然自动扩容有 O (n) 开销,那么在已知元素数量的场景下,我们可以手动调用 ensureCapacity 方法,提前扩容到指定容量,避免多次自动扩容的开销:
// 手动扩容方法:直接指定数组的最小容量publicvoidensureCapacity(int minCapacity) {int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)? 0: DEFAULT_CAPACITY;if (minCapacity > minExpand) {ensureExplicitCapacity(minCapacity);}}
使用场景:比如要往 ArrayList 中添加 10000 个元素,无参构造的 ArrayList 会经历多次自动扩容(10→15→22→33→…→10000+),每次都有数组拷贝开销;而提前调用 list.ensureCapacity(10000),仅需一次扩容,大幅提升性能。
五、实战避坑:ArrayList 的 5 个高频错误用法(90% 的人踩过)
基于源码和底层原理,我们梳理了 ArrayList 最容易踩的 5 个坑,每个坑都给出错误写法和正确解决方案,从根源上避坑。
坑点 1:无参构造后存储大量数据,导致频繁自动扩容
错误场景:创建无参构造的 ArrayList,直接添加 10 万 + 元素,触发多次自动扩容(10→15→22→…),每次都做数组拷贝,性能极差。错误写法:
// 错误:无参构造,频繁自动扩容List<String> list = new ArrayList<>();for (int i = 0; i < 100000; i++) {list.add("data" + i);}
正确解决方案:提前指定初始容量,匹配实际元素数量,避免多次扩容:
// 正确:指定初始容量,仅一次扩容(若需要)List<String> list = new ArrayList<>(100000);for (int i = 0; i < 100000; i++) {list.add("data" + i);}
进阶优化:如果元素数量不确定,可指定「预估容量 + 20%」,预留一定的冗余空间,避免少量新增元素再次触发扩容。
坑点 2:频繁做中间 / 头部增删操作,忽略元素移动开销
错误场景:用 ArrayList 实现「消息队列」「栈」等需要高频头尾 / 中间增删的场景,导致大量元素移动,性能瓶颈。错误写法:
// 错误:ArrayList 头部插入,每次移动全部元素List<String> msgQueue = new ArrayList<>();// 高频头部插入新消息msgQueue.add(0, "新消息1");msgQueue.add(0, "新消息2");
正确解决方案:根据场景选择合适的集合:
-
高频头尾增删:用 LinkedList(O (1))或 ArrayDeque(底层数组,头尾操作 O (1),效率比 LinkedList 更高); -
必须用 ArrayList:尽量将增删操作放在尾部,避免中间 / 头部操作。
坑点 3:遍历中直接调用 remove/add 方法,触发并发修改异常
错误场景:用普通 for 循环 / 增强 for 循环遍历 ArrayList 时,直接调用 remove/add 方法,修改 modCount 导致迭代器校验失败,抛出 ConcurrentModificationException。错误写法:
// 错误:增强 for 循环遍历中删除元素,触发并发修改异常List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "d"));for (String s : list) {if ("b".equals(s)) {list.remove(s);}}
正确解决方案:
- 用迭代器遍历并操作
(推荐):迭代器的 remove/add方法会同步更新modCount和expectedModCount,避免异常;
// 正确:迭代器遍历中安全删除ListIterator<String> iterator = list.listIterator();while (iterator.hasNext()) {String s = iterator.next();if ("b".equals(s)) {iterator.remove();}}
removeIf 方法,底层是迭代器实现,安全高效。坑点 4:误以为 ArrayList 是线程安全的,多线程环境下直接使用
错误场景:多线程同时对 ArrayList 进行增删查操作,导致数据错乱、数组下标越界、空指针异常——ArrayList 是非线程安全的,底层数组和 size 均无同步机制。错误写法:
// 错误:多线程同时操作 ArrayList,存在线程安全问题List<String> list = new ArrayList<>();new Thread(() -> {for (int i = 0; i < 1000; i++) {list.add("thread1-" + i);}}).start();new Thread(() -> {for (int i = 0; i < 1000; i++) {list.add("thread2-" + i);}}).start();
正确解决方案:
- 用线程安全集合
Collections.synchronizedList(new ArrayList<>())(通过同步锁实现,简单但性能一般); - 高并发场景
用 CopyOnWriteArrayList(写时复制,读多写少场景性能极佳,底层也是动态数组); - 手动加锁
在增删查操作外层加 synchronized锁,适合自定义同步逻辑的场景。
坑点 5:用 ArrayList 存储超大对象,忽略冗余空间的内存浪费
错误场景:用 ArrayList 存储百万级超大对象(如每个对象占 1MB),由于 ArrayList 扩容为 1.5 倍,会产生大量的冗余空间(比如实际存储 100 万元素,数组容量 150 万,冗余 50 万位置),导致堆内存严重浪费。正确解决方案:
- 手动缩容
调用 trimToSize()方法,将数组容量缩为实际元素个数size,释放冗余内存:list.trimToSize(); // 数组容量 = size,无冗余空间 -
选择合适的集合
LinkedList(按需创建节点,无提前内存申请)。六、对比总结:ArrayList 与 LinkedList 终极选型指南
上一篇我们详细对比了二者的差异,这里结合 ArrayList 的源码细节,给出更精准、更落地的终极选型指南,附场景化选型建议,告别无脑选择。
1. 核心差异终极对比表(补充扩容、线程安全等细节)
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2. 场景化终极选型口诀 + 建议
核心选型口诀
查多增删少,尾部操作多 → 选ArrayList头尾增删多,中间操作少 → 选LinkedList
精细化场景建议
-
优先选 ArrayList 的场景(90% 的业务场景):
-
数据展示、列表查询、分页加载(高频随机查询); -
仅尾部增删的场景(如日志收集、数据缓存); -
已知元素数量,可提前指定初始容量(避免扩容); -
需要高效遍历的场景(下标遍历效率拉满)。 -
必须选 LinkedList 的场景:
-
实现栈、队列、双端队列(Deque)(高频头尾增删); -
中间增删操作频繁,且数据量较大(元素移动开销 > 链表遍历开销); -
内存敏感场景,避免数组冗余空间(但需接受指针的额外开销)。 -
进阶替代方案:
-
高并发读多写少 → 用 CopyOnWriteArrayList(ArrayList 线程安全版); -
高频头尾增删,追求更高效率 → 用 ArrayDeque(底层循环数组,头尾操作 O (1),比 LinkedList 更快); -
固定长度数据存储 → 用普通数组(无扩容、无冗余、效率最高); -
海量数据存储 → 用 Vector(不推荐,性能差)或第三方集合(如 Guava 的 ImmutableList)。
七、写在最后:理解底层,才能用好工具
ArrayList 看似简单,实则是 Java 集合框架中最能体现数据结构思想的实现类 —— 它的所有特性,都源于「数组」这个基础数据结构的固有属性:
-
数组的下标随机访问,造就了 ArrayList 的查询优势; -
数组的固定长度,催生了动态扩容机制,也带来了拷贝开销; -
数组的连续内存存储,让中间增删必须移动元素,成为天生短板。
而 LinkedList 则是「链表」数据结构的完美落地,二者的差异,本质是数组与链表的经典对决。
作为开发者,我们无需死记硬背「谁快谁慢」,更重要的是理解底层数据结构的特性,根据业务场景选择合适的工具 —— 这也是我们拆解源码的核心目的:知其然,更知其所以然,让每一次技术选择都有底层逻辑支撑。
下一篇预告
这篇我们拆解了 ArrayList 的动态数组结构、扩容机制和实战坑点,搞懂了它「查快插慢」的本质。下一篇,我们将进入Java 并发集合的领域,拆解 《CopyOnWriteArrayList 源码解析:写时复制,如何实现线程安全且高效读?》—— 彻底搞懂写时复制的核心原理,以及它在高并发场景下的应用。
评论区聊聊:你在项目中踩过 ArrayList 的哪些坑?是怎么解决的? 👇点赞 + 收藏,关注我们,后续干货不错过!
夜雨聆风
