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

别再乱用 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 没同步更新,两者不一致就触发了异常,就像你在核对账单时,手里的账本和实际流水对不上,自然要报警。

在这里插入图片描述

错误/正确代码对比

错误代码
importjava.util.ArrayList;importjava.util.List;publicclassArrayListRemoveError{publicstaticvoidmain(String[] args){List<String> orderList =newArrayList<>(); orderList.add("有效订单1"); orderList.add("失效订单"); orderList.add("有效订单2");// 遍历删除“失效订单”——触发ConcurrentModificationExceptionfor(String order : orderList){if("失效订单".equals(order)){ orderList.remove(order);// 循环体内直接remove}}System.out.println(orderList);}}
正确代码(3 种方案)
importjava.util.ArrayList;importjava.util.Iterator;importjava.util.List;publicclassArrayListRemoveCorrect{publicstaticvoidmain(String[] args){List<String> orderList =newArrayList<>(); 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 =newArrayList<>(); 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 =newArrayList<>(); 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,会触发几十次扩容,每次都要拷贝数据,性能自然差。

在这里插入图片描述

错误/正确代码对比

错误代码
importjava.util.ArrayList;importjava.util.List;publicclassArrayListCapacityError{publicstaticvoidmain(String[] args){long startTime =System.currentTimeMillis();// 无参构造,默认容量10,存储10000条数据会频繁扩容List<Integer> dataList =newArrayList<>();for(int i =0; i <10000; i++){ dataList.add(i);}long endTime =System.currentTimeMillis();System.out.println("耗时:"+(endTime - startTime)+"ms");// 约2-5ms(小数据量差异小,大数据量差异显著)}}
正确代码
importjava.util.ArrayList;importjava.util.List;publicclassArrayListCapacityCorrect{publicstaticvoidmain(String[] args){long startTime =System.currentTimeMillis();// 已知数据量约10000,直接设置初始容量10000,避免扩容List<Integer> dataList =newArrayList<>(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 调用方法就会触发空指针,就像你拿到一个空信封,却想打开看里面的信,肯定会出错。

在这里插入图片描述

错误/正确代码对比

错误代码
importjava.util.ArrayList;importjava.util.List;publicclassArrayListIndexError{publicstaticvoidmain(String[] args){List<String> userList =newArrayList<>(); 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不存在,报错}}
正确代码
importjava.util.ArrayList;importjava.util.List;publicclassArrayListIndexCorrect{publicstaticvoidmain(String[] args){List<String> userList =newArrayList<>(); 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 同时修改同一个索引位置的元素,可能导致元素丢失。

这就像两个人同时往一个本子上写字,你写第一行,我也写第一行,最后本子上的字会乱掉,甚至写超出行数。

在这里插入图片描述

错误/正确代码对比

错误代码
importjava.util.ArrayList;importjava.util.List;publicclassArrayListThreadError{// 共享的ArrayListprivatestaticList<Integer> dataList =newArrayList<>();publicstaticvoidmain(String[] args)throwsInterruptedException{// 10个线程,每个线程添加1000个元素for(int i =0; i <10; i++){newThread(()->{for(int j =0; j <1000; j++){ dataList.add(j);}}).start();}// 等待线程执行完成Thread.sleep(2000);// 预期10*1000=10000,实际远小于10000,甚至抛出ArrayIndexOutOfBoundsExceptionSystem.out.println("最终元素数量:"+ dataList.size());}}
正确代码(3 种方案)
importjava.util.ArrayList;importjava.util.List;importjava.util.Vector;importjava.util.concurrent.CopyOnWriteArrayList;importjava.util.concurrent.locks.Lock;importjava.util.concurrent.locks.ReentrantLock;publicclassArrayListThreadCorrect{// 方案1:使用CopyOnWriteArrayList(适合读多写少场景)privatestaticList<Integer> cowList =newCopyOnWriteArrayList<>();// 方案2:使用Vector(线程安全,但性能较差,不推荐)privatestaticList<Integer> vectorList =newVector<>();// 方案3:手动加锁(灵活控制锁范围,推荐)privatestaticList<Integer> lockList =newArrayList<>();privatestaticLock lock =newReentrantLock();publicstaticvoidmain(String[] args)throwsInterruptedException{// 测试CopyOnWriteArrayListfor(int i =0; i <10; i++){newThread(()->{for(int j =0; j <1000; j++){ cowList.add(j);}}).start();}// 测试Vectorfor(int i =0; i <10; i++){newThread(()->{for(int j =0; j <1000; j++){ vectorList.add(j);}}).start();}// 测试手动加锁for(int i =0; i <10; i++){newThread(()->{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());// 10000System.out.println("Vector数量:"+ vectorList.size());// 10000System.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(读多写少)或手动加锁(写多)。
在这里插入图片描述

Read more

基于Rust实现爬取 GitHub Trending 热门仓库

基于Rust实现爬取 GitHub Trending 热门仓库

基于Rust实现爬取 GitHub Trending 热门仓库 这个实战项目将使用 Rust 实现一个爬虫,目标是爬取 GitHub Trending 页面的热门 Rust 仓库信息(仓库名、描述、星标数、作者等),并将结果输出为 JSON 文件。本次更新基于优化后的代码,重点提升了错误处理容错性和 CSS 选择器稳定性。 技术栈 * HTTP 请求:reqwest( Rust 最流行的 HTTP 客户端,支持异步) * HTML 解析:scraper(基于 selectors 库,支持 CSS 选择器,轻量高效) * JSON 序列化:serde + serde_json( Rust 标准的序列化

By Ne0inhk
实战教程:Leaflet+SpringBoot 实现地图任意点位点击查看时间功能

实战教程:Leaflet+SpringBoot 实现地图任意点位点击查看时间功能

目录 前言 一、需求解析 1、地图展示 2、时区和时间的关系 3、经纬度和时区的关系 二、应用实现 1、经纬度和时区求解 2、Leaflet 实现地图点击 3、前后台交互 三、成果展示 1、亚洲地区 2、欧洲地区 3、拉美地区 4、澳洲地区 四、总结 前言         在数字化、全球化的当下,地理位置与时间信息的结合应用,已经渗透到出行导航、跨境调度、物流追踪、国际业务展示等众多场景。用户不再满足于单纯查看地图点位,更需要点击地图任意位置,即可快速获取当地真实时间,比如针对国外新闻的展示,对于我国的用户需要知晓事件发生的时间,一般有两个时间的概念,即北京时间和当地时间。北京时间是跟我们同一时区,让我们清楚的知道在我们的时间时刻中,在何时发生。而全球是个分为多个时区的模式,

By Ne0inhk
FARS全自动科研系统技术深度解析:从多智能体架构到工业化科研范式

FARS全自动科研系统技术深度解析:从多智能体架构到工业化科研范式

前言 2026年2月12日至2月22日,一场持续228小时33分钟的直播在全球AI社区引发了持续震荡。屏幕另一端,一个名为FARS(Fully Automated Research System)的全自动研究系统,在没有人类干预的情况下,自主完成了从文献调研到论文撰写的完整科研流程,最终产出100篇学术论文,总消耗114亿Token,成本10.4万美元。 这场实验的意义远不止于“AI写论文”的简单升级。它向世界展示了科学发现的根本范式正在发生转移——从依赖人类灵感的“手工作坊”,转向由AI驱动的“工业化流水线”。本文将从最底层的技术细节出发,逐层拆解FARS的系统架构、智能体协作机制、资源调度策略、成本控制模型,以及与竞品的技术对比,为读者呈现一个完整的全自动科研系统技术图谱。 第一章 系统总体架构:四智能体流水线设计 1.1 核心设计理念:研究系统的第一性原理 FARS的设计并非简单地模仿人类科研流程,而是基于团队对“研究系统”本质的重新思考。创始团队提出,一个理想的研究系统应遵循两条基本原则: 1. 高效拓展知识边界:系统的吞吐量应成为核心评估指标,而非单篇论文的完

By Ne0inhk
基于 Rust 与 DeepSeek 大模型的智能 API Mock 生成器构建实录:从环境搭建到架构解析

基于 Rust 与 DeepSeek 大模型的智能 API Mock 生成器构建实录:从环境搭建到架构解析

前言 在现代软件工程中,API 接口的开发与前端联调往往存在时间差。为了解耦前后端开发进度,Mock 数据(模拟数据)的生成显得尤为关键。传统的 Mock 数据生成依赖于静态 JSON 文件或简单的规则引擎,难以覆盖复杂的业务逻辑与语义关联。随着大语言模型(LLM)的兴起,利用 AI 根据 Schema 定义动态生成高保真的模拟数据成为可能。本文详细记录了使用 Rust 语言结合 DeepSeek-V3.2 模型构建智能 Mock 生成器的完整技术路径,涵盖操作系统层面的环境准备、Rust 工具链的深度配置、代码层面的异步架构设计以及编译期的版本兼容性处理。 第一部分:Linux 系统底层的构建环境初始化 Rust 语言的编译与链接过程高度依赖于底层的系统工具链。Rust 编译器 rustc 在生成二进制文件时,需要调用链接器(Linker)将编译后的对象文件(Object Files)与系统库(

By Ne0inhk