☰
Java ArrayList底层原理与高频方法实战解析
2026/10/7 11:42:21 网站建设 项目流程

1. 整体设计与底层原理

写代码这么多年,ArrayList 应该算是我用过最频繁的集合类,没有之一。很多同学把它当成“可以动态扩容的数组”,这种理解基本正确,但要想真正用好它,还是得先搞明白它底层到底是怎么工作的,否则后面在性能排查和并发问题上很容易栽跟头。

1.1 数组与 ArrayList:从固定长度到自动扩容

Java 原生的数组在声明时就必须指定长度,比如String[] arr = new String[10],一旦确定就改不了。这个设计在业务开发里非常难受,因为大部分场景下我们事先并不知道数据的最终规模。ArrayList 做的事情,本质上就是在内部维护一个 Object 数组,然后在添加元素时判断容量是否够用,不够就自动扩容。

我这里先不说源码细节,用一个生活化的例子类比:数组就像买了一个固定座位的电影院,票卖完了就不能再进人了;ArrayList 就像一间可以临时加座的活动厅,座位不够的时候,管理员会去旁边仓库搬一批新椅子进来,把原有观众挪过去,然后继续接待新观众。

这个“搬椅子”的过程,在计算机里就是申请一块更大的连续内存空间,把旧数组里的元素逐个拷贝过去,再让内部引用指向新数组。所以 ArrayList 表面上用起来是“无限容量”,实际上每次扩容都是有代价的,代价就是内存申请和数据拷贝的时间。

1.2 扩容机制:ArrayList 性能的核心密码

从源码里可以看到,ArrayList 默认的初始容量是 10,当然你也可以在构造时指定一个更大的初始容量。真正重要的是扩容策略,当数组容量不够时,grow方法会计算一个newCapacity = oldCapacity + (oldCapacity >> 1),也就是每次扩容到原来的 1.5 倍。

这个 1.5 倍不是拍脑袋定的。扩容太频繁会白白消耗拷贝成本,扩容太猛又会浪费内存。1.5 倍是一个比较均衡的选择——既保证均摊到每次 add 操作的时间复杂度接近 O(1),又不会像 2 倍扩容一样造成过大的内存跳跃。

假设你连续添加 100 万个元素,大概会发生多少次扩容呢?从 10 开始,每次乘以 1.5,到容量超过 100 万时大约发生了 29 次扩容。最坏情况下,某一次扩容需要一次性复制几十万个元素的引用。如果这个列表是高频写入的服务端数据,扩容带来的 GC 压力其实不小。

提示:如果你的代码里能预估数据量,比如从数据库查出 10 万条记录要放进 ArrayList,合理做法是new ArrayList<>(100000),一次性把容量分配到位,省掉中间所有扩容拷贝。这个习惯对长列表性能提升非常明显。

1.3 适用场景与局限性

ArrayList 的底层是数组,所以它的强项和弱项都很鲜明。

强项是按下标随机访问,get(index)的时间复杂度是 O(1),这一点秒杀 LinkedList。弱项是中国插入和删除,因为插入或删除位置之后的所有元素都要整体移动。比如在列表头部频繁插入元素,每次操作都是 O(n),数据量一旦上来,你会发现性能退化得非常厉害。

另外,ArrayList 还要求内存空间连续。如果元素数量巨大,JVM 需要找到一整块足够大的连续区域来存放,这时候即使堆内存还有空闲,也可能因为碎片化而触发 Full GC。所以 ArrayList 适合读多写少、尾部追加为主的场景,不适合频繁头插、中间插入或高频删除的业务。

2. 常用方法实操拆解

讲完底层原理,接下来就是本次的核心内容:ArrayList 的常用方法。我会按照日常开发的真实使用频率来组织,从添加、获取、修改、删除到查找判断,每类方法都会给到代码示例和容易忽略的细节。

2.1 添加元素:add 的几种姿势

add(E element)是所有方法里用得最多的一个,作用是往列表末尾追加元素。源码逻辑其实很直接:先检查容量,不够就扩容,然后elementData[size++] = element。这里有个细节,size和elementData.length是两个不同的概念,前者是“当前已存元素个数”,后者是“底层数组总容量”。

List<String> names = new ArrayList<>(); names.add("张三"); names.add("李四"); System.out.println(names); // [张三, 李四]

另一个常用重载是add(int index, E element),指定位置插入。这个方法有一个隐藏校验,index必须在[0, size]范围内,等于size时相当于末尾追加,超出范围会抛IndexOutOfBoundsException。插入的逻辑是先把插入点之后的元素整体后移一位,再在空出来的位置写入新元素,最后size++。

