☰
ArrayList源码深度剖析:扩容机制、fail-fast与工程避坑指南
2026/10/15 4:54:02 网站建设 项目流程

ArrayList可能是绝大多数Java开发者第一个接触的集合类,但真正啃过它源码的人并不多。我最初也是凭着“就是个数组实现”这点印象去应付面试,结果在扩容机制和fail-fast上狠狠吃了几次亏,才下决心把JDK里ArrayList从构造到迭代器的源码完整过了一遍。读完之后,很多以前靠背八股才知道的结论都串起来了,比如为什么无参构造首次add才分配数组、为什么扩容是1.5倍、为什么遍历时不能直接remove。这篇文章不打算按源码顺序逐行贴注释,而是把ArrayList里最核心的设计思路和坑点拆开讲,希望你读完能少踩几个我踩过的雷。

1. ArrayList的整体设计:一个会“伸缩”的数组

1.1 接口与继承关系背后的信号

先看类声明:

public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable

这里几个信息量很大。继承AbstractList,说明它已经有了一部分通用列表的骨架实现,比如iterator()、add()等模板方法。实现RandomAccess接口是纯打标,表示支持快速随机访问,这是ArrayList区别于LinkedList的核心优势。实现Cloneable和Serializable意味着可以克隆和序列化。

底层存储很简单:一个Object数组elementData,加一个int size记录元素个数。

transient Object[] elementData; private int size;

所有操作本质上都是对这个数组的读写。为什么用普通数组而不是链表?因为数组在内存中是连续分配的,CPU缓存友好,而且按下标访问的时间复杂度是O(1),而链表要O(n)遍历。这也是ArrayList最适合做“按索引获取”场景的原因。

1.2 容量和size的账要算清楚

容量是数组的长度elementData.length,size是实际放进来的元素个数。这两个值在大多数普通场景下是一致的,但ArrayList的设计允许容量大于size,因为数组往往留有富余,为后续添加元素做准备。

我见过很多人调试时打印size发现是5,就以为数组长度是5,其实elementData.length可能是10甚至更大。理解这一点后,很多疑问就消失了,比如为什么有时候new ArrayList无参构造之后,数组其实是空的。

看构造器:

public ArrayList() { this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; }

JDK 8之后,无参构造不再一开始就new一个容量10的数组,而是指向一个空数组常量。第一次调用add时才真正创建容量为10的数组。这是懒加载思路,目的是为了省内存。因为很多集合创建后根本没用,没必要提前申请数组。这里有个小细节:还有一个EMPTY_ELEMENTDATA,是给指定初始容量为0的ArrayList用的,而DEFAULTCAPACITY_EMPTY_ELEMENTDATA是专门给无参构造用的,目的就是为了区分“用户显式说了容量为0”和“用户没指定容量”两种情况,这会影响第一次add时扩到多少。

如果用new ArrayList(0),第一次add会扩到1;如果用new ArrayList(),第一次add会扩到10。具体看下面这部分。

2. 扩容机制:ArrayList的灵魂所在

2.1 首次add的“默默安排”

看add(E e)入口:

public boolean add(E e) { ensureCapacityInternal(size + 1); elementData[size++] = e; return true; }

ensureCapacityInternal先确保数组至少能容纳size+1个元素。如果数组是空常量,计算最小容量时会和DEFAULT_CAPACITY取最大值,所以第一次add就算传入的minCapacity是1,也会取10作为最小容量,然后执行grow扩容到10。

这里要强调一下:grow的条件是“最小容量大于当前数组长度时才扩容”。所以无参构造创建后,第一次add时当前数组长度为0,minCapacity=10,肯定要扩。而之后一直往数组里加到第10个元素,数组长度是10,第11次add时size+1=11,大于10才再次扩容。所以无参构造的ArrayList实际上能连续装10个元素不触发扩容。

2.2 扩容为什么是1.5倍而不是2倍

接下来看grow方法:

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 >> 1相当于oldCapacity除以2,所以新容量是老的1.5倍。如果1.5倍还不够minCapacity用的,那就直接用minCapacity。最后还要跟MAX_ARRAY_SIZE比一下。

