ArrayList 源码与扩容机制

候选人小王在字节面试时,被问到:"ArrayList 的默认容量是多少?"

他脱口而出:"16!"

面试官点点头:"第一次 add 之后呢?"

小王愣了一秒:"还是16吧?"

面试官笑而不语。

【面试官心理】 这道题我用来筛选两类候选人:一类是"背过八股"的,知道 ArrayList 默认容量是 16;另一类是"真正动手看过源码"的,知道 ArrayList 的默认容量是 0,第一次 add 才扩容到 10。能说出"为什么是 10 不是 16"的,基本是 P6+ 了。

一、ArrayList 的容量陷阱 🔴

1.1 默认容量到底是多少

很多同学会说 ArrayList 默认容量是 16。错!

ArrayList 的默认构造函数创建的 ArrayList,容量是 0

// JDK 1.8 ArrayList.java
public ArrayList() {
    super();
    this.elementData = EMPTY_ELEMENTDATA; // EMPTY_ELEMENTDATA = {}
}

是的,你没看错,就是空数组。只有当你第一次调用 add() 时,才会触发 grow() 方法扩容到 10:

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // 确认容量
    elementData[size++] = e;
    return true;
}

private void ensureCapacityInternal(int minCapacity) {
    if (elementData == EMPTY_ELEMENTDATA) {
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
        // DEFAULT_CAPACITY = 10
    }
    ensureExplicitCapacity(minCapacity);
}
⚠️

ArrayList 默认容量是 0,不是 16!只有第一次 add 时才扩容到 10。这个设计是为了节省内存,避免创建空 ArrayList 时就占用 16 个 Object 引用的空间。

1.2 常见翻车现场

我见过太多这样的代码:

// 生产事故代码
List<Order> orders = new ArrayList<>();
for (Order order : orderList) {
    orders.add(order); // 每次 add 都要检查容量
}

假设 orderList 有 100 万条数据,这个循环会触发多少次扩容?

  • 初始容量 0 → 扩容到 10
  • 10 → 扩容到 15(1.5 倍)
  • 15 → 扩容到 22
  • 22 → 扩容到 33
  • ...

计算一下:从 0 到 100 万,需要扩容大约 log(1000000/10) / log(1.5) ≈ 33 次

每次扩容都要 System.arraycopy() 迁移所有元素,O(n) 的迁移操作执行 33 次,总复杂度直接退化成 O(n²)

💡

正确做法:预估数据量,预设容量。

List<Order> orders = new ArrayList<>(orderList.size());

二、扩容机制源码解析 🔴

2.1 grow() 方法详解

ArrayList 的扩容核心在 grow() 方法:

private void grow(int minCapacity) {
    // 旧容量
    int oldCapacity = elementData.length;
    // 新容量 = 旧容量 + 旧容量/2 = 1.5 倍
    int newCapacity = oldCapacity + (oldCapacity >> 1);

    // 如果 1.5 倍还不够,直接用 minCapacity
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;

    // 不能超过 MAX_ARRAY_SIZE
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);

    elementData = Arrays.copyOf(elementData, newCapacity);
}

2.2 扩容流程图

graph TD
    A[add 元素] --> B{容量够吗?}
    B -->|不够| C[计算新容量 = old + old/2]
    B -->|够| D[直接插入]
    C --> E{新容量 `<` 最小需求?}
    E -->|是| F[新容量 = 最小需求]
    E -->|否| G{新容量 `>` 最大限制?}
    G -->|是| H[申请 Integer.MAX_VALUE]
    G -->|否| I[正常容量]
    F --> J[Arrays.copyOf 扩容]
    I --> J
    J --> K[插入元素]
    D --> K

2.3 为什么是 1.5 倍而不是 2 倍

面试官追问:"为什么 JDK 选择 1.5 倍扩容,而不是 2 倍?"

这是经典的设计权衡题:

扩容倍数优点缺点
2 倍(oldCapacity * 2扩容频率低内存浪费严重,50% 空闲
1.5 倍(oldCapacity * 1.5空间和时间平衡扩容稍频繁

空间角度:2 倍扩容意味着新数组有 50% 的空位;1.5 倍只有 33% 的空位。

时间角度:扩容频率和扩容倍数成反比。1.5 倍扩容频率略高,但迁移成本更小。

JDK 设计者的权衡:1.5 倍是一个经过数学验证的经验值,既不会因为扩容太频繁而影响性能,也不会因为太激进而导致严重内存浪费。

📖 点击展开 1.5 倍扩容的数学推导

考虑 n 次插入的总代价:

总代价 = 扩容次数 × 迁移成本
       = Σ (每次迁移的元素数量)

设扩容倍数为 k,初始容量为 c,第 i 次扩容后的容量为 c * k^i

当插入第 N 个元素时:

  • 如果用 k 倍扩容,需要 log_k(N/c) 次扩容
  • 每次扩容迁移的元素数量呈指数增长

1.5 被认为是 均摊复杂度最优 的选择。

2.4 System.arraycopy() 元素迁移

JDK 使用 Arrays.copyOf() 进行数组复制:

elementData = Arrays.copyOf(elementData, newCapacity);

// 内部实现
public static <T> T[] copyOf(T[] original, int newLength) {
    T[] copy = (T[]) java.lang.reflect.Array.newInstance(
        original.getClass().getComponentType(), newLength);
    System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength));
    return copy;
}

System.arraycopy() 是 native 方法,直接操作内存复制,效率很高。但再高的效率也扛不住频繁调用。

【学习小结】 ArrayList 扩容机制要点:

  • 默认容量是 0,第一次 add 才扩容到 10
  • 扩容倍数是 1.5(old + (old >> 1)
  • 扩容通过 System.arraycopy() 迁移元素
  • 循环中添加必须预设容量

三、容量设置的艺术 🟡

3.1 构造函数对比

ArrayList 有三种构造函数:

// 1. 无参构造函数 - 容量为 0
ArrayList list1 = new ArrayList();

// 2. 指定初始容量 - 推荐做法
ArrayList list2 = new ArrayList<>(100);

// 3. 传入集合 - 容量等于集合大小
ArrayList list3 = new ArrayList<>(existingCollection);

3.2 预设容量的最佳实践

// 场景 1:已知数据量
List<User> users = new ArrayList<>(expectedUserCount);

// 场景 2:批量添加
List<Order> orders = new ArrayList<>(orderList.size());
orders.addAll(orderList);

// 场景 3:不确定但可能很大
// 使用 Integer.MAX_VALUE 有风险!
List<Data> data = new ArrayList<>(1 << 20); // 约 100 万

// 场景 4:真正需要无限容量
// 用 ArrayList 的容量没有上限
// 但数组本身最大是 Integer.MAX_VALUE

3.3 trimToSize() 的使用

如果 ArrayList 已经使用完毕,不再添加元素,可以调用 trimToSize() 释放多余的容量:

List<String> words = new ArrayList<>(1000);
// ... 添加 100 个元素
words.trimToSize(); // 容量从 1000 降到 100,节省内存
💡

trimToSize() 用于优化内存占用。在内存敏感场景(如 Android 开发、微服务处理大数据)很有用。

四、边界条件与追问 🟡

4.1 最大容量限制

ArrayList 的最大容量不是无限制的:

private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0) // 整数溢出
        throw new OutOfMemoryError();
    return (minCapacity > MAX_ARRAY_SIZE) ?
        Integer.MAX_VALUE : MAX_ARRAY_SIZE;
}

为什么是 Integer.MAX_VALUE - 8?因为一些 JVM 实现会在数组头部预留 8 字节的 header 空间。

4.2 扩容阈值计算

面试官追问:"给定初始容量 0,add() 10 次会扩容几次?"

add(1):  0 → 10   (第 1 次扩容)
add(2-10): 10 → 15 (第 2 次扩容)
add(11): 15 → 22  (第 3 次扩容)
...

答案是:10 次添加,触发 2 次扩容。

4.3 Integer.MAX_VALUE 陷阱

ArrayList<int[]> list = new ArrayList<>();
try {
    for (int i = 0; i < Integer.MAX_VALUE; i++) {
        list.add(new int[1000]); // 每次分配 8KB
    }
} catch (OutOfMemoryError e) {
    // 在到达 MAX_ARRAY_SIZE 之前就会 OOM
    // 因为数组最大只能到 Integer.MAX_VALUE - 8
}

【学习小结】 边界条件要点:

  • 最大容量 MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8
  • 扩容超过限制会抛出 OutOfMemoryError
  • 注意整数溢出风险

五、生产避坑清单 🟡

