ARTICLE · 1242795
ArrayList 源码分析:为什么默认容量是 10,扩容又是 1.5 倍?
平时写 Java 代码,ArrayList 应该算是使用频率非常高的集合之一。
比如查询数据库后封装列表、处理接口返回数据、临时存储一批对象,基本都能用到它。
但如果面试官继续追问:
• ArrayList 底层是怎么实现的? • new ArrayList() 后,底层数组长度是多少? • 为什么扩容通常是原来的 1.5 倍? • 为什么按下标查询很快,中间插入和删除却比较慢? • 为什么遍历时删除元素可能抛出 ConcurrentModificationException?
这些问题看似基础,如果没有看过源码,还真不一定能解释清楚。
现在有了 AI,写一个集合操作基本不是什么难事。但理解这些底层实现,对于排查问题、选择合适的数据结构,以及面试时解释原理,依然很有必要。
今天就结合源码,把 ArrayList 的核心实现梳理一下。
说明:本文主要基于 JDK 8 的 ArrayList 源码,其他 JDK 版本在部分方法实现上有所不同。
一、ArrayList 的底层到底是什么?
先说结论:
ArrayList 底层是一个可以动态扩容的 Object 数组。
普通数组在创建时就需要确定长度,例如:
String[] arr = new String[10];数组创建后,长度就固定了。
如果想存储超过 10 个元素,就需要创建一个更大的数组,再把原来的数据复制过去。
ArrayList 帮我们封装了这些操作。
来看几个核心成员变量(JDK 8):
private static final int DEFAULT_CAPACITY = 10;
transient Object[] elementData;
private int size;这里重点理解三个概念:
1. DEFAULT_CAPACITY
默认容量常量,值为 10。
2. elementData
真正用来存储元素的底层数组。
3. size
当前集合中实际包含的元素数量。
这里很容易混淆:size 和 capacity(容量)不是一回事。
比如一个 ArrayList,底层数组长度为 10,但只添加了 3 个元素。
那么:
size = 3
capacity = 10
底层数组:
[A][B][C][ ][ ][ ][ ][ ][ ][ ]size() 返回的是 3,而不是 10。
而容量 10 代表底层数组目前最多能容纳 10 个元素,不需要再次扩容。
二、new ArrayList() 之后,容量就是 10 吗?
这是 ArrayList 源码里一个比较容易被问到的问题。
先看 JDK 8 的无参构造方法:
public ArrayList() {
this.elementData =
DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}其中:
private static final Object[]
DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};可以看到,创建 ArrayList 对象时,并没有直接创建长度为 10 的数组。
而是先使用一个空数组。
例如:
List<String> list = new ArrayList<>();此时实际元素数量为 0,底层数组长度也是 0。
只有第一次添加元素时,才会分配默认的 10 个数组位置。
为什么这么设计?
因为实际开发中,创建了一个 ArrayList,不代表一定会往里面添加数据。
如果每次创建都立即分配 10 个位置,那么大量空集合就会产生不必要的数组空间开销。
采用延迟分配的方式,可以避免这部分开销。
当然,如果明确知道需要存储多少元素,也可以指定初始容量:
List<String> list = new ArrayList<>(100);这样会直接创建容量为 100 的底层数组,但集合的 size 依然是 0。
三、ArrayList 添加元素时,发生了什么?
接下来看看最常用的 add() 方法。
JDK 8 的核心代码非常简单:
public boolean add(E e) {
ensureCapacityInternal(size + 1);
elementData[size++] = e;
return true;
}这段代码主要做两件事。
第一步:检查底层数组容量是否足够。
调用 ensureCapacityInternal(),如果容量不够,就进行扩容。
第二步:把元素放到数组末尾。
通过 elementData[size++] = e 完成赋值,同时让实际元素数量加 1。
整个过程并不复杂。
例如:
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");第一次添加元素时,底层数组从空数组扩展到默认容量 10。
之后添加 B、C,由于剩余容量足够,就不需要再次扩容。
等到添加第 11 个元素时,才需要重新分配更大的数组。
这也引出了 ArrayList 最核心的一部分:扩容机制。
四、ArrayList 为什么扩容 1.5 倍?
JDK 8 中,负责扩容的核心方法是 grow()。
先看其中的关键逻辑。
下面省略了大容量边界处理,只保留普通情况下的扩容计算:
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity < minCapacity) {
newCapacity = minCapacity;
}
elementData = Arrays.copyOf(
elementData, newCapacity
);重点就是这一行:
int newCapacity = oldCapacity + (oldCapacity >> 1);>> 1 是右移一位,对于这里的非负整数容量,可以理解为除以 2 并向下取整。
因此:
新容量 = 旧容量 + 旧容量 / 2也就是我们经常听到的 扩容约 1.5 倍。
假设采用默认构造方法,依次添加元素,普通情况下的容量变化如下:
这里要特别注意:
ArrayList 并不是每次扩容都严格等于旧容量的 1.5 倍。
如果一次添加很多元素,计算出来的容量仍然小于实际所需容量,就需要按照最小需求容量进行扩容。
而且,当数组过大时,还需要考虑整数溢出和数组长度限制。
为什么不每次只增加一个位置?
因为扩容不是简单地把数组长度修改一下。
真正扩容时,需要重新分配数组,并复制原有元素。
如果每添加一个元素就扩容一次,频繁复制会带来很大的额外开销。
为什么不直接扩大 10 倍?
扩大得太多虽然能减少扩容次数,但也可能浪费更多暂时用不到的内存。
因此,1.5 倍可以理解为在扩容频率和空间开销之间的一种折中策略。
这也解释了为什么 ArrayList 在尾部添加元素,通常具有比较好的性能。
虽然偶尔会触发数组复制,但从连续添加大量元素的整体过程来看,尾部追加的均摊时间复杂度是 O(1)。
补充一点:JDK 21 的扩容实现已经与 JDK 8 不完全相同,例如使用了 ArraysSupport.newLength() 来计算新数组长度,但仍然采用以旧容量的一半作为优选增长量的思路。
五、为什么 ArrayList 查询快,插入和删除相对慢?
这个问题其实和它的底层数组结构直接有关。
1. 查询为什么快?
例如:
String name = list.get(2);ArrayList 可以通过数组下标直接定位到对应元素。
不需要从第一个元素开始逐个寻找。
因此,按下标获取元素的时间复杂度是:
O(1)。
但是,如果使用的是:
list.contains("Java");那就不一样了。
因为它需要检查集合中的元素,通常涉及遍历查找,时间复杂度为 O(n)。
所以不能简单认为 ArrayList 的所有查询操作都是 O(1)。
2. 为什么中间插入比较慢?
假设集合中有:
[A][B][C][D]现在执行:
list.add(1, "X");希望在下标 1 的位置插入 X。
那么原来的 B、C、D 就需要依次向后移动:
插入前:
[A][B][C][D]
插入后:
[A][X][B][C][D]ArrayList 底层使用 System.arraycopy() 完成这部分数组移动。
相关核心逻辑如下:
System.arraycopy(
elementData,
index,
elementData,
index + 1,
size - index
);如果插入的位置比较靠前,需要移动的元素就比较多。
因此,在指定位置插入元素,最坏情况下的时间复杂度为 O(n)。
3. 删除元素也是一样的道理
比如删除 B:
删除前:
[A][B][C][D]
删除后:
[A][C][D]后面的 C、D 需要向前移动。
JDK 8 的删除逻辑中,还有这样一行代码:
elementData[--size] = null;它的作用是将原来最后一个有效位置上的引用清空,避免数组继续持有已经移除的对象引用。
当没有其他有效引用时,对象才有机会被垃圾回收。
另外,删除元素并不会让 ArrayList 自动缩小底层数组容量。
这也是 size 和 capacity 需要区分的原因之一。
六、为什么遍历 ArrayList 时删除元素可能报错?
平时写代码,有时候会遇到这个异常:
java.util.ConcurrentModificationException它和 ArrayList 的 fail-fast(快速失败)机制有关。
来看一个简单的例子:
List<String> list = new ArrayList<>(
Arrays.asList("A", "B", "C")
);
Iterator<String> iterator = list.iterator();
list.add("D");
iterator.next();在这个例子中,创建迭代器之后,又直接通过 list.add() 修改了集合结构。
再次调用迭代器的 next(),就会抛出 ConcurrentModificationException。
这是因为 ArrayList 内部通过 modCount 记录结构性修改,并由迭代器检查修改次数是否符合预期。
如果在迭代过程中,集合被不符合迭代器规则的方式修改,就可能触发快速失败。
需要注意:
这个异常不一定意味着发生了多线程并发问题,单线程也可能出现。
如果想在遍历时删除符合条件的元素,可以使用:
list.removeIf("B"::equals);或者使用迭代器自身的 remove() 方法。
另外,fail-fast 是一种尽力检测错误的机制,不能依靠它保证线程安全。
七、实际开发中,使用 ArrayList 要注意什么?
理解了源码,再回头看日常开发,就能发现几个值得注意的地方。
1. 已知数据量时,可以考虑指定初始容量。
比如预计需要向集合中添加 1000 条数据:
List<User> users = new ArrayList<>(1000);这样可以减少后续添加过程中的扩容次数。
但不是说所有集合都应该提前分配很大的容量。
如果最终只存储几十条数据,过大的初始容量反而会浪费空间。
2. ArrayList 不是线程安全的。
如果只是方法内部的局部集合,而且没有被多个线程共享,通常不用额外考虑并发修改。
但如果将 ArrayList 放到共享对象中,让多个线程同时进行添加、删除等操作,就可能出现数据竞争和结果异常。
需要根据实际场景选择同步控制或者合适的并发集合。
例如读多写少的场景,可以考虑 CopyOnWriteArrayList,但它的写操作存在复制开销,并不适合所有场景。
3. 不要认为 LinkedList 的插入操作一定比 ArrayList 快。
虽然链表在已经定位到节点的位置插入时,不需要像数组一样移动大量元素,但 LinkedList 根据下标定位节点仍然需要遍历。
因此,如果业务主要是随机访问、遍历和尾部追加,ArrayList 通常是很自然的选择。
不要只看单个操作的理论复杂度,还应该结合实际使用方式来判断。
八、面试时,ArrayList 应该怎么回答?
最后把几个常见问题总结一下。
1. ArrayList 底层是什么?
底层是 Object[] 数组,支持动态扩容。
2. 默认初始化容量是多少?
以 JDK 8 为例,无参构造时使用空数组,第一次添加元素时才扩展到默认容量 10。
3. 扩容机制是什么?
普通情况下优先按照旧容量约 1.5 倍扩容,如果仍然无法满足实际需求,就按照最小所需容量调整,并处理大数组的边界情况。
4. ArrayList 查询和插入的时间复杂度是多少?
按下标查询是 O(1),尾部添加均摊 O(1),指定位置插入或删除最坏为 O(n)。
5. ArrayList 是线程安全的吗?
不是。如果多个线程共享同一个 ArrayList,并且涉及并发结构性修改,需要额外的同步措施或更合适的集合实现。
6. 什么是 fail-fast?
迭代器通过检查集合的结构性修改状态,尽力发现不符合预期的修改,并可能抛出 ConcurrentModificationException,但它不是线程安全保障。
写在最后
ArrayList 算是 Java 中比较基础的集合,平时使用起来也非常简单。
但从源码往下看,会发现它的很多设计其实都是在做取舍。
比如为什么采用动态数组、为什么需要扩容、为什么预留容量、为什么删除元素后要清空引用,以及为什么不保证线程安全。
理解这些内容,并不是为了在写业务代码时手动实现一个 ArrayList。
更多的是让自己在使用集合时,知道它适合什么场景,可能存在哪些性能开销,以及遇到问题应该从哪里分析。
很多平时习以为常的 API,真正理解它们背后的实现以后,再看一些面试问题或者实际代码,也会更容易想明白。















































