为什么选1.5倍?如果每次只加1个,增长太慢,反复扩容拷贝数组会浪费时间;如果直接翻倍,内存浪费又比较大。1.5倍是在时间开销和空间浪费之间的一个折中。JDK的工程师做过很多权衡,这个比例还被大量其他容器沿用。用位运算计算一半是因为位运算比除法更高效,虽然现代JIT会做优化,但源码层面仍然保持了这种风格。

2.3 MAX_ARRAY_SIZE与hugeCapacity的限制

在ArrayList源码里定义了一个常量:

private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

为什么要减8?因为有些JVM实现会在数组头里存少量元数据,如果数组长度直接取Integer.MAX_VALUE,可能导致数组对象的总大小超过JVM限制,甚至抛OutOfMemoryError。减8是为了留一点余量。这是一个很实际、但平时容易被忽略的细节。

如果计算出的newCapacity超过MAX_ARRAY_SIZE,就进入hugeCapacity:

private static int hugeCapacity(int minCapacity) { if (minCapacity < 0) throw new OutOfMemoryError(); return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE; }

minCapacity是int,为什么会小于0?因为size+1理论上不会溢出,但某些极端场景,比如外部调用ensureCapacity传了一个负数或者超大值,就可能出现。这里判断的是内存申请失败的直接表现,而不是逻辑上的“你传错了”。

2.4 扩容时的数组拷贝代价

扩容最终是通过Arrays.copyOf完成的,它底层调用System.arraycopy,这是一个native方法,性能和批量复制上比for循环一个个赋值快得多。

但无论多快,扩容的本质都是:申请一个新数组,把旧数组全部拷贝过去。如果数据量大,这个复制过程会很伤。我们做个小估算:假设用无参构造,不断add,容量变化是10、15、22、33、50...看起来好像每次扩容只留一半空间,但总拷贝量仍然是O(n)。如果一开始就能预估元素数量,直接指定容量,完全能免掉中间好几次扩容。

所以官方提供了带初始容量构造:

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

看到没,这里传入负数直接抛IllegalArgumentException。因为容量不可能为负,这是入参校验,不要在门外这块栽坑。

还提供了ensureCapacity(int minCapacity)让用户手动扩容:

public void ensureCapacity(int minCapacity) { int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) ? 0 : DEFAULT_CAPACITY; if (minCapacity > minExpand) { ensureExplicitCapacity(minCapacity); } }

这个方法适合预先知道会添加很多元素的场景。比如你要循环往ArrayList里塞1万条数据,可以先ensureCapacity(10000),避免中间扩容十几次。我实测过,在数据量10万级别时,预分配容量能减少20%左右的耗时,并且大幅减少GC压力。

3. 核心方法源码拆解:每个细节都值得看

3.1 add与rangeCheckForAdd

除了追加到末尾的add(E),add(int index, E element)是插入操作:

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

rangeCheckForAdd检查index是否在[0,size]之间。注意是size而不是length,因为size才是有效元素个数。如果你把元素插入到一个已经有容量但不等于size的位置,源码是拒绝的,这很好理解:list不允许空洞。

插入一个元素时,要把index位置原来的元素及其后面所有元素整体向后挪一位。这里也用System.arraycopy,注意源和目标都是同一个数组,JDK的native方法会处理重叠区间,不需要自己担心。

需要留意的是,插入操作的时间复杂度是O(n),因为它要位移。如果在列表头部频繁插入,LinkedList理论上更适合,但实际LinkedList的节点对象开销和缓存不友好,在中等规模下也没想象中那么强,还是要用基准测试来定。

3.2 get、set和边界检查

很多人以为get方法只是简单返回值,其实它还有一套边界保护:

public E get(int index) { rangeCheck(index); return elementData(index); }

rangeCheck:

private void rangeCheck(int index) { if (index >= size) throw new IndexOutOfBoundsException(outOfBoundsMsg(index)); }

这里只检查了index >= size,没有检查index < 0,因为数组是负索引就会抛ArrayIndexOutOfBoundsException,属于Native层兜底。不过set同样调用了rangeCheck。从这里能看出,对get/set这些“随机访问”操作,ArrayList是最快的,时间复杂度O(1),因为数组就是按地址偏移量直接定位。

3.3 remove的两种实现:按下标和按对象

