别再乱用 ArrayList 了!这 4 个隐藏坑,90% 的 Java 开发者都踩过

🎁个人主页:User_芊芊君子

🎉欢迎大家点赞👍评论📝收藏⭐文章

🔍系列专栏:AI


文章目录:

【前言】

作为一名 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 元素后,后续处理时未判空导致空指针。

踩坑场景

  1. 通过索引操作列表时,未检查索引是否在 0 ~ size()-1 范围内(比如循环中用固定值索引、根据业务ID直接转索引);
  2. 向 ArrayList 添加 null 元素,后续遍历使用 element.xxx() 方法时未判空;
  3. 删除元素时,直接调用 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 不是"万能的",只有选对场景才能发挥它的优势:

  • 适用场景:单线程环境、读多写少、随机访问频繁(比如通过索引快速获取元素)、数据量可预估;
  • 替代方案
    1. 多线程写多场景:用 Collections.synchronizedList() 或手动加锁(ReentrantLock);
    2. 多线程读多写少场景:用 CopyOnWriteArrayList(牺牲写性能,保证读性能);
    3. 频繁增删(非末尾)场景:用 LinkedList(链表结构,增删无需拷贝数组);
    4. 线程安全且兼容旧代码:用 Vector(不推荐,性能差)。

ArrayList 作为 Java 最常用的集合之一,看似简单,实则藏着不少细节------很多开发者踩坑,不是因为技术不够,而是因为"想当然"。希望这篇文章能帮你避开这些坑,也欢迎在评论区分享你踩过的 ArrayList 坑,一起避坑成长!

总结

  1. ArrayList 遍历删除需用迭代器、倒序遍历或 Stream 过滤,避免 foreach+直接 remove 触发并发修改异常;
  2. 存储大量数据时务必设置初始容量,减少扩容带来的数组拷贝性能损耗;
  3. 多线程操作 ArrayList 需保证线程安全,优先选择 CopyOnWriteArrayList(读多写少)或手动加锁(写多)。


相关推荐
你不是我我2 小时前
【Java 开发日记】为什么要有 time _wait 状态,服务端这个状态过多是什么原因?
java·网络·php
xcLeigh2 小时前
JAVA项目实战:用飞算 JavaAI 高效开发电商系统核心功能模块
java·ai编程·电商系统·java开发·飞算javaai炫技赛
xcLeigh2 小时前
IoTDB Java 原生 API 实战:SessionPool 从入门到精通
java·开发语言·数据库·api·iotdb·sessionpool
qq12_8115175152 小时前
Java Web 影城会员管理系统系统源码-SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0【含文档】
java·前端·mybatis
杜子不疼.2 小时前
Java 智能体学习避坑指南:3 个常见误区,新手千万别踩,高效少走弯路
java·开发语言·人工智能·学习
冬天vs不冷2 小时前
为什么 Java 不让 Lambda 和匿名内部类修改外部变量?final 与等效 final 的真正意义
android·java·开发语言
星河耀银海2 小时前
JAVA 多线程编程:从基础原理到实战应用
java·开发语言·php
星河耀银海2 小时前
JAVA IO流:从基础原理到实战应用
java·服务器·开发语言
必胜刻2 小时前
Redis分布式锁讲解
数据库·redis·分布式