
🎁个人主页:User_芊芊君子
🎉欢迎大家点赞👍评论📝收藏⭐文章
🔍系列专栏:AI


文章目录:
- 【前言】
-
- [坑 1:遍历删除元素,触发 ConcurrentModificationException](#坑 1:遍历删除元素,触发 ConcurrentModificationException)
-
- 坑的表现
- 踩坑场景
- 底层原因(通俗解释)
- 错误/正确代码对比
-
- 错误代码
- [正确代码(3 种方案)](#正确代码(3 种方案))
- [坑 2:初始容量设置不当,导致频繁扩容,性能损耗](#坑 2:初始容量设置不当,导致频繁扩容,性能损耗)
- [坑 3:空指针/索引越界,忽略索引范围或元素为空](#坑 3:空指针/索引越界,忽略索引范围或元素为空)
- [坑 4:非线程安全,多线程操作导致数据错乱](#坑 4:非线程安全,多线程操作导致数据错乱)
-
- 坑的表现
- 踩坑场景
- 底层原因(通俗解释)
- 错误/正确代码对比
-
- 错误代码
- [正确代码(3 种方案)](#正确代码(3 种方案))
- [ArrayList 避坑清单](#ArrayList 避坑清单)
- [结尾:ArrayList 该怎么选?](#结尾:ArrayList 该怎么选?)
【前言】
作为一名 1-3 年的 Java 开发者,你是不是觉得 ArrayList 就是个"基础款"集合,随便用都不会出问题?直到某天线上环境突然报出 ConcurrentModificationException,接口响应慢到用户投诉,甚至触发 OOM 导致服务宕机------你才发现,这个看似简单的 ArrayList,藏着不少能让你栽跟头的坑。
我曾在电商项目中遇到过这样的真实场景:大促期间订单查询接口响应耗时从 100ms 飙升到 3s,排查后发现是遍历订单列表删除无效数据时触发了并发修改异常;还有一次,用户积分清算功能因 ArrayList 频繁扩容,导致 CPU 使用率居高不下,最终引发线上告警。今天就跟大家拆解 ArrayList 最容易踩的 4 个坑,帮你避开这些"低级但致命"的错误。
坑 1:遍历删除元素,触发 ConcurrentModificationException
坑的表现
遍历 ArrayList 并删除指定元素时,程序直接抛出 ConcurrentModificationException(并发修改异常),甚至线上服务直接崩溃。
踩坑场景
最常见的场景是:业务中需要过滤列表数据(比如删除状态为"失效"的订单、清理空值元素),开发者习惯用 foreach 循环遍历,在循环体内调用 remove() 方法。
底层原因(通俗解释)
ArrayList 内部有个"修改计数器"(modCount),记录集合被修改的次数(添加、删除元素都会让它+1)。foreach 循环本质上是通过迭代器(Iterator)实现的,迭代器每次遍历都会检查"预期修改数"(expectedModCount)和实际的 modCount 是否一致------如果不一致,就认为有其他线程(或当前线程)在"偷偷修改"集合,直接抛出异常(这是一种快速失败机制,避免数据混乱)。
用 foreach 遍历+删除时,remove() 方法会修改 modCount,但迭代器的 expectedModCount 没同步更新,两者不一致就触发了异常,就像你在核对账单时,手里的账本和实际流水对不上,自然要报警。

错误/正确代码对比
错误代码
java
import java.util.ArrayList;
import java.util.List;
public class ArrayListRemoveError {
public static void main(String[] args) {
List<String> orderList = new ArrayList<>();
orderList.add("有效订单1");
orderList.add("失效订单");
orderList.add("有效订单2");
// 遍历删除"失效订单"------触发ConcurrentModificationException
for (String order : orderList) {
if ("失效订单".equals(order)) {
orderList.remove(order); // 循环体内直接remove
}
}
System.out.println(orderList);
}
}
正确代码(3 种方案)
java
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class ArrayListRemoveCorrect {
public static void main(String[] args) {
List<String> orderList = new ArrayList<>();
orderList.add("有效订单1");
orderList.add("失效订单");
orderList.add("有效订单2");
// 方案1:使用迭代器的remove()方法(推荐,最安全)
Iterator<String> iterator = orderList.iterator();
while (iterator.hasNext()) {
String order = iterator.next();
if ("失效订单".equals(order)) {
iterator.remove(); // 迭代器自身的remove方法,会同步更新modCount
}
}
System.out.println("方案1结果:" + orderList); // [有效订单1, 有效订单2]
// 方案2:倒序遍历删除(适合简单场景)
List<String> orderList2 = new ArrayList<>();
orderList2.add("有效订单1");
orderList2.add("失效订单");
orderList2.add("有效订单2");
for (int i = orderList2.size() - 1; i >= 0; i--) {
if ("失效订单".equals(orderList2.get(i))) {
orderList2.remove(i); // 倒序删除不会影响未遍历的元素索引
}
}
System.out.println("方案2结果:" + orderList2); // [有效订单1, 有效订单2]
// 方案3:Java 8+ Stream过滤(简洁,适合纯过滤场景)
List<String> orderList3 = new ArrayList<>();
orderList3.add("有效订单1");
orderList3.add("失效订单");
orderList3.add("有效订单2");
List<String> filteredList = orderList3.stream()
.filter(order -> !"失效订单".equals(order))
.toList();
System.out.println("方案3结果:" + filteredList); // [有效订单1, 有效订单2]
}
}
坑 2:初始容量设置不当,导致频繁扩容,性能损耗
坑的表现
接口响应慢、CPU 使用率高,排查后发现是 ArrayList 频繁触发扩容操作,大量消耗内存和计算资源;极端情况下,频繁扩容的内存拷贝会触发 GC,甚至 OOM。
踩坑场景
业务中需要存储大量数据(比如批量查询 1 万条用户数据、导入 10 万条订单记录),开发者直接使用 new ArrayList<>() 无参构造,默认初始容量为 10,数据量超过 10 就会触发扩容。
底层原因(通俗解释)
ArrayList 底层是数组实现的,数组的长度是固定的------就像你用一个 100ml 的水杯喝水,水多了装不下,就得换一个更大的杯子(比如 150ml),还要把原来的水倒进去。
ArrayList 的扩容规则是:默认每次扩容为原容量的 1.5 倍(无参构造),扩容时会新建一个更大的数组,把原数组的元素全部拷贝过去------这个"拷贝"操作是耗时的,数据量越大,拷贝越慢。如果一开始就知道要存 1 万条数据,却用默认容量 10,会触发几十次扩容,每次都要拷贝数据,性能自然差。

错误/正确代码对比
错误代码
java
import java.util.ArrayList;
import java.util.List;
public class ArrayListCapacityError {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
// 无参构造,默认容量10,存储10000条数据会频繁扩容
List<Integer> dataList = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
dataList.add(i);
}
long endTime = System.currentTimeMillis();
System.out.println("耗时:" + (endTime - startTime) + "ms"); // 约2-5ms(小数据量差异小,大数据量差异显著)
}
}
正确代码
java
import java.util.ArrayList;
import java.util.List;
public class ArrayListCapacityCorrect {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
// 已知数据量约10000,直接设置初始容量10000,避免扩容
List<Integer> dataList = new ArrayList<>(10000);
for (int i = 0; i < 10000; i++) {
dataList.add(i);
}
long endTime = System.currentTimeMillis();
System.out.println("耗时:" + (endTime - startTime) + "ms"); // 约1-2ms
}
}
扩展建议
如果不确定具体数据量,但知道大概范围(比如最多 1 万,最少 5000),可以设置初始容量为 预估数量 / 0.75 + 1(因为 ArrayList 的扩容阈值是容量的 0.75 倍),比如预估 1 万,设置 10000 / 0.75 + 1 ≈ 13334,进一步减少扩容次数。
坑 3:空指针/索引越界,忽略索引范围或元素为空
坑的表现
程序抛出 NullPointerException(空指针)或 IndexOutOfBoundsException(索引越界),比如调用 get(10) 但列表只有 5 个元素,或向列表添加 null 元素后,后续处理时未判空导致空指针。
踩坑场景
- 通过索引操作列表时,未检查索引是否在
0 ~ size()-1范围内(比如循环中用固定值索引、根据业务ID直接转索引); - 向 ArrayList 添加 null 元素,后续遍历使用
element.xxx()方法时未判空; - 删除元素时,直接调用
remove(index)但未检查 index 是否有效。
底层原因(通俗解释)
ArrayList 的索引就像数组的下标,必须从 0 开始,且不能超过"实际元素个数-1"------比如列表有 5 个元素,索引只能是 0-4,访问索引 5 就像你去 5 楼找房间,但这栋楼只有 4 层,自然找不到。
而 null 元素的问题在于:ArrayList 允许存储 null(数组可以存 null 引用),但后续使用元素的方法(比如 String.length())时,null 调用方法就会触发空指针,就像你拿到一个空信封,却想打开看里面的信,肯定会出错。

错误/正确代码对比
错误代码
java
import java.util.ArrayList;
import java.util.List;
public class ArrayListIndexError {
public static void main(String[] args) {
List<String> userList = new ArrayList<>();
userList.add("张三");
userList.add(null); // 添加null元素
userList.add("李四");
// 错误1:索引越界(size=3,索引只能0-2,访问3报错)
System.out.println(userList.get(3));
// 错误2:null元素未判空,触发空指针
for (String user : userList) {
System.out.println(user.length()); // null调用length()
}
// 错误3:删除索引时未检查范围
userList.remove(5); // 索引5不存在,报错
}
}
正确代码
java
import java.util.ArrayList;
import java.util.List;
public class ArrayListIndexCorrect {
public static void main(String[] args) {
List<String> userList = new ArrayList<>();
userList.add("张三");
userList.add(null);
userList.add("李四");
// 正确1:访问索引前检查范围
int index = 3;
if (index >= 0 && index < userList.size()) {
System.out.println(userList.get(index));
} else {
System.out.println("索引越界,当前列表大小:" + userList.size());
}
// 正确2:遍历元素时判空
for (String user : userList) {
if (user != null) { // 先判空再使用
System.out.println(user.length());
} else {
System.out.println("元素为空,跳过处理");
}
}
// 正确3:删除索引前检查范围
int removeIndex = 5;
if (removeIndex >= 0 && removeIndex < userList.size()) {
userList.remove(removeIndex);
} else {
System.out.println("删除失败,索引超出范围");
}
}
}
坑 4:非线程安全,多线程操作导致数据错乱
坑的表现
多线程环境下,向 ArrayList 添加/删除元素,出现元素丢失、数组越界、数据重复,甚至程序崩溃;比如线程 A 添加元素,线程 B 同时遍历,拿到的列表长度和实际元素数量不一致。
踩坑场景
接口并发请求时,多个线程同时操作同一个 ArrayList(比如统计接口调用次数、收集多线程处理结果);定时任务中,多线程修改同一个列表存储业务数据。
底层原因(通俗解释)
ArrayList 没有任何线程安全的保护机制------比如线程 A 正在扩容(拷贝数组),线程 B 同时添加元素,可能导致数组下标越界;线程 A 和线程 B 同时修改同一个索引位置的元素,可能导致元素丢失。
这就像两个人同时往一个本子上写字,你写第一行,我也写第一行,最后本子上的字会乱掉,甚至写超出行数。

错误/正确代码对比
错误代码
java
import java.util.ArrayList;
import java.util.List;
public class ArrayListThreadError {
// 共享的ArrayList
private static List<Integer> dataList = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
// 10个线程,每个线程添加1000个元素
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
dataList.add(j);
}
}).start();
}
// 等待线程执行完成
Thread.sleep(2000);
// 预期10*1000=10000,实际远小于10000,甚至抛出ArrayIndexOutOfBoundsException
System.out.println("最终元素数量:" + dataList.size());
}
}
正确代码(3 种方案)
java
import java.util.ArrayList;
import java.util.List;
import java.util.Vector;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ArrayListThreadCorrect {
// 方案1:使用CopyOnWriteArrayList(适合读多写少场景)
private static List<Integer> cowList = new CopyOnWriteArrayList<>();
// 方案2:使用Vector(线程安全,但性能较差,不推荐)
private static List<Integer> vectorList = new Vector<>();
// 方案3:手动加锁(灵活控制锁范围,推荐)
private static List<Integer> lockList = new ArrayList<>();
private static Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
// 测试CopyOnWriteArrayList
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
cowList.add(j);
}
}).start();
}
// 测试Vector
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
vectorList.add(j);
}
}).start();
}
// 测试手动加锁
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
lock.lock(); // 加锁
try {
lockList.add(j);
} finally {
lock.unlock(); // 释放锁
}
}
}).start();
}
// 等待线程执行完成
Thread.sleep(2000);
System.out.println("CopyOnWriteArrayList数量:" + cowList.size()); // 10000
System.out.println("Vector数量:" + vectorList.size()); // 10000
System.out.println("加锁ArrayList数量:" + lockList.size()); // 10000
}
}
ArrayList 避坑清单
| 坑点类型 | 核心问题 | 避坑方案 | 适用场景 |
|---|---|---|---|
| 遍历删除异常 | modCount和expectedModCount不一致 | 1. 迭代器remove();2. 倒序遍历;3. Stream过滤 | 单线程过滤列表数据 |
| 频繁扩容性能差 | 初始容量过小,多次数组拷贝 | 提前预估数据量,设置合理初始容量 | 存储大量已知范围的数据 |
| 空指针/索引越界 | 未检查索引/未判空 | 1. 索引操作前检查范围;2. 元素使用前判空 | 所有索引/元素操作场景 |
| 多线程数据错乱 | 非线程安全,无并发保护 | 1. 读多写少用CopyOnWriteArrayList;2. 手动加锁;3. 避免多线程共享 | 多线程操作列表场景 |
结尾:ArrayList 该怎么选?
ArrayList 不是"万能的",只有选对场景才能发挥它的优势:
- 适用场景:单线程环境、读多写少、随机访问频繁(比如通过索引快速获取元素)、数据量可预估;
- 替代方案 :
- 多线程写多场景:用
Collections.synchronizedList()或手动加锁(ReentrantLock); - 多线程读多写少场景:用
CopyOnWriteArrayList(牺牲写性能,保证读性能); - 频繁增删(非末尾)场景:用
LinkedList(链表结构,增删无需拷贝数组); - 线程安全且兼容旧代码:用
Vector(不推荐,性能差)。
- 多线程写多场景:用
ArrayList 作为 Java 最常用的集合之一,看似简单,实则藏着不少细节------很多开发者踩坑,不是因为技术不够,而是因为"想当然"。希望这篇文章能帮你避开这些坑,也欢迎在评论区分享你踩过的 ArrayList 坑,一起避坑成长!
总结
- ArrayList 遍历删除需用迭代器、倒序遍历或 Stream 过滤,避免 foreach+直接 remove 触发并发修改异常;
- 存储大量数据时务必设置初始容量,减少扩容带来的数组拷贝性能损耗;
- 多线程操作 ArrayList 需保证线程安全,优先选择 CopyOnWriteArrayList(读多写少)或手动加锁(写多)。

