☰
ArrayList底层解析:内存布局、扩容机制与实战性能优化
2026/9/30 11:39:04 网站建设 项目流程

ArrayList 是 Java 里出场率最高的集合类之一,几乎每个项目、每道面试题里都有它的身影。很多人用过new ArrayList<>()、list.add()、list.get(i),但真要问“底层数组什么时候扩容”“为什么默认容量是 10”“ensureCapacity到底该不该手动调”,能说清楚的人并不多。这篇不是贴源码加注释的流水账,而是从内存布局到扩容机制,把 ArrayList 的设计逻辑和实战中的坑一次性拆开,适合正在学数据结构的同学、准备面试的开发者,以及写了好几年 Java 但想补一补底层细节的人。

我自己在工作中用 ArrayList 踩过不少坑,比如明明预估了数据量却不传初始容量,导致大批量add时反复扩容白白卡顿;又比如subList当独立集合用,结果修改完原列表才发现视图也跟着变了。这些问题的根源,都在于没有真正理解 ArrayList 的内存模型和扩容策略。把这篇文章看完,你会对它有更完整的认知,后续写代码时也能少走弯路。

1. 为什么要把 ArrayList 拆开来看

1.1 一个接口背后藏着的设计取舍

ArrayList 本质上是一段连续内存上的对象数组,它实现了 List 接口,对外表现成一个“可以动态增长”的序列。它和普通数组最大的区别在于:数组一旦创建,长度就固定了,而 ArrayList 能在元素数量超过当前容量时自动扩容。这个“自动”的背后,是内部维护了一个Object[] elementData和一个int size。

elementData就是真正存放元素的数组,size记录的是“已经使用的元素个数”,而不是数组的总长度。举个例子:你new ArrayList<>(20),底层立刻分配一个长度为 20 的对象数组,但此时size仍然是 0,因为还没有往里放任何元素。这个细节很多人一开始会搞混,以为size()返回的是数组容量,实际上它返回的是有效元素个数。

在 JDK 1.8 及之后的版本中,ArrayList 源码里有两个容易忽略的构造器细节。无参构造器不会一开始就分配Object[10],而是把elementData指向一个共享的空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA,直到第一次真正add时才去扩容到默认容量 10。这种懒加载策略是为了节省内存,毕竟很多 ArrayList 创建后可能压根不会用到。你可以理解为“先开个空头账户,等到真往里存钱才开户”。

从设计取舍上看,ArrayList 用连续内存换取了 O(1) 的随机访问能力。get(index)能直接通过数组下标定位内存地址,不需要像链表那样从头遍历。这也是它在绝大多数场景下比 LinkedList 适合做查询的原因。但代价同样明显:在头部或中间插入、删除元素时,需要批量移动后续元素,最坏情况下是 O(n) 的复制开销。这不是源码写得不好,而是物理存储结构决定的天然特性。

1.2 和其他“兄弟”结构对比后才知道它的边界

把 ArrayList、LinkedList、Vector 放在一起看,能更清楚地知道 ArrayList 适合什么、不适合什么。LinkedList 基于双向链表,每个节点都单独持有前后指针,插入删除确实灵活,但因为节点分散在内存各处,缓存命中率低,实际遍历性能反而经常输给 ArrayList。Vector 是早期线程安全的动态数组,方法上都加了 synchronized,在 JDK 1.2 后基本被 ArrayList 取代,除非明确需要线程安全且能容忍性能损耗,否则没必要用它。

我用一个实际场景感受过这几种结构的差距:批量读 10 万个整数并随机访问其中的 5 万个下标,ArrayList 耗时大约是 LinkedList 的几十分之一。原因很简单,ArrayList 的内存是连续分配的,CPU 在读取时可以走缓存行预取,而链表节点散落各处,每次跳转都可能发生缓存未命中。这种差异在小数据量时看不出来,一旦数据量上了十万级、百万级,性能差别会非常明显。

所以在项目里选型时,我一般遵循这样的思路:如果以随机访问为主、元素数量可预估,优先 ArrayList;如果以头尾插入删除为主且写多读少,才考虑 LinkedList;如果涉及并发且代码比较简单,可以直接用 CopyOnWriteArrayList 或者 Collections.synchronizedList,而不是去裸用 Vector。理解 ArrayList 的边界,不是为了贬低其他结构,而是知道它什么时候不灵。

