ArrayList 是 Java 开发里出场率最高的集合类,没有之一。日常工作里写 new ArrayList<>() 就像呼吸一样自然,但真要问一句:为什么默认选它?什么时候该换?为什么明明只是加了几个元素,列表却变慢到让人抓狂?很多做到三五年经验的开发者,对这些问题也未必能一口答清楚。这篇博文就围绕 ArrayList 的用法和性能优化展开,把底层实现、常用姿势、易错点、调优手段一次讲透,适合不管你是刚入门 Java 的菜鸟,还是写过多年业务代码的熟练工,都能从中抠出点有用东西。
1. 先搞清楚 ArrayList 到底解决了什么问题
1.1 从一句话需求说起:为什么是 ArrayList
写业务代码的时候,我们经常遇到这种需求:从数据库查出一批用户,遍历处理一下;接收前端传进来一批订单号,去重后再落到另一个表。这种"数量不定、需要动态增加、主要按顺序读一遍"的数据,用原生数组是很别扭的。你事先不知道数据量有多少,数组长度定大了浪费内存,定小了又得手动扩容,写起来又臭又长。
ArrayList 就是把这一套动态扩容逻辑封装好了。它本质就是一个可变长度的 Object 数组,内部默认用 Object[] elementData 存数据。你只管往里 add,它自己在容量不够的时候悄悄申请新数组、把旧数据复制过去。用生活里的话说,数组是"租固定大小的房子",ArrayList 是"带自动扩建功能的房子",住进去的人不用操心哪天墙不够用了。
我刚工作那会儿,就有同事在代码里写:
java复制Object[] temp = new Object[100];
int size = 0;
if (size == temp.length) {
temp = Arrays.copyOf(temp, temp.length * 2);
}
temp[size++] = newData;
这套手写逻辑跑起来没问题,但每一次扩容、边界判断都要自己维护,出错概率不低。而 ArrayList 帮你在 JDK 层面把这些细节都做完了,这也是它成为默认选项的根本原因。
1.2 ArrayList 在 Java 集合体系里的定位
Java 集合框架里,List 接口代表着"有序、可重复"的集合语义,ArrayList 是它的核心实现。和 LinkedList 相比,它的底层是连续的内存空间,这决定了两个重要特性:按下标访问元素的时间复杂度是 O(1),中间插入和删除元素需要移动后续元素,时间复杂度是 O(n)。
从继承关系上看,ArrayList 继承了 AbstractList,实现了 List、RandomAccess、Cloneable、Serializable 接口。其中 RandomAccess 这个标记接口值得注意,它表示"这个列表支持高效随机访问"。Java 里的 for 循环遍历和迭代器遍历,很多工具方法都会判断是否实现了 RandomAccess,以此决定用哪种遍历方式。ArrayList 实现了它,意味着用普通 for 循环按下标访问是最快的方式,这一点在后面讲遍历性能的时候会详细展开。
一句话定位:ArrayList 是"读快写慢、内存紧凑、顺序访问友好"的动态数组。理解了这句话,后面所有用法调优都能对上号。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用法细节与高频踩坑点
2.1 创建时的几个隐藏细节:new ArrayList<>() 与容量初始化
最常见的创建方式就是 new ArrayList<>()。这里有个很多人不知道的细节:Java 7 之后,无参构造创建出来的 ArrayList 内部其实是一个空数组 DEFAULTCAPACITY_EMPTY_ELEMENTDATA,只有当第一次 add 元素的时候,才会真正初始化容量,默认扩容到 10。
所以有个经典问题:new ArrayList<>(0) 和 new ArrayList<>() 是一回事吗?不是。前者内部是 EMPTY_ELEMENTDATA,走的是"显式只要 0 容量"的逻辑,之后每次扩容都会按 1.5 倍增长;后者是 DEFAULTCAPACITY_EMPTY_ELEMENTDATA,第一次 add 时会帮你扩容到 10。业务代码里大多数时候用无参构造即可,不用纠结这个细微差别。
还有一类常见写法是创建指定容量:
java复制List<String> list = new ArrayList<>(1000);
在已知数据量大约是多少的时候,这种方式能显著减少扩容次数。这个细节放在后面性能优化部分细讲,这里先记住:能预估容量就尽量给容量。
至于热词里提到的 List<Map<String, Object>> tree = new ArrayList<>(); 这行代码,意思很直白——定义一个 List,每个元素是一个 Map,Map 的键是 String,值是任意 Object。这种嵌套结构在实际项目里非常常见,比如树形菜单、多级分类、批量接口传参等场景都会用到。使用时要注意,这种"泛型套泛型"的结构读起来容易眼花,命名变量时最好别用 tree 这种什么都说明不了的名字,换成 categoryList、menuNodeList 这类可读性强的名字,半年后你自己回来看代码也能少掉几根头发。
2.2 添加、删除、改值的正确姿势
基础操作本身不难,但有几个坑经常让人栽进去。
remove 的重载陷阱。ArrayList 有两个 remove 方法:
java复制remove(int index); // 按下标删除
remove(Object o); // 按对象删除
当你的列表里存的是 Integer 时,就会遇到一个经典问题:
java复制List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.remove(2); // 这删的是下标为2的元素(即数字3),不是删除数字2!
很多新手以为 remove(2) 是删掉值为 2 的元素,实际上这里调用的是 remove(int index),把下标为 2 的第三个元素删掉了。要想删除值为 2 的元素,必须写 list.remove(Integer.valueOf(2))。这个坑在代码 review 里出现频率极高,碰到 Integer/Long 列表删除时一定要多看一眼。
add 指定下标时的元素移动。add(int index, E element) 不是简单的插入,它会把 index 及其后面的所有元素整体后移一位。如果 List 很长,频繁往头部或中间插入,性能会非常难看。我见过有人写循环往 List 的 0 下标插入数据来模拟队列,数据量一大直接卡死。正确的做法是先用 LinkedList 或者在末尾添加完再 reverse,实在不行再考虑其他结构。
set 方法最安全。set(int index, E element) 是替换指定下标的值,时间复杂度 O(1),不会触发扩容,也没有元素移动,是 ArrayList 里性价比最高的操作。
2.3 遍历方式的性能差异与选择
ArrayList 的遍历方式大致有三种:
java复制// 方式一:普通 for 循环
for (int i = 0; i < list.size(); i++) {
String s = list.get(i);
}
// 方式二:增强 for / for-each
for (String s : list) {
// do something
}
// 方式三:迭代器
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
}
大部分场景下,三种方式写起来都差不多。但性能上普通 for 循环最快,因为它直接通过下标访问数组,JIT 编译后几乎没有额外开销。增强 for 在 ArrayList 上底层也是转成迭代器,迭代器每次 next() 都要额外检查 modCount(后面会讲),多了一些校验成本。至于 list.forEach() 和 Stream 遍历,性能基本不差,但要注意 lambda 里的局部变量引用问题。
我的建议是:能按下标读的就用 for i 循环,代码可读性优先时用增强 for 也没问题,性能差异在万级以下数据量几乎感知不到。千万不要为了"炫技"用迭代器在遍历时删除元素,那会触发 ConcurrentModificationException,属于最经典的错误。
2.4 subList、toArray 等一系列"看起来简单"的坑
subList 不是切片,是视图。很多人以为 list.subList(0, 3) 返回的是一个包含前三个元素的新列表,于是放心地往里面 add 元素。这是大错特错的。subList 返回的是原列表的一个视图,内部引用的还是同一个 elementData。对 subList 做的任何修改,都会直接反映到原列表上。反过来也一样,如果你在遍历 subList 的时候原列表的长度变了,subList 就会抛出 ConcurrentModificationException。
toArray 的两个版本。无参 toArray() 返回 Object[],如果想把结果转成 String[] 需要强转,但强转时要注意运行时类型问题,直接 (String[]) list.toArray() 会抛 ClassCastException。正确做法是 list.toArray(new String[0]) 或者 list.toArray(new String[list.size()])。JDK 8 之后 JIT 对 new String[0] 的写法有优化,按官方推荐用 0 长度数组就好。
3. 性能优化:从扩容机制开始
3.1 扩容机制完全拆解:为什么总是用 1.5 倍
ArrayList 的性能优化,绕不开扩容机制。简单来说,当内部数组满的时候,add 方法会调用 grow(int minCapacity) 来扩容。核心代码是:
java复制int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 也就是 1.5 倍
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
elementData = Arrays.copyOf(elementData, newCapacity);
oldCapacity >> 1 等于旧容量的一半,所以新容量是旧的 1.5 倍。这里就有个问题了:为什么是 1.5 倍而不是 2 倍、3 倍?
原因要从时间与空间的权衡看。扩容需要申请新数组、把旧元素全部复制过去,这是一个 O(n) 的操作。如果扩容倍数太小(比如 1.1 倍),扩容次数就会非常多,大量时间耗在复制数据上;如果扩容倍数太大(比如 3 倍),扩容次数少了,但每次扩容都申请了远超需求的空间,内存浪费严重。1.5 倍是一个在时间和空间两个维度上相对平衡的选择:扩容次数不会太多,每扩容一次也就多出 50% 的冗余空间,整体空间利用率降幅可接受。
还有一点需要注意,Arrays.copyOf 底层调用的是 System.arraycopy,这是个 native 方法,效率很高,但在元素非常多的时候仍不能忽视复制开销。比如一个已经有 80 万元素的列表,扩容一次就要复制 80 万个引用,再加上后续的 GC 压力,对实时性要求高的场景确实不可忽略。
3.2 初始化容量带来的真实收益
明白了扩容机制,初始化容量的意义就清楚了。假设你已知数据量约为 100000 条,用无参构造创建列表,初始容量 10,之后按 1.5 倍递增:10 → 15 → 22 → 33 → … → 倍到 100000 左右,差不多要经历 28 次扩容。每次扩容都要申请新数组并复制旧数据,累积起来是个不小的开销。如果直接 new ArrayList<>(100000),整个过程只有一次数组分配,性能差异在最坏情况下能差出一个数量级。
所以经验法则是:能预估数据量就一定要给初始化容量。特别是在循环 add 大量数据、批量从数据库查询结果构造列表、读取大文件按行处理这些场景,给容量是零成本收益极大的优化手段。
我做过一个简单实验:往列表里插 100 万条记录,无参构造耗时大约比预分配容量慢 80% 左右,内存分配次数更是多了几十倍。这不是玄学,是实打实的性能差距。
3.3 批量操作:addAll、removeAll 的性能陷阱
批量操作里最容易踩的坑是 removeAll。比如你要从一个包含 10 万元素的列表里,剔除掉另一个包含 1 万元素的子集:
java复制list.removeAll(toRemove);
此时 ArrayList 会遍历 list 的每个元素,逐个调用 contains 判断。而 contains 是 O(n) 遍历,所以整体复杂度是 O(n*m),10 万乘以 1 万,那就是 10 亿次比较操作,性能直接爆炸。
正确的替代方案是先把待删除集合转成 HashSet:
java复制Set<String> removeSet = new HashSet<>(toRemove);
list.removeIf(removeSet::contains);
HashSet 的 contains 是 O(1),整体复杂度降到 O(n)。这段代码在日常业务里非常实用,比如批量删除已选中的用户、过滤掉黑名单 ID 等场景,值得收藏。
同理,批量添加用 addAll 而不是循环 add,可以减少多次扩容检查,内部一次扩容到位,效率更高。
3.4 手动缩容:trimToSize 与容量管理
ArrayList 扩容只会往上扩,不会自动缩。某些场景下,一个列表高峰期被填充到几万条,但处理完业务后只剩几百条,它依然占据着几万条容量的数组。在内存敏感的业务里,这可能造成无谓的占用。此时可以调用 trimToSize(),把底层数组的容量调整到与当前元素个数一致:
java复制list.trimToSize();
这个方法底层调 Arrays.copyOf(elementData, size),会创建一个新数组,把元素复制过去。所以它不是免费的,频繁调用反而增加 GC 压力。正确的使用时机是:列表容量不再变化之后,调用一次即可。比如缓存对象在构建完成后、常驻内存的配置数据加载完成后,可以 trim 一下省点内存。
4. 工具选型解析:什么时候不该用 ArrayList
4.1 ArrayList vs LinkedList:不是所有"中间插入"都适合 LinkedList
网上很多文章喜欢说"LinkedList 适合频繁插入删除,ArrayList 适合随机访问"。这个结论在教科书上没错,但实际工程里要打个问号。
LinkedList 每个节点都是一个 Node 对象,除了存数据还要存前驱和后继引用,内存占用比 ArrayList 大得多。而且它的插入删除虽然是 O(1),但前提是已经定位到了那个节点。如果你要按下标插入(比如 add(5000, element)),LinkedList 一样要从头开始找 5000 个节点,复杂度是 O(n)。
实测下来,在小数据量(几百条)下,两者性能差异几乎可以忽略。在大量数据下,ArrayList 的连续内存访问对 CPU 缓存更友好,往往综合表现反而更好。我的建议是:默认你别碰 LinkedList,除非你真正确认了"要在头部或尾部高频插入且不需要随机访问"。实际项目中 LinkedList 的用武之地比想象中小得多。
4.2 数据结构层面的取舍:内存布局与 CPU 缓存命中
ArrayList 的底层数组是一块连续内存,访问第 i 个元素时,CPU 会把附近的内存都加载进缓存行(cache line)。如果接下来你访问第 i+1、i+2 个元素,大概率直接命中缓存,速度极快。这就是所谓的"局部性原理"。
而 LinkedList 的节点散落在内存各处,每访问一个节点都可能触发一次缓存未命中,需要重新从主存加载。虽然大 O 复杂度看着差不多,但常数因子差异很大。这也是为什么很多性能敏感的系统设计里,会刻意使用数组结构而不是链表结构。
这个原理也解释了另一个实战经验:ArrayList 在遍历和拷贝场景下表现远好于 LinkedList。Java 自带的 Collections.sort 对 ArrayList 排序时,会把元素拷进临时数组,排序完再拷回来,这操作在 LinkedList 上根本无法高效工作(虽然它也能排,但慢很多)。
4.3 混合场景下的优化组合:读多写少、写多读少怎么选
当你的业务场景是"读多写少",比如一个配置列表,初始化后几乎不再变化,只是频繁被读取,那么用 ArrayList 配合不可变包装是最佳组合:
java复制List<Config> configs = Collections.unmodifiableList(new ArrayList<>(configSource));
这样既能保证安全,又有高效的随机访问。如果是"写多读少"的场景,比如日志收集、消息暂存,且数据量不大,可以考虑 LinkedList 或 ArrayDeque。ArrayDeque 其实是个被低估的集合类,它内部用循环数组实现,双端操作都是 O(1),内存连续,性能比 LinkedList 还好。
另外,在多线程环境下,ArrayList 完全不是线程安全的,直接修改会造成数据错乱。如果写操作不频繁,可以用 CopyOnWriteArrayList;如果是读多写少的缓存场景,可以考虑用 ConcurrentHashMap 的 keySet 视图,或者 Guava 的 ImmutableList,都有各自的取舍。别一上来就上 Collections.synchronizedList(new ArrayList<>()),它把每个方法都加了锁,并发读时的性能下降明显,不一定划算。
5. 实战经验:移动端与高并发场景下的 ArrayList 优化策略
5.1 避免在循环中创建、扩容和清理
热词里提到"移动端内存泄露和性能优化""android 内存泄露和性能优化",这些话题和 ArrayList 的关系非常紧密。移动端内存资源紧张,GC 触发频繁会导致卡顿。ArrayList 最需要注意的,就是不要在频繁调用的方法里反复创建新列表、反复扩容。
举个例子,在循环里处理大量短生命周期对象时:
java复制for (Item item : items) {
List<String> tagList = new ArrayList<>();
// fill tagList
process(tagList);
}
每循环一次就创建一个 ArrayList,如果数据量大,会产生大量短命对象,堆积触发 GC。更优的做法是复用同一个列表,每次用完 clear():
java复制List<String> tagList = new ArrayList<>();
for (Item item : items) {
tagList.clear();
// fill tagList
process(tagList);
}
注意:clear() 只是把 size 设为 0,底层数组的容量还在,下次 add 不会扩容,也不会重新分配数组。这样就把分配次数从 N 次降到 1 次。
5.2 减少 GC 压力:ArrayList 与对象复用、内存泄漏
ArrayList 持有的是对象引用,它本身不会导致内存泄漏,但如果你把一个大列表存在静态变量里,或者 Activity 被静态 ArrayList 引用,就会造成典型的 Android 内存泄漏。这里要区分两个层面:
一是列表长期持有大数据但不使用。比如你在 Activity 里加载了一个包含几 MB 图片路径的列表,Activity 旋转屏幕后,旧 Activity 如果还被静态变量里的列表引用着,就无法被回收。解决方案是,页面销毁时把静态列表 clear() 或置为 null。
二是列表里存了不再使用的监听器/Callback。这类对象常被注册进 ArrayList 后忘记移除,导致每次进入页面都会累积。这是 Android 开发里非常常见的泄漏源,凡是 addListener,必须对称地写 removeListener。
5.3 Android 场景下:ArrayMap/SparseArray 什么时候更合适
在 Android 环境中,如果 key 是 int 或 long,SparseArray 的效率和内存占用都优于 HashMap,但它不直接替代 ArrayList。不过在存储"稀疏的列表数据"时,有一种组合很实用:你有一个从数据源拉到的有序列表(ArrayList),需要通过 id 快速查找某个对象,每次都遍历 O(n) 太慢。一个常见的优化方式是先把 ArrayList 转成 SparseArray:
java复制SparseArray<Item> index = new SparseArray<>();
for (Item item : list) {
index.put(item.id, item);
}
后面按 id 查找时就是 O(1),内存和性能在移动端上都很理想。这种"ArrayList 做顺序展示 + SparseArray 做索引"的组合,是我在多个 Android 项目里验证过的实用方案。
6. 常见问题与排查技巧实录
6.1 问题速查表
我在实际支持同事和 Code Review 过程中,整理了一个高频问题速查表:
| 现象 | 原因 | 解决方案 |
|---|---|---|
ConcurrentModificationException |
迭代或 for-each 遍历时修改列表(增删元素) | 改用 Iterator.remove(),或用 removeIf,或先收集再统一删除 |
UnsupportedOperationException |
Arrays.asList 返回的 List 是定长的,不能 add/remove |
需要动态列表时用 new ArrayList<>(Arrays.asList(...)) |
ClassCastException |
无参 toArray() 强转类型 |
使用 list.toArray(new String[0]) |
| 删除 Integer 元素失败 | remove(int) 被误认为是按元素删除 |
使用 remove(Integer.valueOf(x)) |
| 列表越来越慢 | 频繁往头部/中间插入,或频繁 removeAll | 按数据量判断:量大时改用 LinkedList、倒序插入,批量删除用 HashSet 辅助 |
| 内存长期偏高 | 大列表不用的容量没有释放 | 业务稳定后调用 trimToSize(),不再引用的列表尽快置 null |
subList 修改导致原列表异常 |
认为 subList 是独立切片 | 了解 subList 是视图,所有修改操作会同步回原列表,不要在原列表结构变化时持有 subList |
6.2 排查性能问题的工具与方法
真遇到 ArrayList 性能问题时,别瞎猜,先用工具确认。JVM 层面可以用 JVisualVM 或者 Arthas,dashboard 观察线程和内存,trace 分析方法耗时,看看是不是卡在扩容或者复制上。Android 端可以用 Memory Profiler 看对象分配,如果发现 ArrayList 相关对象大量堆积,就去检查是不是有典型扩容风暴。
有一个非常直观的排查方法:在关键代码前后打印 System.nanoTime() 耗时,对比无参构造和预分配容量的差距。很多时候你不需要做复杂 profiling,一个简单的时间差就能确认问题。
6.3 一些使用 ArrayList 的独家见解
最后分享几个我在实际开发中沉淀的做法。我习惯在写接口返回时,把 ArrayList 包装成不可变列表再返回,这样可以防止调用方误改数据引发 bug。数据量大的批量操作前,我会先想清楚复杂度是不是 O(n²)——一旦发现嵌套循环配 contains,立刻警觉,99% 的情况下都能优化成 HashSet 版本。还有一点,在团队代码规范里,我会明确要求:对外暴露的方法返回值尽量用 List 接口类型,不要直接暴露 ArrayList 具体类型,这样后期切实现(比如换成 CopyOnWriteArrayList)不用改调用方。
用 ArrayList 的体验是,"看起来简单,越用越深"。它不是一个拿过来就完事的工具类,你越理解它的底层行为,就越能在关键时候做出合理的选型和优化。希望这篇文章能帮你在实际项目里少踩几个坑。
