ArrayList 扩容机制:从源码细节到工程实践

一、先把两个概念分清楚:size 和 capacity

ArrayList 扩容之前,先区分两个容易混淆的概念:

  • size:当前列表中实际存放的元素个数。
  • capacity:底层数组 elementData 的长度,也就是当前最多能容纳多少个元素而不触发扩容。

这两个值不是一回事。

java 复制代码
List<String> list = new ArrayList<>(100);
list.add("A");

System.out.println(list.size()); // 1

这段代码里,size() 返回的是 1,因为列表里只有一个元素。但底层数组的容量是 100。日常开发中我们通常只能直接看到 size,看不到 capacity,这也是很多人误解 ArrayList 初始化和扩容机制的原因。

理解这两个概念后,再看 ArrayList 的初始化和扩容,就会清楚很多。


二、ArrayList 的底层结构

2.1 elementData:真正存储元素的数组

ArrayList 底层通过数组存储元素。数组字段在源码中通常叫 elementData,类型是 Object[]

简化理解如下:

java 复制代码
transient Object[] elementData;
private int size;
  • elementData:底层数组,负责保存元素引用。
  • size:当前已经存储的元素个数。

ArrayList 支持按下标快速访问,本质上就是数组随机访问的能力:

java 复制代码
String value = list.get(10);

只要下标合法,get(index) 可以直接通过数组下标定位元素。但数组的缺点也很明显:长度固定,容量不够时不能在原数组上直接变长,只能创建一个更大的新数组,再把旧数组中的元素复制过去。

这就是 ArrayList 扩容机制的基础。

2.2 DEFAULT_CAPACITY:默认容量不是构造时立即分配

很多文章会说:ArrayList 默认容量是 10。

这个说法不算完全错,但容易误导。更准确的说法是:

使用无参构造创建 ArrayList 时,底层数组初始长度是 0;当第一次添加元素时,才会扩容到默认容量 10。

也就是说,DEFAULT_CAPACITY = 10 表示"首次写入时的默认容量",不是"无参构造完成后立即拥有长度为 10 的数组"。

2.3 为什么要延迟初始化

延迟初始化的好处是减少无意义的内存占用。

在后端系统中,一个对象里可能会有多个集合字段:

java 复制代码
public class OrderContext {
    private List<String> validationErrors = new ArrayList<>();
    private List<String> warnings = new ArrayList<>();
    private List<String> operationLogs = new ArrayList<>();
}

如果每个 new ArrayList<>() 都立即分配长度为 10 的数组,而这些列表大多数时候并不会写入元素,就会产生不必要的内存浪费。

所以 ArrayList 采用延迟分配:先用空数组占位,真正添加元素时再分配容量。


三、初始化容量:无参构造和有参构造的区别

3.1 无参构造:初始容量为 0,首次 add 扩到 10

常见写法如下:

java 复制代码
List<String> list = new ArrayList<>();

这种方式创建出来的 ArrayList,在没有添加元素之前,底层数组并不会分配 10 个槽位。第一次执行 add 时,才会触发容量初始化。

java 复制代码
List<String> list = new ArrayList<>();
list.add("A"); // 首次添加时触发扩容,容量通常变为 10

这里要注意:

  • list.size() 是 1。
  • 底层数组容量通常是 10。
  • 10 是默认容量,不是当前元素个数。

3.2 有参构造:按指定容量创建数组

如果能预估列表规模,可以使用有参构造:

java 复制代码
List<Long> userIds = new ArrayList<>(1000);

这表示底层数组一开始就按容量 1000 创建,后续添加元素只要不超过 1000,就不会触发扩容。

这种写法适合列表规模比较明确的场景,例如:

  • 数据库查询结果转换为 DTO;
  • 批量消息消费后组装处理结果;
  • RPC 或 HTTP 接口返回列表后做二次加工;
  • 从已有集合中映射出另一个集合。

例如:

java 复制代码
List<UserDTO> result = new ArrayList<>(users.size());
for (User user : users) {
    result.add(toDTO(user));
}

如果 users.size() 已经明确,提前指定容量可以减少扩容次数,也能让代码表达出"结果列表规模与源列表一致"的意图。

3.3 不要盲目设置过大的初始容量

预分配容量不是越大越好。

java 复制代码
List<String> list = new ArrayList<>(1000000);

如果最终只放入几十个元素,这个数组的大部分空间都会闲置。对于高并发服务,这类过度预分配可能放大堆内存压力,甚至增加 GC 负担。

