做Java开发这么多年,ArrayList可以说是每天都要碰面的老朋友了。不过说实话,很多人对它的了解停留在"动态数组、能自动扩容"这个层面,真正到了线上出现性能问题、内存异常的时候,才回过头来研究它的源码和机制。这篇文章我就围绕ArrayList的用法和性能优化这条主线,从底层数组结构、扩容机制、增删改查的复杂度差异、遍历选型、内存陷阱这几个维度,把日常开发里最容易踩的坑一次讲透。内容既适合刚入门的Java新手快速建立正确的认知,也适合写了几年代码但对集合底层一知半解的开发者对照检查自己的既有习惯。
1. ArrayList基础用法:这些细节最容易忽略
1.1 动态数组的底层结构:elementData与size
ArrayList本质上就是一个Object数组,它做的事情是在原始数组的基础上封装了动态扩容、增删改查这些操作。源码里有一个Object[] elementData数组来真正存储数据,还有一个int size字段记录当前实际存了多少个元素。这里有个最容易混淆的点:elementData.length代表的是数组的容量,而size才是元素个数。你往ArrayList里放了5个元素,size是5,但底层数组容量可能是10,调试的时候展开看到数组长度跟想象中的不一样,别慌,这是正常现象。
还有一个细节值得注意,new ArrayList<>()创建出来的时候,elementData指向的是一个空数组EMPTY_ELEMENTDATA,并不是直接给你一个容量为10的数组。真正初始化成容量10的数组,是第一次调用add的时候才发生的。JDK这样做是有讲究的,很多场景下开发者new了ArrayList但可能一个元素都不放,延迟初始化能省下这一份没必要的内存占用。虽然10个Object引用也就几十个字节,但架不住高并发场景下大量短生命周期集合的创建,积少成多,内存占用和GC压力就是这么一点点堆起来的。
顺带说一句泛型和钻石语法。Java 7开始,new ArrayList后面的泛型类型可以省略,写成new ArrayList<>(),编译器会根据左边的泛型声明自动推断。比如搜索热词里经常有人问的List<Map<String, Object>> tree = new ArrayList<>();什么意思,拆开看就是:声明了一个List,泛型参数是Map<String, Object>,也就是这个列表里每个元素都是一个Map,Map的key是String,value是Object。这种"列表套Map"的结构在业务开发里非常常见,比如解析JSON配置、动态表格数据、接口返回值组装等场景。只要记住泛型是编译期的类型检查工具,运行时ArrayList底层存的还是Object对象,类型信息在编译后会被擦除。
1.2 三种构造方式与容量预估技巧
ArrayList提供了三个构造方法,用法都很简单,但选不对会在后面付出性能代价。
java复制// 方式一:默认构造,首次add时初始化为容量10
ArrayList<String> list1 = new ArrayList<>();
// 方式二:指定初始容量,推荐在知道数据量时使用
ArrayList<String> list2 = new ArrayList<>(1000);
// 方式三:用已有集合初始化,直接拷贝元素
ArrayList<String> list3 = new ArrayList<>(otherCollection);
方式二看起来平平无奇,但它是性能优化的第一道防线。举个例子,你从数据库查了一批数据,明确知道最多一万条,那么new ArrayList<>(10000)和new ArrayList<>()的差距在哪里?前者一步到位,后者会经历多次扩容,每次扩容都要申请新数组、拷贝旧元素。数据量越大,这个差距越明显。我见过不少线上接口,查询结果集几万条,ArrayList没指定容量,一次请求光扩容拷贝就多花了几十毫秒,在高QPS下这个开销会被放大得很厉害。
这里还有个工具方法可以配合使用,如果你已经创建了默认构造的ArrayList,后面才知道大概的数据量,可以用ensureCapacity来手动触发预扩容:
java复制ArrayList<String> list = new ArrayList<>();
// 业务代码执行到这里,知道了大概会有5000条数据
list.ensureCapacity(5000);
ensureCapacity的原理和构造时指定容量是一样的,都是提前把底层数组扩容到位,这样后续add的时候就不会因为容量不足而反复触发自动扩容。另外要注意,容量预估宁多勿少但要合理。如果预估100万实际只用1000,底层数组就会白白占着约4MB的内存(100万个Object引用),这对内存是实打实的浪费。预估要基于业务的实际峰值来定,而不是拍脑袋往大了填。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 扩容机制:性能瓶颈的源头
2.1 扩容流程与数组拷贝开销
ArrayList的自动扩容,触发条件很简单:当size已经等于elementData.length,此时再调用add方法,数组装不下了,就必须扩容。JDK 8以及后续版本的扩容逻辑是,新容量等于旧容量加上旧容量右移一位的结果,也就是旧容量的1.5倍。
java复制// 简化后的JDK 8扩容核心逻辑
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
elementData = Arrays.copyOf(elementData, newCapacity);
为什么是1.5倍而不是2倍?这里面有个权衡。扩容倍率越大,扩容次数越少,但每次扩容浪费的预留空间也越多;倍率越小,空间利用率越高,但扩容次数增加,拷贝开销变大。1.5倍是一个在实践中验证过的均衡点,既保证了均摊时间复杂度为O(1),又不会像2倍那样造成明显的内存浪费。
扩容本身是一个资源消耗很大的操作,因为它不是"原地变大",而是先申请一块更大的新数组,再把旧数组里的所有元素拷贝过去,最后让elementData指向新数组。这个拷贝是批量进行的,底层调用的是System.arraycopy这个native方法,拷贝速度已经很快了,但数据量大的时候仍然不可忽视。100万条引用的数组拷贝,在普通机器上大概需要几毫秒到十几毫秒,如果一次扩容不够、连续触发多次,累积起来就是肉眼可见的卡顿。
2.2 扩容次数计算:为什么预分配容量至关重要
我们来算一笔账。默认初始容量10,如果不断add到1000个元素,这中间要经历多少次扩容?容量序列是10、15、22、33、49、73、109、163、244、366、549、823、1234,从10增长到超过1000,一共扩容了12次。每一次扩容都要把前面所有已存在的元素拷贝一遍,把所有拷贝量加起来,元素移动的总次数是10+15+22+33+49+73+109+163+244+366+549+823,约等于2456次元素移动。而如果你一开始就new ArrayList<>(1000),元素移动次数是0。
这个数量级在数据量小的时候无所谓,但在数据量大的场景下,差距是指数级的。我自己在实际项目中就遇到过类似问题,一个批处理任务要往ArrayList里塞20万条记录,默认构造跑下来耗时接近1秒,改成预估容量后直接降到几十毫秒。所以扩容优化的关键就一句话:尽可能在创建的时候就把容量预分配好,让扩容次数无限趋近于零。
批量add的场景除了构造时预估容量,还可以利用addAll的一次性扩容特性。addAll(Collection)内部会计算两个集合的总元素数,一次性扩容到位,比在for循环里逐个add要高效得多。
提示:扩容看起来只是数组拷贝,但它会同时触发旧数组对象的失效和新数组的内存分配,间接增加GC压力。高并发、大对象场景下,频繁扩容导致的GC抖动往往比拷贝本身的耗时更麻烦。
3. 增删改查性能分析与选型对比
3.1 随机访问:ArrayList最擅长的场景
ArrayList的get(int index)是O(1)时间复杂度,这个特性来自数组的物理结构。数组在内存中是一段连续的地址空间,通过下标访问元素时,计算方式是数组起始地址加上下标乘以每个元素的大小,一次内存寻址就能拿到数据,不需要任何遍历。所以按索引查询、二分查找、倒序访问这类场景,ArrayList的表现几乎无可挑剔。
实际开发里,随机访问最常见的应用就是配合索引做二分查找,或者实现分页逻辑时按offset取数据。比如一个经过排序的ArrayList,用Collections.binarySearch做查找,底层就是二分查找,依赖的正是get(int index)的高效。这也是为什么在需要频繁随机访问的场景下,LinkedList几乎没有任何优势,因为LinkedList的get需要从头节点开始逐个next,一次get就是O(n)的遍历。千万别只看网上说什么"LinkedList适合频繁增删",那是建立在头尾操作的前提上的。
3.2 插入与删除的复杂度差异
ArrayList的增删操作要分情况讨论。尾部追加add(E e)是均摊O(1),因为大多数时候直接往数组末尾写就行,只有容量不足时才触发扩容。尾部删除remove(size-1)同理,也是O(1),直接把size减一即可,不需要移动任何元素。
但指定位置插入和删除就没那么幸运了。add(int index, E e)需要把index位置及之后的所有元素都往后挪一位,腾出空位再插入;remove(int index)需要把index之后的元素全部往前挪一位。这两者最坏情况都是O(n),注意这里的n是整个集合的大小,不是index之后有多少个元素。在100万条数据的ArrayList头部插入一个元素,意味着要移动100万个元素。
java复制// 头部插入,性能灾难
list.add(0, element);
// 头部删除,同样灾难
list.remove(0);
如果业务场景确实需要频繁在头部或中部插入删除,正确做法是换数据结构。LinkedList在头尾操作上是O(1),但代价是随机访问变成O(n)。还有一个容易被忽略的选择是ArrayDeque,它实现了Deque接口,头尾插入删除都是O(1),而且底层也是数组,内存紧凑度比LinkedList好,不过ArrayDeque不允许存null元素,这是一个使用限制。
3.3 ArrayList vs LinkedList vs Vector:一张表看懂选型
很多老项目里还能看到Vector的身影,这里要明确一点:Vector是JDK 1.0时代的产物,它的方法都加了synchronized同步,线程安全但性能差,而且它扩容是翻倍增长,比ArrayList的1.5倍更浪费内存。现在几乎没有任何理由再用Vector,需要线程安全集合的时候应该用CopyOnWriteArrayList,或者用Collections.synchronizedList做包装。
| 维度 | ArrayList | LinkedList | CopyOnWriteArrayList |
|---|---|---|---|
| 底层结构 | 动态数组 | 双向链表 | 动态数组 |
| 随机访问 | O(1) | O(n) | O(1) |
| 尾部插入 | 均摊O(1) | O(1) | O(1) |
| 头部插入 | O(n) | O(1) | O(n) |
| 删除指定位置 | O(n) | O(n),定位和删除叠加 | O(n) |
| 线程安全 | 否 | 否 | 是 |
| 适用场景 | 随机访问多、尾部增删 | 头尾操作多、不要求随机访问 | 读多写少的并发场景 |
这里多说一句,LinkedList的删除指定元素虽然也是O(n),但它的O(n)分成了定位的O(n)和删除本身的O(1)两部分,整体还是O(n)。所以在数据量大的情况下,LinkedList并不比ArrayList快多少,只有在明确知道要操作的是头节点或尾节点时才有明显优势。我见过不少人迷信LinkedList的性能,结果随机访问场景下反而慢得离谱,这就是没理解底层结构造成的。
4. 遍历方式与fail-fast机制
4.1 三种遍历方式实测对比
ArrayList的遍历方式主要有三种:fori索引循环、for-each增强循环、Iterator迭代器。性能上,fori最快,因为直接走get(int index)的随机访问,没有额外的迭代器对象开销;for-each和Iterator本质上是同一种方式,for-each是语法糖,编译后就是用Iterator实现的,差别只在代码写起来更简洁;Java 8之后的stream流遍历,中间会有额外的函数式接口包装,单次遍历性能略差,但可读性和链式操作能力更强。
java复制// 方式一:fori,性能最好
for (int i = 0; i < list.size(); i++) {
String s = list.get(i);
}
// 方式二:for-each,写起来最简洁
for (String s : list) {
// 处理逻辑
}
// 方式三:Iterator显式使用
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
}
一个小细节,fori循环里求list.size(),每次循环都会调用一次方法,虽然size()返回的只是int字段,开销极小,但如果你对性能有洁癖,可以在循环外先存一个局部变量。不过这属于微优化,真正影响性能的是循环体里的业务逻辑,不要本末倒置。还有一点,如果你在遍历的同时需要修改元素,用set(index, value)没问题,set不改变集合大小,不会触发并发修改检测。
4.2 modCount与ConcurrentModificationException避坑
ArrayList内部维护了一个modCount字段,用来记录结构被修改的次数。所谓结构性修改,指的是改变了集合元素数量的操作,比如add、remove、clear,而set不改变数量,所以不会增加modCount。当Iterator创建的时候,会把这个modCount记录在expectedModCount里,后续每次调用next()都会检查modCount是否等于expectedModCount,不一致就抛出ConcurrentModificationException。
这个机制叫fail-fast,它的目的是尽早暴露并发修改的问题,而不是保证数据一致性。最常见的触发场景是在for-each循环里直接调用list.remove():
java复制for (String s : list) {
if (s.equals("bad")) {
list.remove(s); // 抛出ConcurrentModificationException
}
}
正确的删除方式是使用Iterator自己的remove方法,因为it.remove会同步更新expectedModCount,不会触发检查失败:
java复制Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
if (s.equals("bad")) {
it.remove(); // 正确
}
}
还有一种解法是使用Java 8的removeIf,它对"按条件批量删除"做了专门优化,内部用BitSet标记要删除的位置,然后一次性批量搬移元素,避免了逐个remove导致的多次数组移动:
java复制list.removeIf(s -> s.equals("bad"));
注意:for-each遍历时删除元素,几乎是每个Java开发者都会遇到一次的经典异常。遇到就改用Iterator.remove()或removeIf。另外,如果你需要遍历的同时做大量删除,可以考虑反向遍历,这样remove(index)不会影响前面未遍历到的位置。
5. 内存管理与性能陷阱排查
5.1 静态集合与监听器引起的内存泄漏
ArrayList本身不会泄漏内存,但它的使用方式可以泄漏。最常见的是把ArrayList赋值给static字段,并且只往里加数据、从不清理。这样集合的生命周期等同于JVM的生命周期,里面存放的对象永远无法被GC回收。比如静态缓存列表、静态监听器列表,这些在高频使用场景下会越积越多,最终引发OutOfMemoryError。
java复制public class CacheManager {
// 静态集合持有对象引用,如果不主动清理,GC永远收不掉
private static final List<Object> CACHE = new ArrayList<>();
public static void add(Object obj) {
CACHE.add(obj);
}
}
正确的做法是给集合设置上限,或者用弱引用、软引用包装对象,再或者使用Guava的CacheBuilder这类支持过期策略的缓存组件。如果是监听器场景,一定要提供remove方法,并且确保对象不再使用后主动移除。很多内存泄漏问题不是框架的锅,就是这种"往里加、不往外删"的坏习惯积累出来的。
5.2 自动装箱:包装类集合的隐形代价
ArrayList只能存对象,不能存基本类型。所以写ArrayList
java复制List<Integer> list = new ArrayList<>(1000000);
for (int i = 0; i < 1000000; i++) {
list.add(i); // 每次add都做一次int -> Integer装箱
}
100万次add,就是100万个Integer对象被创建。Integer对象虽然只有16字节左右,但100万个就是16MB的分配量,再加上GC压力,整体开销不容忽视。如果你确实需要存储大量基本类型数据,比如百万级别的int、long、double,可以考虑用专门的高性能集合库,或者干脆用原始数组。我自己的一个教训是,统计系统里需要暂存大量long类型的时间戳,最初用ArrayList
5.3 批量操作:addAll、removeAll、clear的性能红利
ArrayList提供了一些批量操作方法,在性能上比循环逐个操作要好得多。addAll前面说过,可以一次性扩容;removeAll和retainAll内部用了批次搬移的机制,避免逐个remove导致的反复数组移动;clear()则是把底层数组引用指向一个新的空数组,原来的数组元素如果不再被引用,GC会回收,比逐个remove快得多。
java复制// 批量追加,推荐
list.addAll(newItems);
// 批量删除,比循环remove高效得多
list.removeAll(toRemoveSet);
// 清空,底层引用直接换新数组
list.clear();
特别是removeAll,如果你要删除的元素本身是一个集合,建议把它先转成HashSet再传给removeAll。因为removeAll判断元素是否在待删除集合里时用的是contains,ArrayList的contains是O(n)遍历,而HashSet的contains是O(1)。虽然removeAll内部有优化,但待删除集合很大的时候,这个contains开销依然存在。这个细节属于典型的"知道原理才能优化的点"。
6. 实战优化案例与自查清单
6.1 高频写入场景的优化方案
先说一个典型的业务场景:日志采集模块,每次请求会产生一批日志记录,需要聚合到内存列表再统一刷盘。如果这个列表容量预估不准,频繁扩容会吃掉不少CPU。我当时的做法是:在创建列表前先根据请求量做统计预估,用new ArrayList<>(预估容量)初始化,同时设置一个上限,超过上限就先刷一批,避免内存无限增长。
再看另一个场景,多线程往同一个ArrayList里添加数据。ArrayList不是线程安全的,多线程并发add会出各种诡异问题,比如size被覆盖、数据丢失、甚至数组越界。这个问题的解法不是简单给add加synchronized,而是看业务需求选对工具:
- 只允许尾部追加,且遍历时希望看到最新数据,用CopyOnWriteArrayList;
- 单写多读场景,可以用普通ArrayList配合读写锁;
- 需要高性能并发队列,直接用ConcurrentLinkedQueue或ArrayBlockingQueue。
还有一个容易被忽略的场景是大量字符串拼接。很多人习惯用ArrayList
6.2 性能问题排查速查表
| 症状 | 可能原因 | 排查与解决 |
|---|---|---|
| 大List创建后add特别慢 | 频繁扩容,数组反复拷贝 | 预估容量,用new ArrayList<>(n)或ensureCapacity |
| 遍历时抛ConcurrentModificationException | 遍历中直接调用了list.remove/add | 改用Iterator.remove()或removeIf |
| 接口响应耗时波动大,偶发几百ms | 大集合写入触发多次扩容 | 用Arthas或JFR观察耗时,预分配容量 |
| 内存占用异常高 | 存了大量包装类对象,或静态集合未清理 | 换基本类型数组,检查静态引用 |
| removeAll耗时吓人 | 待删除集合是ArrayList,contains为O(n) | 将待删除集合转成HashSet再传入 |
| 多线程add后数据丢失 | ArrayList线程不安全 | 换CopyOnWriteArrayList或加锁 |
| 清空大列表后内存没降 | 底层数组容量依然很大 | 重新new一个ArrayList替换旧引用 |
6.3 我的几条实战经验
最后整理几条我在实际开发中沉淀下来的经验。如果你正在做ArrayList相关的性能优化,可以先按照这个清单过一遍。
第一,所有新写的代码,只要知道集合大概的规模,一律在构造时指定容量。这条规则简单粗暴但收益极高,改动成本只有一行代码,却能直接消灭扩容带来的所有性能问题,属于性价比最高的优化手段。
第二,循环体内不要做集合的增删操作。要么用Iterator,要么用removeIf,要么先记录索引再循环外处理。这个习惯能帮你避开绝大多数并发修改异常和性能隐患。
第三,不要在一段代码里反复创建和销毁大ArrayList。能复用就复用,复用的时候用clear()而不是重新new,clear()的成本比新建对象低不少。尤其是在循环处理批次任务的场景里,这个习惯能让GC压力明显下降。
如果你在用JDK 21或更高版本,可以了解一下SequencedCollection接口。ArrayList实现了这个接口,提供了addFirst、addLast、getFirst、getLast、reversed等顺序操作API,让"获取第一个/最后一个元素"不再需要list.get(0)或list.get(list.size() - 1)这种别扭写法,代码语义更清晰,性能也保持一致。
写完这些,我自己又回去翻了翻手头的项目代码,抽了几个常见的地方做检查,发现还是能找到几处没指定初始容量的ArrayList,可见知道原理和养成习惯是两回事。我个人的体会是,每个Java开发者都值得花一两个小时把ArrayList的源码读一遍,尤其是扩容和modCount这两块,读完之后很多之前觉得"玄学"的性能问题都会豁然开朗。如果大家在做ArrayList性能优化时遇到了具体的案例,欢迎在评论区把数据贴出来,这种实际的数字比任何理论分析都有说服力。