按下标删除:

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

删除与插入类似,只是往反方向挪。最后把末尾的引用置为null,这样站数组里最后一个对象可以被GC回收,否则即使size减了,数组仍持有那个引用,有内存泄漏风险。

按对象删除:

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

这里体现了ArrayList允许null元素,删除null时要用==判断,删除非null对象时调equals,而不是==。所以自定义对象不重写equals的话,remove会找不到对象,这是经典坑。fastRemove是“快速删除”,它不检查边界,因为前面已经找到了目标下标,逻辑上必定合法,剩余部分和remove(int)一样。

另一个细节:list.remove(obj)只会删除第一个匹配的元素,如果你有多个重复元素且想全删除,得用removeAll或者循环。这是行为设计,不是bug。

3.4 indexOf与lastIndexOf的线性扫描

public int indexOf(Object o) { if (o == null) { for (int i = 0; i < size; i++) if (elementData[i]==null) return i; } else { for (int i = 0; i < size; i++) if (o.equals(elementData[i])) return i; } return -1; }

看到循环结构,就知道indexOf的时间复杂度是O(n)。这也意味着ArrayList在做“查找对象位置”这个操作时并不高效,如果频繁按内容找位置,应该用HashMap等哈希表。

4. modCount:并发修改的报警器

4.1 Fail-Fast机制的实现思路

AbstractList里定义了一个protected瞬态字段modCount,记录“结构性修改”次数。什么叫结构性修改?就是改变了列表长度,或者对列表内容进行会影响迭代结果的重排,比如remove、add、clear。注意set不改变结构,只替换值,所以set不会增加modCount。

任何会修改结构的方法,几乎都在开头执行modCount++。为什么要有这个字段?为了给迭代器一个检测并发修改的凭据。

看Itr迭代器:

private class Itr implements Iterator<E> { int cursor; int lastRet = -1; int expectedModCount = modCount; public E next() { checkForComodification(); // ... } final void checkForComodification() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); } }

迭代器在创建时把expectedModCount记住为当时的modCount。每次调用next或remove时,如果发现modCount变了,说明在迭代期间列表被别的地方改动了,就会立刻抛出ConcurrentModificationException,避免后续行为不确定。

这种“快速失败”并不是为了并发安全,而是为了把问题尽早暴露出来。因为如果有人在迭代过程中偷偷改数据,迭代器自己的cursor可能已经过期,继续迭代会得到错误结果甚至数组越界,CFE反而是更可预期的。

有人问:为什么ArrayList有fail-fast就不需要线程同步了?不对,fail-fast只负责“报错”,不负责“正确”。多线程环境下,即使并发修改抛了异常,业务也中断了。所以要做真正的并发安全,还是得加锁或用并发集合。

4.2 迭代过程中安全的删除方式

Iterator自带remove:

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

它内部调用了ArrayList的remove,但调用完后又同步了expectedModCount,所以不会在后续next时触发异常。这才是遍历删除的正确姿势。

我遇到过很多人在for循环里这样写:

for (int i = 0; i < list.size(); i++) { if (条件) list.remove(i); }

如果只满足一次条件,这代码运气好还能跑通;要是连续删多个元素,因为size动态变化和索引位移,很容易漏删甚至数组越界。用增强for循环直接list.remove更是必炸,因为迭代器发现modCount变了直接抛异常。

正确的做法有几种,最好的是——

Iterator<Integer> it = list.iterator(); while (it.hasNext()) { if (条件) it.remove(); }

或者使用removeIf:

list.removeIf(条件);

removeIf底层也是走迭代器的删除逻辑,它内部维护了modCount,所以能安全删除。JDK 8之后我都推荐用removeIf,代码最简洁。

5. 序列化、toArray与subList这些容易被忽视的细节

5.1 为什么elementData要标记transient

看源码会发现:

transient Object[] elementData;

ArrayList明明实现了Serializable,却把成员数组标成transient,然后自定义writeObject和readObject。

private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException{ int expectedModCount = modCount; s.defaultWriteObject(); s.writeInt(size); for (int i=0; i<size; i++) { s.writeObject(elementData[i]); } if (modCount != expectedModCount) { throw new ConcurrentModificationException(); } }

为什么不全序列化?因为elementData.length可能比size大,里面有很多多余的空位。如果把整个数组序列化,浪费的不仅仅是时间,还有输出流的空间。自定义writeObject只把size和有效元素写出去,体积更小。readObject再重建数组。这里又有一个concurrent保护:序列化期间如果modCount变了,数据可能已经不一致,直接抛异常终止,防止静默写出脏数据。

这段代码体现了一个重要工程思想:序列化协议应该尽量紧凑,不要暴露内部实现细节。如果你序列化整个数组,以后升级为其他数据结构时,序列化格式就废了。自定义后只要保持readObject兼容,内部结构可以随便换。

5.2 toArray的两种形态

ArrayList提供两个toArray:

public Object[] toArray() { return Arrays.copyOf(elementData, size); }

返回的是Object[]。如果直接强转:

String[] arr = (String[]) list.toArray();

会抛ClassCastException,因为Object[]不是String[]。这是新手经典错误。正确的做法是:

String[] arr = list.toArray(new String[0]);

JDK 8之后,new String[0]会被特殊优化,原理上还是把元素拷进新建数组。如果传入的数组容量大于size,会在原数组后面补null。

5.3 subList是视图,不是新建列表

ArrayList的subList方法返回的是SubList内部类,不是复制出来的独立List。它是一个视图:原ArrayList的底层数组改动,会立即反映到subList上;subList的add/remove也会影响原列表。这个特性坑过很多人,比如:

List<String> sub = list.subList(0, 3); sub.clear();

执行后原list也会减少3个元素。这其实是subList设计的初衷,方便对子范围批量操作。但你如果把它当成一个独立快照处理,就很容易出bug。

另外,subList继承了AbstractList,它也有自己的modCount记录。如果修改原list的结构,再去操作subList就可能抛ConcurrentModificationException,因为subList检测到原list的modCount变了。这也是一种快速失败的保护。

实际开发中,如果你需要一个“副本”,就直接new ArrayList<>(list.subList(0, 3))。

5.4 批量添加与removeAll的时间复杂度

在批量添加时:

public boolean addAll(Collection<? extends E> c) { Object[] a = c.toArray(); int numNew = a.length; ensureCapacityInternal(size + numNew); System.arraycopy(a, 0, elementData, size, numNew); size += numNew; return numNew != 0; }

它先toArray拿到数组,再一次性拷进去,而不是逐个add,效率高很多。这也是为什么List之间复制数据时,要用addAll而不是for循环。

removeAll和retainAll需要遍历查找,时间复杂度通常O(n*m),如果数据量很大,建议用HashSet临时辅助,把逻辑改成基于哈希的判断,能显著提速。这也提示我们:ArrayList在“批量关联操作”上并没有做特殊索引优化,遇到性能瓶颈时不要硬扛。

6. 实战避坑:我踩过几次ArrayList的坑之后总结的清单

6.1 Arrays.asList转出来的List不能增删

直接看这个现象:

List<String> list = Arrays.asList("a", "b", "c"); list.add("d"); // UnsupportedOperationException

原因很简单:Arrays.asList返回的是一个Arrays内部ArrayList,继承AbstractList,但add/remove没有实现,直接抛异常。它不是java.util.ArrayList。很多人以为这个ArrayList就是java.util.ArrayList,把集合传进去做操作,结果运行时诡异出错。

如果想拿到一个可增删的ArrayList,需要这样:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));

这里把Arrays.asList的数组视图重新包装了一下。

6.2 并发环境下别直接用ArrayList

ArrayList的所有方法都没有加锁,多线程并发读写时,一个线程修改size,另一个线程读取,轻则读到脏数据,重则数组越界。别以为fail-fast能救命,它只是抛出异常,如果业务没处理,线程照样挂。

并发替代方案有几种:

  • 读多写少:CopyOnWriteArrayList,写时复制数组,读不加锁,适合读多写少场景。
  • 写多读少:Collections.synchronizedList(new ArrayList<>(...)),对所有方法加锁。
  • 性能要求极高:参考并发集合专门实现,比如ConcurrentLinkedQueue或写自定义数据结构,别生搬ArrayList。

6.3 容量预估是真正的高手动作