比较稳妥的原则是:

  • 能明确预估规模时,指定合理初始容量;
  • 无法预估规模时,使用默认构造即可;
  • 对可能很大的列表,要考虑分页、流式处理或分批处理,而不是单纯扩大 ArrayList 容量。

四、add 操作的扩容流程

4.1 第一次 add:从空数组扩到默认容量

使用无参构造时:

java 复制代码
List<Integer> numbers = new ArrayList<>();
numbers.add(1);

第一次 add 会发现当前底层数组容量不足,于是触发扩容。对于 JDK 8/11 常见实现,首次扩容容量通常是 10。

可以理解为:

text 复制代码
当前容量 = 0
新增 1 个元素后所需最小容量 = 1
无参构造使用默认容量规则
最终容量 = max(10, 1) = 10

4.2 超过当前容量后,按 1.5 倍尝试扩容

当列表已经有 10 个元素,再添加第 11 个元素时,当前容量不够,需要扩容。

ArrayList 的新容量计算逻辑可以简化理解为:

java 复制代码
int newCapacity = oldCapacity + (oldCapacity >> 1);

oldCapacity >> 1 表示旧容量右移一位,相当于取旧容量的一半。因此新容量约等于旧容量的 1.5 倍。

例如:

text 复制代码
旧容量 10 -> 新容量 15
旧容量 15 -> 新容量 22
旧容量 22 -> 新容量 33

这里不是用浮点数乘以 1.5,而是使用整数运算。所以当旧容量是 15 时:

text 复制代码
15 + (15 >> 1) = 15 + 7 = 22

这也是为什么扩容结果不是 22.5,而是 22。

4.3 1.5 倍不够时,直接扩到所需最小容量

如果一次性新增很多元素,1.5 倍扩容可能仍然不够。

例如当前容量是 10,当前 size 是 10,此时一次性需要容纳 100 个元素,那么最小所需容量是 100。按 1.5 倍只能扩到 15,显然不够。

这种情况下,新容量会直接取所需最小容量。

text 复制代码
旧容量 = 10
1.5 倍后容量 = 15
最小所需容量 = 100
最终新容量 = 100

这个规则在 addAll 场景中特别常见。

4.4 扩容的本质:创建新数组并复制元素

数组长度固定,扩容不是在原数组上直接变长,而是:

  1. 创建一个更大的新数组;
  2. 把旧数组中的元素复制到新数组;
  3. elementData 指向新数组;
  4. 旧数组如果没有其他引用,后续由 GC 回收。

简化理解如下:

java 复制代码
Object[] oldArray = elementData;
Object[] newArray = Arrays.copyOf(oldArray, newCapacity);
elementData = newArray;

这意味着扩容会带来两类成本:

  • CPU 成本:复制旧数组元素;
  • 内存成本:扩容瞬间新旧数组可能同时存在,形成短时间的内存峰值。

对于小列表,这个成本通常可以忽略。对于大列表,尤其是高并发接口中频繁构建大集合时,这个成本就需要关注。


五、addAll 操作的扩容规则

5.1 空集合 addAll:容量取默认容量和新增元素数的较大值

看一个例子:

java 复制代码
List<Integer> source = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> target = new ArrayList<>();

target.addAll(source);

target 是无参构造的空集合,第一次 addAll 时,新增元素个数是 5。此时底层容量通常会扩到 10。

可以理解为:

text 复制代码
新增元素数 = 5
默认容量 = 10
最终容量 = max(10, 5) = 10

如果新增元素超过 10:

java 复制代码
List<Integer> source = new ArrayList<>(20);
for (int i = 0; i < 20; i++) {
    source.add(i);
}

List<Integer> target = new ArrayList<>();
target.addAll(source);

此时所需最小容量是 20,超过默认容量 10,因此目标列表会直接扩到 20。

text 复制代码
新增元素数 = 20
默认容量 = 10
最终容量 = max(10, 20) = 20

5.2 非空集合 addAll:容量至少满足 size + numNew

对于非空集合,addAll 的核心是先计算最小所需容量:

text 复制代码
最小所需容量 = 当前 size + 新增元素数量

例如:

java 复制代码
List<Integer> target = new ArrayList<>(10);
for (int i = 0; i < 10; i++) {
    target.add(i);
}

List<Integer> source = Arrays.asList(10, 11, 12, 13, 14);
target.addAll(source);

此时:

text 复制代码
当前 size = 10
新增元素数 = 5
最小所需容量 = 15

旧容量是 10,按 1.5 倍扩容后正好是 15,所以新容量为 15。

如果新增元素更多:

java 复制代码
List<Integer> target = new ArrayList<>(10);
for (int i = 0; i < 10; i++) {
    target.add(i);
}

List<Integer> source = new ArrayList<>(100);
for (int i = 0; i < 100; i++) {
    source.add(i);
}

target.addAll(source);

此时:

text 复制代码
当前 size = 10
新增元素数 = 100
最小所需容量 = 110
1.5 倍扩容结果 = 15
最终容量 = 110

因为 15 无法容纳 110 个元素,所以新容量直接取 110。

5.3 批量添加前更应该关注容量

addAll 通常出现在批量处理场景中:

  • 合并多个接口返回的结果;
  • 聚合多个数据库查询结果;
  • 批量消费消息后收集处理结果;
  • 将多个分组数据汇总成一个列表。

如果能提前知道总数量,可以直接指定容量:

java 复制代码
List<OrderDTO> allOrders = new ArrayList<>(paidOrders.size() + unpaidOrders.size());
allOrders.addAll(paidOrders);
allOrders.addAll(unpaidOrders);

这段代码有两个好处:

  • 避免 addAll 过程中多次扩容;
  • 让读者一眼看出结果列表容量与两个源集合之和有关。

这种写法不是为了追求"极致性能",而是用很低的代码成本减少不必要的数组复制,并提升代码意图的清晰度。


六、工程实践:什么时候需要提前指定容量

6.1 从已有集合映射新集合

这是最典型的预分配场景。

java 复制代码
public List<UserDTO> convert(List<User> users) {
    List<UserDTO> result = new ArrayList<>(users.size());
    for (User user : users) {
        result.add(new UserDTO(user.getId(), user.getName()));
    }
    return result;
}

如果 result 的数量与 users 基本一致,使用 users.size() 作为初始容量是合理的。

在 Java Stream 中,也可以写得更简洁,但普通循环在一些性能敏感路径中更容易控制容量和调试过程。

6.2 数据库分页结果转换

后端服务中常见写法是查询一页数据后转换为 VO/DTO:

java 复制代码
public List<OrderVO> buildOrderVOList(List<OrderDO> records) {
    List<OrderVO> result = new ArrayList<>(records.size());
    for (OrderDO record : records) {
        result.add(toOrderVO(record));
    }
    return result;
}

这里列表大小通常不会超过分页大小,容量比较容易估算,适合提前指定。

不过,如果一次查询可能返回非常大的结果集,更重要的问题不是 ArrayList 扩容,而是是否应该分页、限流或流式处理。

6.3 消息批处理结果收集

批量消费消息时,经常需要收集处理成功或失败的结果:

java 复制代码
public List<MessageResult> handleBatch(List<Message> messages) {
    List<MessageResult> results = new ArrayList<>(messages.size());
    for (Message message : messages) {
        results.add(handleOne(message));
    }
    return results;
}

这种场景下,结果数量通常和输入消息数量一致,预分配容量可以减少扩容,也能让代码表达更明确。

6.4 不适合提前分配大容量的场景

以下场景不建议盲目设置大容量:

  • 结果数量无法预估;
  • 大多数请求只会产生少量元素;
  • 列表可能长期持有,过大容量会造成内存浪费;
  • 高并发路径中每次请求都创建大容量列表;
  • 更合理的方案是分页、分批、流式处理,而不是一次性装入内存。

例如:

java 复制代码
// 不建议在没有依据的情况下这么写
List<String> logs = new ArrayList<>(100000);

如果每个请求都创建这样的列表,即使只写入少量元素,也会造成明显的内存浪费。


七、常见坑与线上排查思路

7.1 把 size 当成 capacity

list.size() 只能说明当前元素个数,不能说明底层数组容量。

java 复制代码
List<String> list = new ArrayList<>(1000);
list.add("A");

System.out.println(list.size()); // 1

这里不能因为 size() 是 1,就认为底层数组容量也是 1。

在业务代码中通常不需要直接获取 ArrayList 的底层容量。更重要的是在构建大列表时,思考是否能合理预估容量。

7.2 频繁扩容带来的数组复制

如果不断向列表中添加元素,而没有指定初始容量,ArrayList 会按规则多次扩容。

java 复制代码
List<Long> ids = new ArrayList<>();
for (long i = 0; i < 100000; i++) {
    ids.add(i);
}

这段代码不一定有问题,但如果它位于高频调用链路中,就要关注扩容带来的额外数组复制。

更好的写法是:

java 复制代码
int expectedSize = 100000;
List<Long> ids = new ArrayList<>(expectedSize);
for (long i = 0; i < expectedSize; i++) {
    ids.add(i);
}

如果数据量来自分页大小、批处理大小或源集合大小,优先使用这些已有信息作为容量依据。

7.3 ArrayList 不是线程安全容器

ArrayList 的扩容和写入操作不是线程安全的。多个线程同时修改同一个 ArrayList,可能导致数据丢失、状态不一致,甚至出现难以复现的问题。

不建议这样写:

java 复制代码
List<Integer> list = new ArrayList<>();

// 多线程同时 add 同一个 ArrayList,存在并发安全问题
parallelTasks.forEach(task -> list.add(task.getId()));

如果确实需要多线程收集结果,可以根据场景选择:

  • 使用线程内局部列表,最后单线程合并;
  • 使用 Collections.synchronizedList(new ArrayList<>())
  • 读多写少场景考虑 CopyOnWriteArrayList,但要注意写入复制成本;
  • 使用并发队列,例如 ConcurrentLinkedQueue,再按需转换为列表。

更推荐先从业务流程上避免多个线程共享可变列表。

7.4 如何排查列表膨胀或内存问题

当线上出现内存占用升高、GC 频繁、接口响应变慢时,如果怀疑和大列表有关,可以从以下方向排查:

  • 看调用链路 :是否有接口一次性查询过多数据并放入 ArrayList
  • 看分页限制:是否缺少分页、limit 或批处理上限。
  • 看对象大小:列表里存的是轻量 ID,还是包含大量字段的大对象。
  • 看生命周期:列表是方法内临时对象,还是被缓存、上下文对象、静态变量长期持有。
  • 看 GC 日志和堆转储 :是否存在大量 Object[]、业务 DTO、集合对象占用内存。
  • 看可观测性指标:接口耗时、返回条数、批处理大小、失败重试次数是否异常放大。

需要注意,ArrayList 本身通常不是问题根源。真正的问题往往是:业务代码一次性把过多数据装进内存,或者列表生命周期被意外拉长。


八、总结

ArrayList 扩容机制并不复杂,但有几个细节容易被误解:

  • 无参构造不会立即分配容量为 10 的数组;
  • DEFAULT_CAPACITY = 10 更准确地说是首次添加元素时的默认容量;
  • size 表示元素个数,capacity 表示底层数组容量,两者不是一回事;
  • 扩容通常按 oldCapacity + (oldCapacity >> 1) 计算,也就是约 1.5 倍;
  • 如果 1.5 倍仍不能满足最小所需容量,会直接扩到所需容量;
  • 扩容本质是创建新数组并复制旧元素,不是原地修改数组长度;
  • addAll 场景下,应重点关注 当前 size + 新增元素数
  • 在能预估规模的批量场景中,提前指定容量可以减少不必要的扩容和数组复制。

从工程角度看,理解 ArrayList 扩容不是为了在每一行代码里做微优化,而是为了在批量处理、大结果集转换、高并发接口、内存排查等场景中做出更稳妥的判断。

如果列表规模很小,默认构造通常足够;如果列表规模明确,指定初始容量更合适;如果列表规模可能很大,更应该优先考虑分页、分批、流式处理和生命周期控制。

相关推荐
运维瓦工1 小时前
DevOps 生态介绍(八):docker &dockerfile 命令介绍及构建项目的第一个镜像
java·docker·devops
yurenpai(27届找实习中)1 小时前
Spring AI 实战:从零实现 AI 对话的记忆与历史记录管理(附源码级解析)
java·spring·ai·prompt·word
nnsix1 小时前
Unity 自定义包的 package.json 简单写法
java·服务器·前端
宸津-代码粉碎机1 小时前
Spring AI企业级RAG进阶|文档智能分片调优、ES深度整合、接口限流熔断监控生产实战
java·开发语言·人工智能·后端·spring·elasticsearch·oracle
唐青枫1 小时前
Java MyBatis-Flex 实战指南:从 BaseMapper 到 QueryWrapper 的轻量 ORM 用法
java·mybatis
两年半的个人练习生^_^1 小时前
JVM进阶系列:彻底理解 Java 内存模型(JMM)
java·开发语言
云烟成雨TD9 小时前
Spring AI Alibaba 1.x 系列【69】Token 用量统计
java·人工智能·spring
JAVA9659 小时前
JAVA面试-并发篇 03-使用synchronized doublecheck实现单例有什么坑
java·单例模式·面试
在繁华处9 小时前
Java从零到熟练(四):面向对象基础
java·开发语言