Java 实例 – 数组扩容(完整指南)

Java 实例 – 数组扩容:从零开始理解动态增长的本质

在 Java 编程中,数组是一个基础但极其重要的数据结构。它以连续的内存空间存储相同类型的元素,访问效率极高。然而,它的“固定长度”特性却常常让初学者感到困扰——一旦创建,长度就不能改变。这就像你买了一个固定大小的行李箱,无论旅途多长,都只能装下那么多东西。

那么问题来了:当数据量不断增长,原有的数组容量不够用了怎么办?这就引出了今天的核心话题——Java 实例 – 数组扩容。我们不仅要学会“怎么扩”,更要理解“为什么扩”以及“如何高效扩”。

在实际开发中,比如我们要从文件读取用户信息、处理网络请求返回的数据列表,这些数据量往往是动态变化的。如果每次都手动创建新数组、复制旧数据,不仅繁琐,还容易出错。因此掌握数组扩容机制,是迈向成熟 Java 开发者的关键一步。


数组的“刚性”与“柔性”之别

Java 中的原生数组(Array)是“刚性”的。一旦声明,长度就固定了。比如:

int[] numbers = new int[5];

这条语句创建了一个长度为 5 的整型数组,只能存放 5 个整数。如果你想添加第 6 个元素,直接写 numbers[5] = 10; 会抛出 ArrayIndexOutOfBoundsException 异常。

这就像你有一张 5 个座位的火车票,但车上来了 6 个人,第 6 个人只能站着,或者你得重新买一张票(即创建新数组)。

但现实世界中的数据是流动的。我们不可能提前知道需要多少个元素。这时候就需要“扩容”——动态增加数组容量。


手动数组扩容的实现原理

虽然 Java 提供了 ArrayList 这种自动扩容的集合类,但了解其底层实现,能让我们真正掌握数据结构的本质。下面我们来手写一个简单的动态数组类,模拟数组扩容过程。

创建数组与初始化

public class DynamicArray {
    private int[] data;        // 存储数据的数组
    private int size;          // 当前实际元素个数
    private int capacity;      // 当前数组容量

    // 构造函数:初始化容量为 10
    public DynamicArray() {
        this.capacity = 10;
        this.data = new int[capacity];
        this.size = 0;
    }
}

注释说明:

  • data 是真正的存储容器,用原生数组实现。
  • size 表示当前已使用的元素数量,初始为 0。
  • capacity 是数组当前的最大容量,初始设为 10,方便后续演示扩容。

添加元素并触发扩容

public void add(int value) {
    // 如果当前容量已满,触发扩容
    if (size >= capacity) {
        // 扩容逻辑:容量翻倍
        int newCapacity = capacity * 2;
        int[] newData = new int[newCapacity];

        // 将旧数组所有元素复制到新数组
        for (int i = 0; i < size; i++) {
            newData[i] = data[i];
        }

        // 更新引用:让 data 指向新数组
        data = newData;
        capacity = newCapacity; // 更新容量
    }

    // 将新元素放入当前 size 位置
    data[size] = value;
    size++; // 元素数量加 1
}

注释说明:

  • add() 方法用于添加一个整数。
  • 每次添加前检查 size >= capacity,判断是否需要扩容。
  • 扩容策略为“翻倍”,即新容量 = 原容量 × 2。
  • 使用 new int[newCapacity] 创建新数组,然后通过 for 循环将旧数据复制过去。
  • 最后更新 data 引用和 size

这个过程就像你原本的行李箱满了,于是你买了一个更大的箱子(容量翻倍),把原来的东西一件件搬进去,再继续装新东西。


扩容策略的优化:为什么是“翻倍”?

很多人会问:为什么扩容时要翻倍?难道不能每次多增加 1 个?比如从 10 变成 11?

我们来对比两种策略:

扩容方式 每次增加 1 个 每次翻倍
初始容量 10 10
第 11 次添加 11 10(不扩容)
第 12 次添加 12 10(不扩容)
第 13 次添加 13 10(不扩容)
第 14 次添加 14 10(不扩容)
第 15 次添加 15 20(扩容)

你会发现,如果每次只增加 1 个,当数据量大时,频繁扩容会导致大量复制操作。假设你要添加 1000 个元素,那么可能要进行 990 次扩容,每次都要复制前面的所有数据,时间复杂度接近 O(n²)。

而“翻倍”策略能显著降低复制频率。因为每次扩容后,下一次需要扩容的临界点是当前容量的两倍。这样,整体的平均时间复杂度可以控制在 O(1),即“摊还时间复杂度”为常数。

比喻:翻倍扩容就像你每次搬家都选一个大一倍的房子,而不是每次只多加一个房间。虽然大房子可能暂时空着,但长远来看,搬家次数少多了。


实际测试:验证扩容行为

下面我们写一个测试类,演示扩容过程:

public class TestDynamicArray {
    public static void main(String[] args) {
        DynamicArray arr = new DynamicArray();

        // 添加 15 个元素,观察扩容
        for (int i = 1; i <= 15; i++) {
            arr.add(i);
            System.out.println("添加 " + i + ",当前容量:" + arr.capacity + ",实际大小:" + arr.size);
        }
    }
}

输出结果如下:

添加 1,当前容量:10,实际大小:1
添加 2,当前容量:10,实际大小:2
...
添加 10,当前容量:10,实际大小:10
添加 11,当前容量:20,实际大小:11
添加 12,当前容量:20,实际大小:12
...
添加 15,当前容量:20,实际大小:15

注释说明:

  • 前 10 次添加,容量未变,说明未扩容。
  • 第 11 次添加时,容量从 10 扩展到 20,说明触发扩容。
  • 之后的添加不再扩容,直到容量再次不足。

这个实验直观地展示了扩容的“触发点”和“效果”。


常见误区与最佳实践

在实现数组扩容时,有几个容易踩坑的地方,需要特别注意:

误区一:扩容后未更新引用

// ❌ 错误写法
int[] temp = new int[capacity * 2];
for (int i = 0; i < size; i++) {
    temp[i] = data[i];
}
// 未将 data 指向新数组!

如果忘了 data = temp;,后续操作仍然使用旧数组,会导致数据丢失或错误。

误区二:扩容策略过于激进或保守

  • 每次只增加 1 个:频繁扩容,性能差。
  • 每次翻倍:虽然好,但可能浪费内存(如只多加 1 个就扩容到 1000)。
  • 建议:可以结合实际情况,比如初始容量设为 10 或 16,扩容时增加 50% 或使用 Math.max(10, capacity * 1.5)

误区三:未处理边界情况

比如 size 为 0 时,添加元素是否正常?答案是肯定的。但必须确保 data 数组被正确初始化。


总结:掌握“Java 实例 – 数组扩容”的核心价值

通过本篇讲解,我们不仅实现了自己的动态数组,还深入理解了扩容的原理、策略与实际应用。掌握了这些知识,你就能:

  • 理解 ArrayList 底层是如何工作的;
  • 在需要高性能场景下,自定义合适的扩容策略;
  • 避免因数组容量不足导致的运行时异常;
  • 培养对内存管理和时间复杂度的敏感度。

数组扩容不是简单的“复制粘贴”,而是一门平衡性能与内存的工程艺术。当你能在代码中自如地处理这种动态增长的需求时,你已经迈出了成为高级 Java 开发者的重要一步。

最后提醒:虽然我们手写了一个动态数组,但在实际项目中,优先使用 ArrayList<E>。它是线程不安全但性能极佳的集合类,内部实现正是基于数组扩容机制,且经过长期优化,远比我们手写的更健壮、更高效。

记住:理解底层,是为了更好地使用工具。而“Java 实例 – 数组扩容”正是连接基础与进阶的桥梁。