names.add(1, "王五"); // 现在顺序为 [张三, 王五, 李四]

第三个是addAll(Collection<? extends E> c),批量添加。底层会在扩容时一次性按c.size()计算新容量,避免多次单条 add 触发多次扩容。我在实际项目中批量组装数据时,都会优先考虑addAll,而不是 for 循环里一个个add。

2.2 获取与遍历:get、size 与迭代器

get(int index)是最简单的方法,底层就是return elementData[index],没有任何遍历过程。所以在循环里调用get的性能极好,但是有一个前提:循环的终止条件不能每次都用list.size()在循环体内重新计算吗?其实size()本身也是 O(1),因为底层维护的就是一个 int 变量,不会造成性能问题。

for (int i = 0; i < list.size(); i++) { System.out.println(list.get(i)); }

除了最传统的 for 循环,还有增强 for 循环和迭代器方式。严格来说,增强 for 底层也是基于迭代器实现的,编译后会自动转换成Iterator的hasNext()和next()调用。

for (String name : names) { System.out.println(name); }

这里必须提醒一点:遍历过程中不要直接调用list.remove()或list.add(),否则会抛出ConcurrentModificationException。原因是用迭代器遍历时,迭代器会维护一个expectedModCount,而list的增删操作会让modCount增加,两者对不上就会直接抛异常。正确的删除方式是用迭代器自带的remove()方法,或者干脆用 JDK 8 之后的removeIf。

names.removeIf(name -> name.startsWith("张"));

2.3 修改与删除:set、remove

set(int index, E element)是替换指定位置的元素,返回被替换的旧值。这个操作时间复杂度是 O(1),因为只改数组的一个位置。

String oldValue = names.set(0, "赵六"); System.out.println(oldValue); // 张三

remove(int index)与remove(Object o)是两个截然不同的方法,它们的区别在面试和笔试中都是经典考点。remove(int index)按索引删除,返回被删除的元素;remove(Object o)按值删除,只删除第一个匹配项,返回布尔值。因为 Java 的方法重载规则,如果你的列表装的是 Integer,调用remove(1)删除的是下标 1 的元素,而不是值等于 1 的元素,想删除值 1 得用remove(Integer.valueOf(1))。

List<Integer> numbers = new ArrayList<>(); numbers.add(1); numbers.add(2); numbers.add(3); numbers.remove(1); // 删除下标1,即数字2 numbers.remove(Integer.valueOf(3)); // 删除值3

删除的底层实现,是把删除点之后的所有元素整体向前移动一位,然后把最后一个位置置为 null,最后size--。末尾的置 null 操作是为了帮助 GC 回收,否则即使size减了,底层数组仍然保留着这个对象的引用,会造成内存泄漏风险。

2.4 查找与判断:contains、indexOf、isEmpty

contains(Object o)底层调用indexOf,而indexOf说白了就是一个从 0 开始的线性查找循环,遍历数组逐个用equals判断。所以 ArrayList 的contains时间复杂度是 O(n),如果列表很长又频繁做存在性判断,可以改用HashSet。

indexOf返回的是第一个匹配元素的下标,找不到返回 -1,lastIndexOf则是从尾部开始找。这里有一个很多人忽略的细节:查找时用的equals方法是元素对象自己实现的。如果你的元素是自定义对象,没有重写equals,那默认比较的是对象引用,两个内容相同的对象会被判定为不相等。所以需要根据值查找的时候,务必确认元素类已经正确重写了equals和hashCode。

isEmpty()判断列表是否为空,底层的逻辑非常简单,就是size == 0,不需要遍历。日常推荐用它而不是list.size() == 0,语义更清晰,代码读起来也更舒服。

3. 增删改查之外的实用方法

除了最基本的 API,ArrayList 还有一批批量操作、排序转换方法。这些方法看起来不起眼,但在真实项目中能让代码精简很多,也能帮我们避开一些性能陷阱。

3.1 批量操作:addAll、removeAll、retainAll

addAll前面已经提过,这里重点讲removeAll和retainAll。removeAll(Collection<?> c)会从当前列表中删除所有“也存在于 c 中”的元素,retainAll(Collection<?> c)则反过来,只保留当前列表与 c 的交集。

看起来这两个方法挺方便,但实现上有一个隐藏的坑:底层本质上是“先构建一个 filtered 列表,再整体覆盖”,期间会大量调用contains方法。如果传入的 c 是一个大的 ArrayList,而当前列表又很大,那双重线性查找就会把时间复杂度推到 O(n*m),数据量稍微大一点就很慢。

