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

size:当前列表中实际存放的元素个数。capacity:底层数组 elementData 的长度,也就是当前最多能容纳多少个元素而不触发扩容。这两个值不是一回事。
List<String> list = new ArrayList<>(100);
list.add("A");System.out.println(list.size()); // 1
这段代码里,size() 返回的是 1,因为列表里只有一个元素。但底层数组的容量是 100。日常开发中我们通常只能直接看到 size,看不到 capacity,这也是很多人误解 ArrayList 初始化和扩容机制的原因。
理解这两个概念后,再看 ArrayList 的初始化和扩容,就会清楚很多。
ArrayList 底层通过数组存储元素。数组字段在源码中通常叫 elementData,类型是 Object[]。
简化理解如下:
transient Object[] elementData;
private int size;
elementData:底层数组,负责保存元素引用。size:当前已经存储的元素个数。ArrayList 支持按下标快速访问,本质上就是数组随机访问的能力:
String value = list.get(10);
只要下标合法,get(index) 可以直接通过数组下标定位元素。但数组的缺点也很明显:长度固定,容量不够时不能在原数组上直接变长,只能创建一个更大的新数组,再把旧数组中的元素复制过去。
这就是 ArrayList 扩容机制的基础。
很多文章会说:ArrayList 默认容量是 10。
这个说法不算完全错,但容易误导。更准确的说法是:
也就是说,DEFAULT_CAPACITY = 10 表示“首次写入时的默认容量”,不是“无参构造完成后立即拥有长度为 10 的数组”。
延迟初始化的好处是减少无意义的内存占用。
在后端系统中,一个对象里可能会有多个集合字段:
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 采用延迟分配:先用空数组占位,真正添加元素时再分配容量。
常见写法如下:
List<String> list = new ArrayList<>();
这种方式创建出来的 ArrayList,在没有添加元素之前,底层数组并不会分配 10 个槽位。第一次执行 add 时,才会触发容量初始化。
List<String> list = new ArrayList<>();
list.add("A"); // 首次添加时触发扩容,容量通常变为 10
这里要注意:
list.size() 是 1。如果能预估列表规模,可以使用有参构造:
List<Long> userIds = new ArrayList<>(1000);
这表示底层数组一开始就按容量 1000 创建,后续添加元素只要不超过 1000,就不会触发扩容。
这种写法适合列表规模比较明确的场景,例如:
例如:
List<UserDTO> result = new ArrayList<>(users.size());
for (User user : users) {
result.add(toDTO(user));
}
如果 users.size() 已经明确,提前指定容量可以减少扩容次数,也能让代码表达出“结果列表规模与源列表一致”的意图。
预分配容量不是越大越好。
List<String> list = new ArrayList<>(1000000);
如果最终只放入几十个元素,这个数组的大部分空间都会闲置。对于高并发服务,这类过度预分配可能放大堆内存压力,甚至增加 GC 负担。
比较稳妥的原则是:
ArrayList 容量。使用无参构造时:
List<Integer> numbers = new ArrayList<>();
numbers.add(1);
第一次 add 会发现当前底层数组容量不足,于是触发扩容。对于 JDK 8/11 常见实现,首次扩容容量通常是 10。
可以理解为:
当前容量 = 0
新增 1 个元素后所需最小容量 = 1
无参构造使用默认容量规则
最终容量 = max(10, 1) = 10
当列表已经有 10 个元素,再添加第 11 个元素时,当前容量不够,需要扩容。
ArrayList 的新容量计算逻辑可以简化理解为:
int newCapacity = oldCapacity + (oldCapacity >> 1);
oldCapacity >> 1 表示旧容量右移一位,相当于取旧容量的一半。因此新容量约等于旧容量的 1.5 倍。
例如:
旧容量 10 -> 新容量 15
旧容量 15 -> 新容量 22
旧容量 22 -> 新容量 33
这里不是用浮点数乘以 1.5,而是使用整数运算。所以当旧容量是 15 时:
15 + (15 >> 1) = 15 + 7 = 22
这也是为什么扩容结果不是 22.5,而是 22。
如果一次性新增很多元素,1.5 倍扩容可能仍然不够。
例如当前容量是 10,当前 size 是 10,此时一次性需要容纳 100 个元素,那么最小所需容量是 100。按 1.5 倍只能扩到 15,显然不够。
这种情况下,新容量会直接取所需最小容量。
旧容量 = 10
1.5 倍后容量 = 15
最小所需容量 = 100
最终新容量 = 100
这个规则在 addAll 场景中特别常见。
数组长度固定,扩容不是在原数组上直接变长,而是:
elementData 指向新数组;简化理解如下:
Object[] oldArray = elementData;
Object[] newArray = Arrays.copyOf(oldArray, newCapacity);
elementData = newArray;
这意味着扩容会带来两类成本:
对于小列表,这个成本通常可以忽略。对于大列表,尤其是高并发接口中频繁构建大集合时,这个成本就需要关注。
看一个例子:
List<Integer> source = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> target = new ArrayList<>();target.addAll(source);
target 是无参构造的空集合,第一次 addAll 时,新增元素个数是 5。此时底层容量通常会扩到 10。
可以理解为:
新增元素数 = 5
默认容量 = 10
最终容量 = max(10, 5) = 10
如果新增元素超过 10:
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。
新增元素数 = 20
默认容量 = 10
最终容量 = max(10, 20) = 20
对于非空集合,addAll 的核心是先计算最小所需容量:
最小所需容量 = 当前 size + 新增元素数量
例如:
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);
此时:
当前 size = 10
新增元素数 = 5
最小所需容量 = 15
旧容量是 10,按 1.5 倍扩容后正好是 15,所以新容量为 15。
如果新增元素更多:
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);
此时:
当前 size = 10
新增元素数 = 100
最小所需容量 = 110
1.5 倍扩容结果 = 15
最终容量 = 110
因为 15 无法容纳 110 个元素,所以新容量直接取 110。
addAll 通常出现在批量处理场景中:
如果能提前知道总数量,可以直接指定容量:
List<OrderDTO> allOrders = new ArrayList<>(paidOrders.size() + unpaidOrders.size());
allOrders.addAll(paidOrders);
allOrders.addAll(unpaidOrders);
这段代码有两个好处:
addAll 过程中多次扩容;这种写法不是为了追求“极致性能”,而是用很低的代码成本减少不必要的数组复制,并提升代码意图的清晰度。
这是最典型的预分配场景。
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 中,也可以写得更简洁,但普通循环在一些性能敏感路径中更容易控制容量和调试过程。
后端服务中常见写法是查询一页数据后转换为 VO/DTO:
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 扩容,而是是否应该分页、限流或流式处理。
批量消费消息时,经常需要收集处理成功或失败的结果:
public List<MessageResult> handleBatch(List<Message> messages) {
List<MessageResult> results = new ArrayList<>(messages.size());
for (Message message : messages) {
results.add(handleOne(message));
}
return results;
}
这种场景下,结果数量通常和输入消息数量一致,预分配容量可以减少扩容,也能让代码表达更明确。
以下场景不建议盲目设置大容量:
例如:
// 不建议在没有依据的情况下这么写
List<String> logs = new ArrayList<>(100000);
如果每个请求都创建这样的列表,即使只写入少量元素,也会造成明显的内存浪费。
list.size() 只能说明当前元素个数,不能说明底层数组容量。
List<String> list = new ArrayList<>(1000);
list.add("A");System.out.println(list.size()); // 1
这里不能因为 size() 是 1,就认为底层数组容量也是 1。
在业务代码中通常不需要直接获取 ArrayList 的底层容量。更重要的是在构建大列表时,思考是否能合理预估容量。
如果不断向列表中添加元素,而没有指定初始容量,ArrayList 会按规则多次扩容。
List<Long> ids = new ArrayList<>();
for (long i = 0; i < 100000; i++) {
ids.add(i);
}
这段代码不一定有问题,但如果它位于高频调用链路中,就要关注扩容带来的额外数组复制。
更好的写法是:
int expectedSize = 100000;
List<Long> ids = new ArrayList<>(expectedSize);
for (long i = 0; i < expectedSize; i++) {
ids.add(i);
}
如果数据量来自分页大小、批处理大小或源集合大小,优先使用这些已有信息作为容量依据。
ArrayList 的扩容和写入操作不是线程安全的。多个线程同时修改同一个 ArrayList,可能导致数据丢失、状态不一致,甚至出现难以复现的问题。
不建议这样写:
List<Integer> list = new ArrayList<>();// 多线程同时 add 同一个 ArrayList,存在并发安全问题
parallelTasks.forEach(task -> list.add(task.getId()));
如果确实需要多线程收集结果,可以根据场景选择:
Collections.synchronizedList(new ArrayList<>());CopyOnWriteArrayList,但要注意写入复制成本;ConcurrentLinkedQueue,再按需转换为列表。更推荐先从业务流程上避免多个线程共享可变列表。
当线上出现内存占用升高、GC 频繁、接口响应变慢时,如果怀疑和大列表有关,可以从以下方向排查:
ArrayList。Object[]、业务 DTO、集合对象占用内存。需要注意,ArrayList 本身通常不是问题根源。真正的问题往往是:业务代码一次性把过多数据装进内存,或者列表生命周期被意外拉长。
ArrayList 扩容机制并不复杂,但有几个细节容易被误解:
DEFAULT_CAPACITY = 10 更准确地说是首次添加元素时的默认容量;size 表示元素个数,capacity 表示底层数组容量,两者不是一回事;oldCapacity + (oldCapacity >> 1) 计算,也就是约 1.5 倍;addAll 场景下,应重点关注 当前 size + 新增元素数;从工程角度看,理解 ArrayList 扩容不是为了在每一行代码里做微优化,而是为了在批量处理、大结果集转换、高并发接口、内存排查等场景中做出更稳妥的判断。
如果列表规模很小,默认构造通常足够;如果列表规模明确,指定初始容量更合适;如果列表规模可能很大,更应该优先考虑分页、分批、流式处理和生命周期控制。