很多人new ArrayList()用得很随意,但如果你能预估数据量,一定要指定初始容量或调用ensureCapacity。举个例子,你准备从数据库查询10万行并且每行都add进列表。无参构造的扩容路径大约:10、15、22、33...后面越来越接近1.5倍,算下来可能要扩容十几次。每扩容一次就是一次完整的数组拷贝,十几万次元素的搬移全部白费了。

直接new ArrayList<>(100000)会把数组申请到位,后续全部O(1)追加,零扩容。实测数据量越大,收益越明显。对于内存敏感又无法精确预估的情况,适当高估容量比低估更安全,因为数组就是连续内存,整体一次性申请够用是最高效的。

6.4 增强for循环里删除要特别小心

如下代码:

for (String s : list) { if (s.equals("x")) { list.remove(s); } }

会抛ConcurrentModificationException。增强for底层是迭代器,remove后modCount变大,next时检测到不一致。真想删就早用Iterator,或者倒序普通for:

for (int i = list.size() - 1; i >= 0; i--) { if (条件) { list.remove(i); } }

倒序删除的好处是删除当前索引后,不需要对后续索引做额外调整,也不会漏元素。这算是我早期最常用的小技巧,后来上了JDK 8就用removeIf了。

6.5 自定义对象要重写equals,否则removeAll是空操作

ArrayList判断对象相等依赖equals方法。如果你有一个自定义业务对象,没重写equals/hashCode,那么remove、contains、indexOf都会用Object的equals,即比较引用地址。两个new出来的同一个业务对象,即使字段都一样,也互不匹配。这是我见过很多业务Bug的根源。不重写equals就别想着用ArrayList做对象查找,或者老老实实传同一个引用。

顺带一提,重写equals时也务必重写hashCode,否则后续放进基于哈希的集合里会出更恶心的问题。

6.6 内存释放:大列表状态下及时置null

如果你在一个大方法里创建了一个非常大的ArrayList,处理完以后不释放,它可能一直占据着那个巨大的数组。GC只看可达性,如果变量还活着,数组就活着。及时把列表置null或者让引用超出作用域,能帮助老年代快速回收。更常见的是把大集合作为局部变量放在方法内,方法结束后引用自然失效,就跟这个坑没关系了。

但我见过有人把大List放在某一个常驻对象字段里,清空时只调用clear()。clear()会把每个元素置为null,但是elementData这个数组对象本身还是大容量的。如果之后还要继续用这个列表,那是正常的;如果确定再也不用了,应该把整个list引用置为null,而不是只clear。因为clear之后,数组对象依旧占着容量。

6.7 警惕ArrayList在高频头部插入场景下的性能劣化

如果业务频繁在List开头插入数据,比如:

list.add(0, value);

那每次插入都要把所有元素后移,复杂度O(n)。数据量小还好说,数据量一大就卡。有人直接拿ArrayList当队列使用,这是性能反模式。这时候换成LinkedList,或者更好的ArrayDeque(作为双端队列)会舒服得多。但ArrayDeque不能随机按下标访问,或性能不如数组。具体取舍还要看你的核心操作是“头尾操作”还是“中间操作”加“随机读取”。真要是高频头插又需要随机访问,可以考虑自建循环数组,或者用Apache Commons Collections的TreeList,不要硬啃ArrayList。


我个人在实际操作中的体会是:读集合类源码最忌功利地找答案,ArrayList的源码看似简单,但背后隐藏的工程权衡非常多——懒加载、1.5倍扩容、transient序列化、fail-fast迭代器,每一条设计都能在真实项目中找到对应的教训。以后面试如果被问到“ArrayList和LinkedList的区别”,与其背知识点,不如把扩容和迭代器的源码细节讲一遍,面试官往往会在这个深度上放过你。

最后再分享一个小技巧:如果你手边没有IDE,也可以通过JDK安装目录下的src.zip直接看源码,平时有空就把Java启动、集合、并发这几个核心包翻一遍,结合压力测试验证理解,比刷算法题积累底层直觉快得多。我的建议是先从今天这篇ArrayList开始,把它彻底吃透,你会发现后面读HashMap、LinkedList那种复杂的类都会轻松很多。

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

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

立即咨询