2. 从内存布局看 ArrayList 的底层真相

2.1 elementData、size 和那个隐藏的空数组

ArrayList 的类字段大致是: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;。

无参构造器执行的是this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;,这时候底层数组长度是 0。第一次add时,add方法内部会调用ensureCapacityInternal(size + 1),也就是判断当前需要的最小容量。如果是DEFAULTCAPACITY_EMPTY_ELEMENTDATA状态,就会把目标容量与DEFAULT_CAPACITY取最大值,也就是直接扩容到 10。这个机制在源码里叫“懒分配”,在我看来它的设计意图非常清晰:大量程序会创建空的 ArrayList 并绑定到字段上,可能永远不往里塞数据,如果每个都提前分配 10 个对象的空间,内存浪费是没必要的。

而EMPTY_ELEMENTDATA是另一个空数组,用在显式指定初始容量的构造器中,比如new ArrayList<>(0)或new ArrayList<>(5)(当容量为 0 时)。区分这两个空数组是为了让扩容逻辑能知道:我应该按照默认容量扩容,还是严格按用户指定的容量走。这个细节平时不会有人注意,但 debug 时看出容量差异就能定位很多问题。

size字段和elementData.length的关系,我建议每个用 ArrayList 的人都记牢:size永远小于等于elementData.length,ArrayList 的容量并不是实际元素个数,而是“还能继续添加元素而不触发扩容”的空间上限。理解这一点,才能真正看懂ensureCapacity、trimToSize这类 API 在干什么。

2.2 为什么 capacity 要“留一手”

ArrayList 之所以维护 capacity 而不是每次 add 都正好分配 size 的空间,本质上是为了平衡“空间占用”和“扩容频率”这对矛盾。如果每次 add 都让数组长度等于元素个数,那每加入一个元素都得Arrays.copyOf一次,这个操作是 O(n) 的,插入 n 个元素将变成 O(n^2),直接不可用。

所以 ArrayList 的扩容策略是留出冗余空间。grow方法默认扩容 1.5 倍,这样从空列表开始,按 1.5 倍递增,扩容次数是对数级别的。举个例子:从容量 10 一路加到容量 10000,需要扩容的次数大约只有十几次,而每次扩容都涉及一次数组复制,复制总代价整体摊还下来,单个 add 操作的均摊时间复杂度是 O(1)。这就是算法书上讲的“平摊分析”,ArrayList 是教科书级案例。

“留一手”的代价是内存浪费。如果你 add 了 11 个元素,底层数组长度会从 10 跳到 15,多出的 4 个槽位就闲在那里。在元素很大(比如存了几百 KB 的图片 base64 字符串)时,这种浪费会很明显。此时可以用trimToSize()把数组长度精确调整到size,释放多余空间,但要注意调用后再次添加元素又会触发扩容,所以只在确定不会继续增长时才做。

2.3 transient 修饰符背后的序列化考量

elementData被transient修饰,这个细节我身边很多人都没注意到,但它其实很值得玩味。如果一个字段是 transient,默认的 Java 序列化机制就不会自动持久化它。那 ArrayList 序列化元素怎么做?它自己实现了writeObject和readObject方法,在序列化时只把size和实际元素写入流,而不是把整个elementData数组按图全扔进去。

原因很直接:底层数组长度往往大于实际元素个数,那些空的槽位如果也序列化,会白白多写出去,浪费网络带宽和磁盘空间。ArrayList 这样做等于把“有效数据”和“冗余容量”在序列化层做了切割。这也是一个很值得学习的 API 设计范例——不是所有字段都该序列化,尤其是那些可以按需重建的临时状态。

理解这个细节还有一个实际用途:当你自定义一个包含elementData这样“数组长度可能大于内容长度”的类时,也可以参考 ArrayList 的做法,用 transient + 自定义 writeObject/readObject 来精简序列化数据。面试官如果问到 ArrayList 的 transient,能答出“避免序列化多余容量”这个点,基本就比大多数候选人多了一层深度。

3. 扩容机制的完整演绎和参数计算

3.1 grow 方法是怎么一步步扩的

先看 JDK 8 里扩容最核心的两段代码,我把关键部分贴出来方便对照:

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); }

