上周帮业务线同事排查一个接口超时问题,日志里没有异常,链路追踪也看不出瓶颈,最后定位到罪魁祸首是一个看起来人畜无害的 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 构造函
