别再乱用 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

MaxKB 新手保姆级教程:从零到一,亲手搭建你的专属 AI 知识库助手

MaxKB 新手保姆级教程:从零到一,亲手搭建你的专属 AI 知识库助手

你是否曾想过,能拥有一个只回答你自己领域知识的 AI 聊天机器人?一个能 7x24 小时为客户解答产品问题、为公司员工提供内部资料查询的智能客服?MaxKB 就是这样一款强大且开源的工具,它能帮助你轻松实现这个想法。 本文是一篇面向新手的、极其详尽的指南。将手把手带你完成 MaxKB 的安装、配置,并深入讲解如何创建和优化你的知识库,最后还将详细拆解其最强大的“高级应用”功能,让你真正掌握这个利器。 一、安装 MaxKB:三步搞定,小白也能行 对于新手而言,服务器环境配置往往是第一道坎。别担心,我们选用宝塔面板来简化一切操作。 1. 2. 执行安装命令 Docker 环境就绪后,点击面板左侧的 终端,这会打开一个命令输入窗口。复制以下这行命令,粘贴进去,然后按下回车键。 准备 Docker 环境 登录你的宝塔面板,在左侧菜单栏中找到并点击 docker。如果你是第一次使用,系统会提示你安装

By Ne0inhk
Java reactor编程详解

Java reactor编程详解

一、Reactor简介 Reactor 是由 Pivotal(Spring 团队)主导的响应式编程库,是 Java 响应式流(Reactive Streams)规范的重要实现之一。 它是 Spring WebFlux 的核心底层库,广泛用于构建高性能、非阻塞、异步的数据流应用。 主要特点: * 基于事件驱动和数据流 * 支持背压(Backpressure) * 高性能、低延迟 * 易于与 Spring WebFlux、Netty 集成 二、核心概念 2.1 Publisher & Subscriber * Publisher:数据源,发布数据流 * Subscriber:订阅数据流,处理数据 * Subscription:管理订阅关系和数据请求 2.2 Flux

By Ne0inhk
Spring AI:Java 生态的 AI 赋能革命,企业级智能应用新标杆

Spring AI:Java 生态的 AI 赋能革命,企业级智能应用新标杆

目录 一、核心定位:不止是框架,更是生态连接器 二、核心架构与关键能力:简化复杂 AI 应用构建 1. 对话交互核心:ChatClient 2. 语义理解基础:EmbeddingClient 与 VectorStore 3. 提示工程利器:PromptTemplate 4. 1.1 版本核心突破 三、典型场景落地:赋能全行业智能升级 四、未来展望:Java 生态的 AI 普及之路 当生成式 AI 与大型语言模型(LLMs)重塑软件开发范式,如何让 AI 能力无缝融入成熟的企业级技术体系,成为全球开发者面临的核心命题。Spring AI 的横空出世,为 Java 生态带来了颠覆性解决方案 —— 它以

By Ne0inhk
CoWoS封装技术全面解析:架构、演进与AI时代的基石作用

CoWoS封装技术全面解析:架构、演进与AI时代的基石作用

CoWoS(全称 Chip-on-Wafer-on-Substrate,即 “芯片 - 晶圆 - 基板封装”)是由台积电(TSMC)开发并主导的革命性先进封装技术,属于 2.5D 封装的核心代表。它通过在硅中介层(Silicon Interposer)上集成多颗异构芯片(如高性能逻辑芯片与高带宽存储器),并将整个堆叠结构与有机基板互连,实现超高密度、超低延迟的系统级集成,成为推动人工智能(AI)、高性能计算(HPC)及数据中心芯片发展的关键技术引擎。以下是关于 CoWoS 封装的全面解析: 一、技术本质与核心架构 1.名称拆解与封装原理 * CoW(Chip-on-Wafer):首先将多个功能芯片(如 GPU、CPU、AI 加速芯片等逻辑芯片及高带宽存储器 HBM 裸片)通过微凸块(Micro-Bumps)或混合键合技术垂直堆叠并互连到一片硅晶圆中介层上,形成高密度的芯片堆叠体(

By Ne0inhk