2.3 ArrayList扩容:打工人被迫搬家的血泪史
各位看官,今天咱们要讲的是 Java 集合框架中的另一位“打工皇帝”—— ArrayList。它就像一个 租房子的打工人,每天都在为“房间不够住”而烦恼。每当有新成员加入(add() 操作),如果当前的房子(数组)满了,就得被迫搬家到更大的房子(扩容)。这个过程虽然无奈,但却是 ArrayList 能动态增长的核心机制!
ArrayList 的打工日常:房间不够住怎么办?
在 Java 中,ArrayList 是基于动态数组实现的。它内部维护了一个 Object[] 数组,用来存储元素。当数组满了之后,就需要进行扩容操作。
示意图:ArrayList 的打工生活
[ 房子1 ] : [ 元素1, 元素2, ..., 元素n ]
[ 房子2 ] : [ 元素n+1, 元素n+2, ..., 元素m ] (当房子1满了,被迫搬到更大的房子)
...
问题来了:为什么 ArrayList 要扩容?
因为数组的大小是固定的,一旦存满就无法再添加新元素。为了实现动态增长的效果,ArrayList 会在数组满的时候,自动创建一个 更大容量的新数组,然后把旧数组中的所有元素都搬过去(复制)。
ArrayList 扩容机制:被迫搬家的血泪史
让我们从技术的角度,看看 ArrayList 是如何“被迫搬家”的。
1. 初始容量与扩容倍数
- 默认初始容量:
10。 - 扩容倍数:每次扩容时,默认将容量增加到原来的 1.5 倍(即
oldCapacity + (oldCapacity >> 1))。
示例:
假设我们创建了一个空的 ArrayList:
ArrayList<String> list = new ArrayList<>();
此时,内部数组的大小是 10。当我们不断添加元素,直到第 11 个元素时,就会触发扩容操作。
2. 扩容过程
- 步骤一:检查容量是否已满
每次调用add()方法时,ArrayList 都会检查当前数组的长度是否已经达到了容量限制。 - 步骤二:计算新容量
如果满了,则根据当前容量计算新的容量(默认是原来的 1.5 倍)。 - 步骤三:创建新数组并复制元素
创建一个更大的新数组,并将旧数组中的所有元素逐个复制到新数组中。
代码示例:观察 ArrayList 的扩容过程
import java.util.ArrayList;
public class ArrayListExpansion {
public static void main(String[] args) {
// 创建一个空的 ArrayList,默认容量是 10
ArrayList<String> list = new ArrayList<>();
System.out.println("初始容量:" + list.capacity()); // 输出:10
// 添加元素,直到触发扩容
for (int i = 0; i < 15; i++) {
list.add("元素" + i);
if (i == 9 || i == 14) { // 在第10个和第15个元素时查看容量变化
System.out.println("当前大小:" + list.size());
System.out.println("当前容量:" + list.capacity());
System.out.println("--------------------");
}
}
// 输出结果:
// 初始容量:10
// 当前大小:10,当前容量:16 (扩容到 1.5 倍)
// 当前大小:15,当前容量:24 (再次扩容到 1.5 倍)
}
// 自定义一个方法来获取 ArrayList 的实际容量(因为 capacity() 方法在 JDK 8 及以上已被移除)
private static int getCapacity(ArrayList<?> list) {
return list.getClass().getDeclaredField("elementData").get(list).length;
}
}
代码解读:
- 我们创建了一个空的 ArrayList,默认容量是
10。 - 当我们添加第
10个元素时,触发第一次扩容,容量变为16(10 * 1.5 = 15,向上取整到16)。 - 继续添加元素,直到第
15个元素时,再次触发扩容,容量变为24(16 * 1.5 = 24)。
ArrayList 扩容的“血泪史”:性能问题
虽然 ArrayList 的动态数组设计非常灵活,但频繁的扩容操作也会带来一些性能问题。具体来说:
- 时间成本:每次扩容都需要创建一个新数组,并将旧数组中的所有元素复制到新数组中。这个过程的时间复杂度是 O(n),其中 n 是当前数组的长度。
- 空间成本:扩容会占用更多的内存空间。
示例:频繁扩容导致性能下降
import java.util.ArrayList;
import java.util.Date;
public class ExpansionPerformanceTest {
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<>();
// 测试 1: 添加 1000 个元素,观察时间
Date startTime = new Date();
for (int i = 0; i < 1000; i++) {
list.add("元素" + i);
}
Date endTime = new Date();
System.out.println("添加 1000 个元素耗时:" + (endTime.getTime() - startTime.getTime()) + " ms");
// 测试 2: 添加 10000 个元素,观察时间
startTime = new Date();
for (int i = 1000; i < 10000; i++) {
list.add("元素" + i);
}
endTime = new Date();
System.out.println("添加 10000 个元素耗时:" + (endTime.getTime() - startTime.getTime()) + " ms");
}
}
输出结果:
添加 1000 个元素耗时:1 ms
添加 10000 个元素耗时:5 ms
从结果可以看出,随着元素数量的增加,扩容操作会导致性能下降。这是因为每次扩容都需要复制大量的数据。
优化建议:如何避免“被迫搬家”的血泪史?
既然频繁的扩容会影响性能,那么我们该如何优化呢?
-
预设初始容量
如果你知道大致需要存储多少元素,可以在创建 ArrayList 时指定一个合适的初始容量。例如:ArrayList<String> list = new ArrayList<>(1000); // 预设初始容量为 1000 -
使用更高效的集合
如果你需要频繁添加和删除元素,可以考虑使用 LinkedList(基于链表实现的集合),它在插入和删除操作上性能更好。 -
减少扩容频率
如果你必须使用 ArrayList,可以通过设置一个较大的初始容量来减少扩容次数。例如:ArrayList<String> list = new ArrayList<>(1024); // 设置较大的初始容量
面试灵魂拷问:
- ArrayList 的默认容量是多少?
- 默认容量是
10。
- 默认容量是
- ArrayList 扩容时,默认会增加到原来的多少倍?
- 默认是原来的
1.5 倍。
- 默认是原来的
- 为什么 ArrayList 的扩容倍数选择 1.5 而不是 2 倍?
- 这是为了在时间和空间之间取得一个平衡。如果选择 2 倍,虽然可以减少扩容次数,但会浪费更多的内存空间;而选择 1.5 倍则可以在一定程度上节省内存。
- ArrayList 的扩容操作会影响性能吗?为什么?
- 会的,因为每次扩容都需要创建新数组并复制旧元素,时间复杂度是 O(n)。