5.1 常见问题代码

// ❌ 错误:循环内不预设容量
public List<User> getUsers(List<Long> ids) {
    List<User> users = new ArrayList<>();
    for (Long id : ids) {
        users.add(userService.getUser(id));
    }
    return users;
}

// ✅ 正确:预设容量(如果 ids 可预估大小)
public List<User> getUsers(List<Long> ids) {
    List<User> users = new ArrayList<>(ids.size());
    for (Long id : ids) {
        users.add(userService.getUser(id));
    }
    return users;
}

5.2 GC 调优案例

我们的订单系统在重构前,GC 频繁停顿。排查后发现:

// 原来的代码
List<OrderItem> items = new ArrayList<>();
for (Order order : orders) {
    items.addAll(order.getItems()); // 触发多次小扩容
}

优化后:

// 优化后:预估总大小
int totalSize = orders.stream()
    .mapToInt(o -> o.getItems().size())
    .sum();
List<OrderItem> items = new ArrayList<>(totalSize);
for (Order order : orders) {
    items.addAll(order.getItems());
}

GC 停顿时间从 120ms 降到 15ms

5.3 容量设置公式

// 经验公式:避免扩容的最优初始容量
int optimalCapacity = (int) (expectedSize / 0.75) + 1;

// 或者直接用 expectedSize + 1
List<T> list = new ArrayList<>(expectedSize + 1);
💡

预设容量时,可以适当多预留 10%~20% 的 buffer,防止估算偏差导致额外扩容。

六、ArrayList vs Vector 🟢

虽然 ArrayList 最常用,但 JDK 中还有另一个 List 实现——Vector:

维度ArrayListVector
线程安全是(synchronized)
扩容倍数1.5 倍2 倍
性能
使用建议单线程首选已废弃
// Vector 的扩容
private void grow(int capacity) {
    int oldCapacity = capacity * 2; // 2 倍扩容
}

// ArrayList 的扩容
private void grow(int minCapacity) {
    int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5 倍
}
⚠️

Vector 是 JDK 1.0 的遗留类,已不推荐使用。如果需要线程安全的 List,用 Collections.synchronizedList()CopyOnWriteArrayList

七、工程选型建议 🟡

7.1 选型决策树

graph TD
    A[需要 List?] --> B{需要线程安全?}
    B -->|是| C[数据量小?]
    B -->|否| D[需要随机访问?]
    D -->|否| E[考虑 LinkedList]
    D -->|是| F[ArrayList 首选]
    C -->|是| G[CopyOnWriteArrayList]
    C -->|否| H[Collections.synchronizedList]

7.2 最佳实践

// 单线程:ArrayList 是 95% 场景的首选
List<String> list = new ArrayList<>(expectedSize);

// 读多写少:CopyOnWriteArrayList
List<String> readHeavy = new CopyOnWriteArrayList<>();

// 批量处理:用 addAll 代替循环 add
list.addAll(batchData);

// 内存敏感:trimToSize
list.trimToSize();

【学习小结】 工程选型要点:

  • 单线程用 ArrayList,预设容量
  • 多线程写多用 ConcurrentHashMap 或 synchronizedList
  • 多线程读多写少用 CopyOnWriteArrayList
  • 循环中添加必须预设容量

八、追问升级

8.1 第一层追问

面试官:"ArrayList 的 add(int index, E element) 方法插入元素的时间复杂度是多少?"

候选人:"O(1)?"

面试官:"为什么不是?"

正确回答:O(n)。因为需要将 index 之后的元素全部后移一位,时间复杂度是 O(n)。

8.2 第二层追问

面试官:"add(int index, E element)add(E e) 有什么区别?"

候选人:...

正确回答

  • add(E e):尾部添加,O(1) 均摊
  • add(int index, E e):中间插入,O(n)

8.3 第三层追问

面试官:"ArrayList 删除元素后,容量会缩小吗?"

候选人:...

正确回答:ArrayList 不会自动缩小容量。如果需要,用 trimToSize() 手动释放。

8.4 第四层追问

面试官:"ArrayList 的 iterator 是 fail-fast 的,什么意思?"

候选人:...

正确回答:fail-fast 意味着一旦检测到结构性修改(在迭代过程中调用 add/remove/clear),就会抛出 ConcurrentModificationException。这是检测并发修改的机制,不是线程安全保证。