oldCapacity + (oldCapacity >> 1)就是“原容量 + 原容量的一半”,即扩容 1.5 倍。位运算右移一位等于除以 2,这种写法比除号高效,是源码里常见的性能优化。如果 1.5 倍后的容量仍然小于所需最小容量(比如你addAll了一个超大集合,原容量 10,要加入 100 个元素,1.5 倍后是 15,仍然不够),那就直接取minCapacity为目标容量。

有人会问,为什么 JDK 8 的合理扩容倍数是 1.5,而不是 2 倍或者 1.25 倍?这个选择背后有取舍。倍数大了,扩容次数少,但中间闲置空间大;倍数小了,空间利用率高,但扩容频繁、复制次数多。1.5 倍是一个经验值,兼顾了时间和空间,同时扩容后的容量总是一个“预计还有余量”的状态。JDK 17 之后的 grow 方法做了更精细的溢出检查,但整体策略本质上还是一致的。

实际调用add时,流程是这样的:先ensureCapacityInternal(size + 1),其内部会走ensureExplicitCapacity,判断minCapacity - elementData.length > 0,也就是“新增一个元素后容量不够了”,才触发grow。如果现有容量还够,就什么都不做,直接往数组下标为size的位置赋值,然后把size加一。这就是为什么持续 add 过程中的前 N 次操作非常快,真正慢的只是那几次扩容瞬间。

3.2 阈值边界:从 MAX_ARRAY_SIZE 到 OOM

MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8,这个数不是随便定的。很多 JVM 针对数组大小有自己的对象头开销限制,部分实现里数组想要达到Integer.MAX_VALUE会有风险,所以 JDK 预留了 8 个元素的空间作为安全余量。这个边界值在面试里经常被当作冷门考点,真正遇到的人很少,但一旦遇到就难处理。

hugeCapacity的逻辑是这样的:如果minCapacity < 0,说明容量溢出成了负数,直接抛OutOfMemoryError;如果minCapacity > MAX_ARRAY_SIZE,就把容量设为Integer.MAX_VALUE,否则保持MAX_ARRAY_SIZE。换句话说,ArrayList 的上限容量理论上可以达到Integer.MAX_VALUE,但在实际 32 位 JVM 或接近内存上限的机器上,数组还没分配到那个大小就已经 OOM 了。

我在做海量数据导入功能时踩过一个大坑:一次性把一个几百万行的 CSV 读进来,用addAll塞给 ArrayList,结果程序长时间卡住甚至 OOM。排查后发现问题不在单次扩容的倍数,而在于我一开始没有预估容量,导致从 10 开始反复扩容,每次扩容都要把已有数据整体复制一遍,到后面一次复制就是十几万对象的数组拷贝,时间呈指数叠加。后来改成先统计行数,new ArrayList<>(预估行数 + 1),内存和耗时都稳了。

这个经验不只是 ArrayList 本身的坑,也是所有“动态扩展结构”的共性:如果你能预估数据规模,一定要在初始化阶段就把容量给足,而不是让结构自己一步步撞大运式扩容。

3.3 ArrayList 扩容与 LinkedList 插入的真实代价对比

很多人记着一句口诀“ArrayList 查询快增删慢,LinkedList 增删快查询慢”,但真实场景里这句口诀需要修正。ArrayList 的“慢删”分位置:删除末尾元素是 O(1),因为不需要复制后续数据;删除头部或中间元素才需要System.arraycopy移动后续元素,这才是 O(n)。LinkedList 的“快插入”也有前提:如果你已经持有那个节点的引用,add操作才是 O(1);但大多数情况下你先得遍历到目标位置,那一步已经是 O(n) 了,后面的 O(1) 反而没什么优势。

我拿真实数据对比过:在一个 10 万元素的 ArrayList 里,循环末尾追加 10 万次,耗时很低;但在头部插入 1 万次,耗时急剧上升,因为每次插入都要移动余下所有元素,累计复制量是 O(n^2)。LinkedList 在头部插入时确实是 O(1) 级别的优势,但遍历访问时又慢得让人着急。所以更合理的做法是“看操作模式选结构,而不是看单个操作复杂度选结构”。

想用 ArrayList 做频繁头部插入但又不想付出复制代价,有两个思路。一是反转使用习惯,始终在尾部追加,最后统一Collections.reverse;二是用ArrayDeque或自定义循环数组,尽量避免总是在 0 下标插入。这些思路本质是在绕开 ArrayList 的结构缺陷,而不是硬刚复杂度。

