夜雨聆风学习资料网

ARTICLE · 1242795

ArrayList 源码分析:为什么默认容量是 10,扩容又是 1.5 倍?

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 倍。

假设采用默认构造方法,依次添加元素,普通情况下的容量变化如下:

扩容时机
扩容前
扩容后
添加第 1 个元素
0
10
添加第 11 个元素
10
15
添加第 16 个元素
15
22
添加第 23 个元素
22
33
添加第 34 个元素
33
49

这里要特别注意:

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,真正理解它们背后的实现以后,再看一些面试问题或者实际代码,也会更容易想明白。

相关学习资料