实际开发中,我更推荐把待删除集合c转成HashSet再调用removeAll,这样底层contains的查找时间变成 O(1),整体速度快一个数量级。同理,retainAll也可以采用这种思路。

List<String> toRemove = new ArrayList<>(); toRemove.add("张三"); Set<String> removeSet = new HashSet<>(toRemove); list.removeAll(removeSet);

3.2 排序与洗牌:借助 Collections 工具类

ArrayList 自身没有提供排序方法,排序依赖Collections.sort(List<T> list)。方法内部会把列表转成数组,用效率更高的TimSort排序后,再写回列表。对于元素数量较少的列表,这个“转数组再写回”的额外开销几乎可以忽略。

List<Integer> numbers = new ArrayList<>(); numbers.add(3); numbers.add(1); numbers.add(2); Collections.sort(numbers); // [1, 2, 3]

如果希望自定义排序规则,可以传入Comparator:

Collections.sort(names, (a, b) -> b.length() - a.length());

JDK 8 之后还直接提供了list.sort(comparator)实例方法,用法和Collections.sort基本一致。另外还有一个冷门但实用的Collections.shuffle(List<?> list),可以把列表元素顺序随机打乱,在抽奖、随机出题、洗牌等场景非常方便。

3.3 子列表与转换:subList、toArray

subList(int fromIndex, int toIndex)返回的是原列表的视图,而不是一份拷贝。这句话说再多遍都不为过,因为太多线上事故都出在这个方法上。你拿到subList后修改元素,原列表会跟着变;反过来原列表做结构性修改,subList再操作就会抛ConcurrentModificationException。

List<String> sub = names.subList(0, 2); sub.set(0, "钱七"); System.out.println(names); // 原列表第一个元素也变了

如果需要一份独立的子列表,正确做法是重新new ArrayList<>(names.subList(0, 2)),这样底层会拷贝过去,视图关联就断了。

toArray()有两个重载:无参版返回Object[],有参版toArray(T[] a)返回指定类型的数组。日常更推荐有参版,因为无参版返回的 Object 数组在强转时很容易抛ClassCastException。

String[] nameArray = names.toArray(new String[0]);

这里提一个小知识点,new String[0]在早期版本会被诟病“多创建了一个数组对象”,实际上 JDK 源码里判断传入数组长度小于 size 时,会重新创建正确长度的数组,所以传 0 长度的数组不仅没有性能问题,反而是官方推荐写法,语义上也非常清晰。

4. 并发场景与线程安全选择

我曾经在梳理一个线上偶发故障时,排查到最后发现是多个线程同时往一个 ArrayList 里写数据,某个时刻数组扩容与赋值发生了交错,最终导致数据丢失和下标越界。群里不少人有“ArrayList 线程不安全”的耳闻,但具体不安全在哪,很多朋友说不清楚。

4.1 为什么 ArrayList 在多线程下不安全

ArrayList 的所有add和remove操作都分解为底层数组的读写,而这些读写没有加锁,也没有 volatile 保证可见性。典型的并发问题集中在两个地方:

  • 扩容竞争:两个线程同时add,都发现容量不够,都去执行扩容,最终可能把新数组的引用互相覆盖,导致一部分元素“丢失”。
  • size 竞态:size++并非原子操作,多个线程同时执行时可能把 size 少加,后续遍历或get时漏掉元素,甚至数组越界。

即使只是单线程读、多线程写,也可能因为内存可见性问题,导致读线程看到未写入完全的数据。

4.2 线程安全替代方案怎么选

Java 里能替代 ArrayList 的线程安全类其实不少,我按场景分了三类。

  • Vector:把每个方法都加上了 synchronized 锁,功能基本一致,但因为锁粒度太重,并发高时竞争非常激烈,现在几乎不推荐新项目使用。
  • Collections.synchronizedList(new ArrayList<>()):在方法级别加锁,使用简单,适合小规模并发。需要注意的是,遍历时必须手动加锁,否则遍历过程中被其他线程修改仍可能抛ConcurrentModificationException。
  • CopyOnWriteArrayList:写操作时复制一份新数组进行修改,读操作完全不加锁。适合读多写少的场景,比如配置项缓存、白名单列表,但写操作成本较高,不适合频繁写入。

以我自己的经验,如果并发写比较多,优先考虑并发容器而不是简单的加锁集合;如果只是读多写少,用CopyOnWriteArrayList能获得非常好的读性能。关键还是要对业务读写比例做一个预判,没有万能方案。