4. 实际开发中常见的性能陷阱与排查实录

4.1 循环里调用 size() 的性能损耗

这个是我在 code review 里提过很多次的问题。for (int i = 0; i < list.size(); i++)这种写法在 ArrayList 上其实损耗并不大,因为size()只是读一个 int 字段,没有计算开销。但如果这个循环发生在 LinkedList 上,size()在部分实现里是 O(1) 的,也没问题,真正的问题是get(i),LinkedList 的get(i)每次都要从头遍历,千万别在循环里对 LinkedList 用下标访问。

更隐蔽的性能陷阱是循环体里反复调用list.size()作为动态边界,如果同时有线程在改这个 list,可能还会出现越界或漏元素问题。规范做法是提前把 size 存到局部变量,或者直接使用增强 for 循环。增强 for 循环背后用的是迭代器,对 ArrayList 来说遍历效率高,但如果你在循环内还想做删除,就必须用Iterator.remove(),否则会触发 ConcurrentModificationException。

我见过一个真实案例:一个接口里对 20 万元素的 ArrayList 做for + get()遍历,看起来天经地义,但数据突然涨到 200 万后,接口耗时从几十毫秒涨到几秒。排查下来发现不是 get 的问题,而是循环体内嵌了一个contains()调用,这个调用本身是 O(n),两层循环变成了 O(n^2)。后来改成先把外部集合转 HashMap 做去重判断,耗时立刻降回两位数。排查性能问题,永远要先看复杂度嵌套,别一开始就怀疑 ArrayList 本身。

4.2 初始容量明明知道却就是不传

new ArrayList<>()这个写法太顺手了,以至于很多人明明知道最多会有多少条数据,还是懒得传初始容量。在数据量小(几百条以内)的情况下,这个影响确实不大,扩容几次也就多了几次数组复制,毫秒级损耗。但一旦数据量上万,或者元素本身是大对象,不传初始容量的代价就被放大了。

我建议养成两个习惯:第一,凡是能估算上限的场景,一律new ArrayList<>(预估容量 + 1),加 1 是为了避免刚好满员后再 add 一次就触发扩容,宁可多给一个坑位;第二,如果完全预估不了,就用无参构造器,但后续一旦发现数据量可能会变大,尽早调用ensureCapacity手动扩容,而不是等到grow被迫触发。

ensureCapacity(int minCapacity)是我见很多人忽略的公开方法。它的作用相当于“提前把数组做大”,一次性复制到位,避免后续一次次小扩容。源码内部ensureCapacityInternal会比对当前容量,如果容量已经够了,这个方法几乎是零成本的空操作。所以在批量导入、批量初始化数据的代码里,先ensureCapacity再循环 add,是一个性价比非常高的优化手段。

4.3 remove 和 clear 之后的隐性开销

ArrayList 的remove(int index)会把index之后的所有元素整体前移,然后置空最后一个槽位(帮助 GC)。这个移动过程用的是System.arraycopy,底层是内存复制,效率很高,但仍然是 O(n)。如果你有一个 10 万元的列表,一次次按 index 删除,每次都触发复制,那性能会非常难看。想批量删,要么用迭代器边遍历边 remove,要么先筛选出要保留的元素放进新列表,彻底绕开频繁的数组搬移。

clear()方法的实现也值得注意:JDK 8 里它是一个 for 循环把每个槽位置为 null,而不是直接把size改成 0。这么做的原因是,如果只是把 size 清零,数组里旧对象仍然被强引用着,GC 无法回收它们,内存就会“泄漏”一段时间。置 null 之后,数组里不再持有这些引用,GC 才能及时回收。但clear()不会缩小数组容量,底层数组还是那么大。如果这个 ArrayList 是长期驻留在内存里的,但使用高峰期已经过了,最好再调用trimToSize()把容量收缩。

我在开发一个批量任务引擎时,遇到过clear()后老年代内存迟迟不降的问题。后来查了堆转储,发现任务列表的elementData数组仍然抱着几十万个任务对象,就是因为clear()里把所有引用置了 null,按理说应该能被回收,但那个 ArrayList 对象本身一直被引擎的上下文持有,数组长度没变,容量还是超大。处理方式是定期重建列表而不是只 clear,或者 clear 后trimToSize,最终内存曲线才恢复正常。这个坑提醒我:容量也是内存,不用的空间要主动释放。

