上周帮业务线同事排查一个接口超时问题,日志里没有异常,链路追踪也看不出瓶颈,最后定位到罪魁祸首是一个看起来人畜无害的 ArrayList。也不是代码写错了,而是数据量上来之后,集合操作选了不合适的方式。今天借这篇东西,把 ArrayList 从基础用法到性能优化完整梳理一遍,尤其是那些文档里不会明说、但线上一定会踩的细节。
我默认读这篇文章的人已经会写 new ArrayList<>(),也大概知道它是个动态数组。但正因为太常用,很多人才会忽略它背后的机制和边界。这篇文章不讲源码逐行注释这种流水账,而是站在"实际开发中怎么用它才不翻车"的角度,把扩容机制、遍历删除、大数据量下的选择、以及真实优化案例串起来讲。
1. 扩容机制这笔账:默认容量10和1.5倍增长到底怎么算
1.1 new ArrayList<>() 到底干了什么
先回答一个很多人问过的问题:new ArrayList<>() 这个写法是什么意思。JDK 7 之后引入了菱形语法,new ArrayList<>() 右边的尖括号可以不写类型,编译器会根据左边声明的类型推断。所以 List<String> list = new ArrayList<>(); 等价于 List<String> list = new ArrayList<String>();,纯粹是语法糖。
关键在于,这句话执行后,底层数组并没有马上分配一个很大的空间,而是初始化成一个空的 Object 数组。只有第一次往里 add 元素时,才会按默认容量 10 来分配。
这个设计有什么影响?如果你要往 ArrayList 里塞 100 万条数据,默认容量 10 意味着它需要经历很多次扩容才能长到足够大。每次扩容都是一次"老数组复制到新数组"的全量操作,扩容越到后面,单次复制成本越高。所以"先预估容量、再创建集合"不是洁癖,而是实打实的性能优化。
1.2 1.5倍扩容机制:复制次数比你想象的多
ArrayList 的扩容策略是:新容量 = 旧容量 + (旧容量 >> 1),也就是 1.5 倍。从容量 10 开始,依次涨到 15、22、33、49、73、109、163、244、366、549、823、1234、1851、2776、4164、6246、9369、14053、21079、31618、47427、71140、106710、160065、240097、360145、540217、810325、1215487,最后才覆盖到 100 万。
也就是说,往一个默认初始化的 ArrayList 里 add 100 万次,中间要发生将近 30 次全量数组复制。虽然均摊下来每次 add 的时间复杂度还是 O(1),但复制过程会带来明显的 CPU 消耗和短暂的停顿,尤其在数据量大、并发高的接口里,这种停顿会被放大。
我列个直观的对比表,假设初始容量分别为默认和直接指定 100 万时,扩容次数和复制总量差异巨大:
| 初始化方式 | 扩容次数 | 累计数组复制元素量级 |
|---|---|---|
new ArrayList<>() |
约 29 次 | 接近最终容量数倍的累计复制量 |
new ArrayList<>(1_000_000) |
0 次 | 无扩容复制 |
new ArrayList<>(100_000) |
约 7 次 | 少量扩容复制,最终还需扩容 |
1.3 add 之外的批量插入也要走扩容逻辑
很多人知道 add 会扩容,但没意识到 addAll、Arrays.asList 转 ArrayList 后再批量 add 同样会触发扩容。比如从一个 List 拷贝到另一个 List 时,目标 ArrayList 如果没指定足够容量,就会因为扩容机制多出大量复制。
这里有个小技巧:使用 addAll 前,可以先用 targetList.size() 加上预估增量来指定初始容量。比如:
java复制List<String> source = loadData(); // 已知大概 5 万条
List<String> target = new ArrayList<>(source.size());
target.addAll(source);
不要小看这一步。在数据量 10 万以上时,这个改动通常能减少一半以上的数组复制耗时。
注意:ArrayList 构造函数接收的是初始容量数值,不是其他集合。
new ArrayList<>(source)是复制构造,new ArrayList<>(source.size())是容量预分配。两者语义完全不同。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 遍历删除、subList、Arrays.asList:三处高频误用
2.1 遍历删除的三种姿势与抛错根因
先看一个几乎人人都遇到过的经典错误:在 for 循环里删除元素。
java复制List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "b"));
for (int i = 0; i < list.size(); i++) {
if ("b".equals(list.get(i))) {
list.remove(i);
}
}
这段代码的问题是:删除元素后,后面的元素会往前移动,索引 i 指向的元素已经变了。如果连续两个相邻元素都满足删除条件,第二个会被漏删。而如果用增强 for 循环:
java复制for (String s : list) {
if ("b".equals(s)) {
list.remove(s);
}
}
大概率会直接抛 ConcurrentModificationException。原因是增强 for 循环底层依赖 Iterator,ArrayList 的 Iterator 内部维护了一个 modCount 字段,任何结构性修改(add、remove、clear)都会让 modCount 增加。当 Iterator 发现 expectedModCount != modCount 时,就会认为集合被并发修改了,立刻抛异常。
这其实是一种 fail-fast 机制,本意是防止多线程环境下出现不确定行为,但单线程里也会因为"一边遍历一边删"而误触发。正确的做法有三种:
- 使用
Iterator.remove(),因为这个方法会把expectedModCount同步更新。 - 倒序遍历并用
list.remove(i),这样前面的元素不受影响。 - Java 8 起直接用
list.removeIf(predicate),这是最优雅、最推荐的方式。
java复制// 方式一:Iterator
Iterator<String> it = list.iterator();
while (it.hasNext()) {
if ("b".equals(it.next())) {
it.remove();
}
}
// 方式三:removeIf(最推荐)
list.removeIf("b"::equals);
2.2 subList 不是副本,是视图
subList(left, right) 返回的是原列表的视图,不是新建的独立列表。修改 subList 里的元素,原列表也会变;反过来,如果修改了原列表的结构(比如 add 或 remove),再操作之前那个 subList,会触发结构不一致检查,直接抛 ConcurrentModificationException。
很多人拿 subList 当"截取副本"用,结果后续修改原列表后出问题。如果要一个真正独立的子列表,需要 new ArrayList<>(list.subList(left, right))。
java复制List<String> sub = new ArrayList<>(list.subList(0, 3));
这一行额外 new 了一次,但得到的是完全独立的列表,后续随便改原列表都没关系。
2.3 Arrays.asList 转身就变数组?注意两个坑
Arrays.asList("a", "b", "c") 返回的不是 java.util.ArrayList,而是 Arrays 内部的一个固定长度 List。这个内部类继承 AbstractList,但没有实现 add/remove,所以调用 add 或 remove 会抛 UnsupportedOperationException。
另一个坑是:Arrays.asList 包装的数组如果被修改,List 也会变。因为它是直接基于原数组的视图,没有copy。大多数时候这不影响功能,但如果后续代码里有人的逻辑是通过 List 修改数组元素,容易造成意想不到的相互影响。
所以,如果需要一个真正的 ArrayList,最稳妥的写法是:
java复制List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c"));
这一步 copy 的开销不大,但能避开很多后续的问题。
3. 数据量一大,contains、remove(Object) 和排序就拖后腿
3.1 为什么 contains 在百万级数据里那么慢
ArrayList 基于数组存储,contains(Object o) 的源码就是线性遍历,从第一个元素开始,逐个用 equals 比较。这意味着时间复杂度是 O(n)。如果你在一个 100 万元素的 ArrayList 里做一次 contains,最坏情况要比较 100 万次。
更隐蔽的是循环里套 contains。很多人写过去重逻辑时会这样:
java复制for (Item item : sourceList) {
if (!resultList.contains(item)) {
resultList.add(item);
}
}
这段代码的时间复杂度是 O(n^2)。sourceList 有 1 万条时,最多要比较 5000 万次;有 10 万条时,是 50 亿次级别的比较。这种代码在测试环境根本看不出问题,数据量一上生产就秒变性能炸弹。
remove(Object o) 也是同理。它先要做一次 O(n) 的 indexOf 查找,然后再做一次 O(n) 的数组前移复制。所以在大数据量下频繁 remove 某个对象,代价比很多人以为的高得多。
3.2 大数据量去重、交集、差集的正确姿势
当集合中元素数量达到数千以上,而且需要频繁做"是否包含"判断时,就该考虑换数据结构了。HashSet 的 contains 是 O(1),这是质的区别,不是量的区别。
用 HashSet 辅助去重:
java复制Set<String> seen = new HashSet<>(sourceList.size());
List<String> result = new ArrayList<>(sourceList.size());
for (String s : sourceList) {
if (seen.add(s)) {
result.add(s);
}
}
这段代码把 O(n^2) 降到 O(n)。实测 10 万条字符串去重,在普通笔记本上从原本可能卡顿几秒,变成几十毫秒。
如果是求两个大集合的交集或差集,同样优先考虑 HashSet:
java复制Set<String> setA = new HashSet<>(listA);
listB.removeIf(s -> !setA.contains(s)); // 交集
但要注意:listB.removeIf(...) 和 listB.removeAll(setA) 虽然都能做批量删除,但 removeAll(Collection) 在 ArrayList 内部还是通过 contains 来判断的,所以如果传入的是一个大的 List,效率依然不高。正确的做法是:把要被删除的集合转成 HashSet 再传给 removeAll。
3.3 指定位置插入和删除的数组搬移开销
ArrayList 在头部或中间插入元素时,需要把插入位置之后的所有元素整体后移一位,用的是 System.arraycopy。虽然 arraycopy 是 native 方法,速度很快,但数据量大了以后,这种搬移成本还是不能忽视。
举个例子:在 50 万个元素的 ArrayList 头部插入一个元素,需要把 50 万个引用后移一位。虽然 arraycopy 本身很快,但如果这个操作在循环里反复执行,就会变成 O(n^2) 的总开销。
这时候该不该换 LinkedList?我的建议是:别急着换。LinkedList 虽然插入头部是 O(1),但它的随机访问是 O(n),而且每个节点有额外的对象头、前后指针内存开销,缓存局部性也差。实际项目中,如果你真的需要频繁在头部插入,更常用的做法是改用 ArrayDeque,或者干脆倒序构建列表,最后再反转一次。
判断标准很简单:读多写少用 ArrayList,头部插入极其频繁且不需要随机访问时再考虑 LinkedList 或 ArrayDeque。ArrayList 的默认地位不是没道理的。
4. 一次批量导入去重的优化实录:8秒压到300毫秒
4.1 当时线上代码长什么样
业务场景是这样的:运营后台导入一批商品编码,需要从历史记录里去掉已存在的编码,再写入新增部分。历史记录有大概 50 万条,本次导入可能一次性提交 5 万条。
原始代码简化后大概是:
java复制List<String> history = historyService.queryAllCodes();
List<String> imported = parseImportedFile(file);
List<String> toAdd = new ArrayList<>();
for (String code : imported) {
if (!history.contains(code)) {
toAdd.add(code);
}
}
for (String code : toAdd) {
history.add(code);
}
saveToDatabase(toAdd);
一眼看过去,逻辑没问题,数据也对。但生产环境实际执行时间在 8 秒以上,接口超时阈值 3 秒,直接触发告警。
4.2 瓶颈定位过程
当时我通过火焰图和线程栈马上能看到,热点集中在 ArrayList.contains 那一行。原因是 history 是一个 50 万规模的 ArrayList,而 imported 有 5 万条,最坏情况就是 5 万乘以 50 万,等于 250 亿次 equals 比较。
这还不算完。toAdd 累积完后,往 history 里 add 时,如果 history 初始容量不够,还会触发多次扩容复制。整个操作的耗时大头就是这两个:contains 的 O(n*m),以及大规模扩容的复制。
4.3 改造后的版本
第一版优化:把 history 转成 HashSet,contains 从 O(n) 变成 O(1)。
java复制Set<String> historySet = new HashSet<>(history);
List<String> toAdd = new ArrayList<>();
for (String code : imported) {
if (historySet.add(code)) {
toAdd.add(code);
}
}
同时,toAdd 和后续的 history 都可以提前预估容量:
java复制List<String> toAdd = new ArrayList<>(imported.size());
history = new ArrayList<>(historySet.size() + imported.size() + 1);
第二版优化:如果导入的量很大,不需要一次性全部加载到内存,而是分批处理。每批 5000 条,处理完直接写入数据库,既避免内存暴涨,也降低单次 GC 压力。
改造后,整个导入过程从 8 秒降到 300 毫秒左右,其中大部分时间已经花在数据库写入上了。
4.4 在 Android 和移动端的额外提醒
提到性能优化,顺便说一个移动端常见的问题。Android 开发里,ArrayList 本身是引用类型集合,存放 int、long、boolean 等基础类型时会自动装箱成 Integer、Long、Boolean,产生大量临时对象,GC 压力比原生数组大很多。
如果你的数据是纯数值且大小固定,优先用 int[]、long[],或者用 IntArray、FloatArray 这类专用集合,可以明显减少对象分配。
还有就是列表页滑动卡顿的问题。很多人排查 Adapter 性能时,会忽略每次 getView 里都在 new ArrayList。如果这个列表只需要在数据加载时构建一次,就把它提到 ViewHolder 外部或者 Adapter 的成员变量里,避免每滚动一屏就重复创建集合对象。这个优化虽然不起眼,但在低端机上体感差异非常明显。
5. 补充几个容易忽略但能救命的 ArrayList 细节
5.1 初始容量的估算技巧
预估容量时,别拍脑袋写。如果数据来自数据库查询,可以直接用查询结果的 size;如果来自分批加载,用批次大小乘批次数量。给 ArrayList 预留一半的富余空间通常就够了,没必要精确到不差一个元素。
比如你预期最多 1000 条数据,new ArrayList<>(1000) 就完全 OK。给多了也就浪费一点引用数组的空间,给少了反而会触发扩容复制,两笔账哪头划算很清楚。
5.2 trimToSize 可以让内存瘦身
当一个 ArrayList 经历多次扩容后,底层数组容量往往远大于元素数量。比如你装了 120 万个元素,底层数组容量可能已经涨到 160 万甚至更高。如果这个列表要长期驻留内存,又不打算再添加元素,调用一次 list.trimToSize() 可以把底层数组裁剪到和当前元素数量一致,节省一部分内存。
这个操作不要滥用。如果你后续还要往里面加数据,trimToSize 之后再扩容反而会增加复制成本。所以它只适合"填完数据、准备长期持有"的场景。
5.3 多线程环境下别直接裸用 ArrayList
ArrayList 不是线程安全的。多个线程同时 add 或者同时遍历,轻则数据错乱,重则抛 ConcurrentModificationException。
常见的错误是用 Collections.synchronizedList 包装后,以为"加了个同步锁就万事大吉"。实际上 synchronizedList 只是让每个方法单独同步,复合操作比如"检查再添加"依然存在竞态条件。
正确做法有三条路:
- 如果读多写少,用
CopyOnWriteArrayList,读操作不加锁,写操作复制新数组。 - 如果写多,用
ConcurrentLinkedQueue或加锁的 ArrayList。 - 如果只是需要遍历时保证一致性,可以考虑在遍历前先
synchronized (list)包一层。
具体选哪个,要看你的读写比例和实时性要求。没有银弹,但至少别裸奔。
5.4 JDK 版本带来的几个小差异
Java 8 之后,List 家族多了 removeIf、replaceAll、sort 这些默认方法,大大简化了代码。Java 9 以后,List.of 可以创建不可变列表,但它返回的并不是 ArrayList,而是内部优化的不可变实现,API 行为差异很大,不要拿它当普通 ArrayList 用。
Java 11 之后,toArray(T[]::new) 这个写法更简洁:
java复制String[] arr = list.toArray(new String[0]);
// 也可以这样
String[] arr = list.toArray(String[]::new);
new String[0] 这个写法在早期版本里会让人疑惑,但它其实是性能最好的传参方式,因为 JIT 会优化掉空数组的创建,方法内部如果列表大小合适,会复用传入数组或者直接新建数组。
还有一个容易忽略的点:很多人在 toString、打印日志时直接输出大的 ArrayList,这个操作本身也会遍历所有元素,如果集合里有复杂的对象,toString 可能非常耗性能,甚至因为循环引用导致栈溢出。生产环境打日志前,先确认集合大小,再决定打不打全量。
最后再分享一个实用小技巧
关于 modCount 这个机制,平时不痛不痒,但一旦遇到"遍历时删元素"的问题,理解它就能少掉很多头发。我的个人习惯是:在写涉及集合增删的业务代码时,第一反应不是去记各种循环写法,而是先问自己我要的数据结构是什么。需要频繁查重就用 Set,需要保持插入顺序且查重就用 LinkedHashSet,需要按排序规则访问就用 TreeSet 或 PriorityQueue。ArrayList 是一个很好用的默认选择,但它不是所有场景的最优解。
排查线上性能问题时,我也建议大家先看数据规模。数据在几百条以内,怎么折腾都无所谓;数据到几十万级,一个普通的 contains 就能拖垮接口。优化前先量化,优化后再 benchmark,别靠感觉做性能优化。把 ArrayList 的扩容、遍历、查找成本都记在心里,你写出来的代码自然会比大部分人稳一截。