5. 高频踩坑与性能优化

最后这部分是含金量最高的,我整理了这些年在使用 ArrayList 时踩过和见过的典型问题,每条都附上了原因分析和解决方案,方便直接对照自查。

5.1 循环删除的经典陷阱

很多初学者会这样写:

for (int i = 0; i < list.size(); i++) { if (list.get(i).equals("删除")) { list.remove(i); } }

这个写法的问题是,删除元素之后,后面的元素整体前移,但下标i继续递增,于是被前移的元素被跳过了,很可能漏删。解决方案是从后往前遍历,或者使用迭代器的remove,也可以使用前面提到的removeIf。从后往前遍历有一个额外好处,删除时前移的元素不会影响还未遍历到的位置,逻辑最稳。

如果使用增强 for 循环直接删除,则会直接触发ConcurrentModificationException,连“漏删”的机会都没有。所以规范做法是:普通 for 循环从后往前删,或者直接用Iterator.remove()。

5.2 初始化容量与扩容优化

我见过不少项目的构造写法是new ArrayList<>()之后疯狂add,明明数据量能提前确定,却懒得多写一个容量参数。在高频接口里,这会导致每次上线扩容时都发生一次大数组复制,白白消耗 CPU 与内存。经验法则是:

  • 能确定数据量时,务必用new ArrayList<>(expectedSize)。
  • 无法确定但知道大概是量级时,可以给一个略大于预期的初始容量,比如预期 800 条就给 1000。
  • 不要随手填一个Integer.MAX_VALUE或几千万,分配超大数组会让 JVM 瞬间占用大量内存,甚至触发 GC 停顿。

5.3 subList 视图陷阱

subList的设计意图是提供一个轻量视图,避免无谓拷贝,但它的视图特性经常让不熟悉的人踩坑。以下面这段代码为例:

List<String> sub = list.subList(0, 3); list.clear(); sub.size(); // 抛出 ConcurrentModificationException

原列表一旦发生结构性变化,视图的modCount检测就会失败。如果你只是用subList读取一段数据,不要保留太久;如果后续还会操作原列表,建议立即拷贝一份独立列表再使用。

5.4 常见问题速查表

问题现象根本原因解决方案
循环中删除漏元素删除后元素前移,下标跳过从后往前删除,或使用 Iterator.remove / removeIf
增强 for 中删除抛异常modCount 与 expectedModCount 不一致使用迭代器删除,避免直接 list.remove
remove(1) 删除的不是值 1方法重载匹配了 int 索引删除值使用 remove(Integer.valueOf(1))
自定义对象 contains 找不到未重写 equals / hashCode按业务值重写 equals 与 hashCode
subList 操作原列表受影响返回的是视图而非副本若要独立列表,用 new ArrayList<>(subList)
多线程同时写入数据丢失扩容与 size 更新竞态使用 CopyOnWriteArrayList 或同步容器
大列表 contains 很慢线性查找 O(n)改用 HashSet 做存在性判断
频繁扩容导致 GC 压力大默认容量 10,多次扩容预估容量并指定初始容量

5.5 不可变列表:防御性编程的最后一步

一个经常被忽略的细节是,ArrayList 是可变的。如果方法里返回了一个内部维护的 ArrayList,调用方可以直接修改它,轻则数据错乱,重则产生安全漏洞。我习惯的做法是,对外暴露集合时用Collections.unmodifiableList()包一层,这样任何修改都会抛UnsupportedOperationException,从源头杜绝被意外改动。

public List<String> getNames() { return Collections.unmodifiableList(names); }

JDK 9 之后的List.of()也能创建不可变列表,但它不接受 null 且完全不能增删,适合作为常量集合使用。这两者的区别要搞清楚,Collections.unmodifiableList只是“不可修改”视图,底层原列表仍然可能变化,而List.of是不可变快照,两者用途不同。

在实际项目中,我还经常把 ArrayList 和 HashMap 写进一个工具类里做缓存,这时要注意 ArrayList 的非线程安全性是否会被传递到缓存结构中。只要缓存可能被多线程读写,发音处理上要格外谨慎。

最后再分享一个我个人的小习惯:每当我在代码里看到new ArrayList<>()时,我都会条件反射地问一句,“这里到底有多少数据?能不能把容量估出来?”这个问题虽然简单,却帮我在不少性能排查场合提前规避了隐患。ArrayList 作为 Java 开发里最基础的集合工具,看似平平无奇,但把这些细节真正嚼透之后,写出来的代码会和以前明显不一样。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询