5. 一些你未必注意到的 API 细节坑

5.1 subList 是视图不是副本

list.subList(from, to)返回的不是独立列表,而是原列表的一个视图,底层通过SubList内部类实现,持有了parent引用和对原列表modCount的校验。这意味着你修改 subList 时,原列表也会跟着变;反过来,原列表的结构性修改(add、remove 等)会让之前拿到的 subList 失效,再操作就抛ConcurrentModificationException。

我踩过一次很痛:从一个大列表里取了一段subList,为了性能直接往 subList 里加元素,以为只是操作“切片”,结果把源列表整个打乱了,线上数据被污染。后来才意识到 subList 的语义是“同一个列表的不同视角”,并不是复制。如果你确实需要独立切片,标准做法是new ArrayList<>(list.subList(from, to)),把这个视图“物化”成新列表,代价是一次数组复制,但语义完全可控。

subList 还有个用处值得提:删除大列表的连续区域时,list.subList(from, to).clear()会比手动循环 remove 快很多,因为SubList.clear()在部分 JDK 版本里会直接走批量删除逻辑,只用一次 arraycopy 就完成区间搬移。用对地方是利器,用错地方是炸弹。

5.2 fail-fast 和 modCount 的关系

modCount是 AbstractList 里的一个字段,记录结构修改次数。ArrayList 内部在add、remove、clear、ensureCapacity等结构性操作时都会modCount++。迭代器创建时会保存一个expectedModCount,每次next()和remove()都会检查modCount == expectedModCount,不一致就直接抛ConcurrentModificationException。这个机制叫 fail-fast,目的是尽早暴露并发修改问题,而不是在错误数据上继续跑。

很多人只知道“不能边遍历边修改”,但没搞明白背后的设计意图。fail-fast 本身不保证绝对可靠,它只能在“单线程内结构性修改未按迭代器规则进行”时发现错误,并不能锁住集合。多线程并发读写 ArrayList 是线程不安全的,即使没抛异常,也可能出现数据读一半、数组越界等诡异问题。正确的并发做法是使用CopyOnWriteArrayList,或者加锁同步。

迭代器自己的remove()方法会同步更新expectedModCount,所以不会触发异常。但add()是没有对应的“安全迭代器新增”的,迭代过程中你想加元素,只能先收集到另一个列表,循环结束后再统一addAll。这些都是 API 在使用层面给我们的约束,理解 modCount 之后,很多报错都能一眼看穿。

5.3 asList 返回的 ArrayList 是个异类

Arrays.asList(T... a)返回的 ArrayList 是 Arrays 内部类,不是 java.util.ArrayList。它确实实现了List接口,底层直接持有了传入数组的引用,长度不可变。你调用add会抛UnsupportedOperationException;你通过set(index, value)修改元素,原数组会跟着变,反之亦然。很多新手在这里栽跟头,以为自己拿到的是普通 ArrayList。

正确的用法是,如果只是需要一个只读的、基于数组的 List 视图,Arrays.asList很好用;但如果后续要做增删操作,必须new ArrayList<>(Arrays.asList(arr))包一层。这个包装过程会复制数组内容,构建一个真正可变的 ArrayList,代价是一次数组拷贝,但换来的是完整 API 和可变性。

还有一个容易忽略的坑:Arrays.asList提供的是“定长列表”,虽然调set是允许的,但不能改变 size。从内存布局角度看,它压根没有扩容机制,结构上更像一个“带 List 接口包装的数组”。搞清楚了这一点,你在 debug 时看到奇怪的UnsupportedOperationException就不再慌张了。

结尾

我个人在实际项目里的总结是:ArrayList 像一个基本功,懂的人觉得没什么好说,不懂的人永远在踩同一个坑。它的内存布局决定了一切——连续数组带来随机访问优势,扩容机制决定了高频插入时的性能曲线,而那一堆 API 细节坑(subList、modCount、transient)都是这套内存模型的自然延伸。你不需要背下所有源码,但要能在遇到性能问题、诡异异常时,快速联想到是不是跟底层数组容量、视图语义、结构性修改有关。最后分享一个小技巧:写工具类时,凡是接收 Collection 的方法,尽量在入口处根据预估规模ensureCapacity一下,这个动作的成本几乎为零,却能帮你在数据量暴涨时保住系统的响应时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询