ArrayList扩容:打工人被迫搬家的血泪史

64 阅读5分钟

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

代码解读:

  1. 我们创建了一个空的 ArrayList,默认容量是 10
  2. 当我们添加第 10 个元素时,触发第一次扩容,容量变为 1610 * 1.5 = 15,向上取整到 16)。
  3. 继续添加元素,直到第 15 个元素时,再次触发扩容,容量变为 2416 * 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

从结果可以看出,随着元素数量的增加,扩容操作会导致性能下降。这是因为每次扩容都需要复制大量的数据。


优化建议:如何避免“被迫搬家”的血泪史?

既然频繁的扩容会影响性能,那么我们该如何优化呢?

  1. 预设初始容量
    如果你知道大致需要存储多少元素,可以在创建 ArrayList 时指定一个合适的初始容量。例如:

    ArrayList<String> list = new ArrayList<>(1000); // 预设初始容量为 1000
    
  2. 使用更高效的集合
    如果你需要频繁添加和删除元素,可以考虑使用 LinkedList(基于链表实现的集合),它在插入和删除操作上性能更好。

  3. 减少扩容频率
    如果你必须使用 ArrayList,可以通过设置一个较大的初始容量来减少扩容次数。例如:

    ArrayList<String> list = new ArrayList<>(1024); // 设置较大的初始容量
    

面试灵魂拷问:

  • ArrayList 的默认容量是多少?
    • 默认容量是 10
  • ArrayList 扩容时,默认会增加到原来的多少倍?
    • 默认是原来的 1.5 倍
  • 为什么 ArrayList 的扩容倍数选择 1.5 而不是 2 倍?
    • 这是为了在时间和空间之间取得一个平衡。如果选择 2 倍,虽然可以减少扩容次数,但会浪费更多的内存空间;而选择 1.5 倍则可以在一定程度上节省内存。
  • ArrayList 的扩容操作会影响性能吗?为什么?
    • 会的,因为每次扩容都需要创建新数组并复制旧元素,时间复杂度是 O(n)。