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

[特殊字符]颠覆MCP!Open WebUI新技术mcpo横空出世!支持ollama!轻松支持各种MCP Server!Cline+Claude3.7轻松开发论文检索MCP Server!

[特殊字符]颠覆MCP!Open WebUI新技术mcpo横空出世!支持ollama!轻松支持各种MCP Server!Cline+Claude3.7轻松开发论文检索MCP Server!

🔥🔥🔥本篇笔记所对应的视频:🚀颠覆MCP!Open WebUI新技术mcpo横空出世!支持ollama!轻松支持各种MCP Server!Cline+Claude3.7轻松开发MCP服务_哔哩哔哩_bilibili Open WebUI 的 MCPo 项目:将 MCP 工具无缝集成到 OpenAPI 的创新解决方案 随着人工智能工具和模型的快速发展,如何高效、安全地将这些工具集成到标准化的 API 接口中成为了开发者面临的重要挑战。Open WebUI 的 MCPo 项目(Model Context Protocol-to-OpenAPI Proxy Server)正是为了解决这一问题而设计的。本文将带您深入了解 MCPo 的功能、优势及其对开发者生态的影响。 什么是 MCPo? MCPo 是一个简单、可靠的代理服务器,能够将任何基于 MCP 协议的工具转换为兼容

By Ne0inhk
Qwen3+Qwen Agent 智能体开发实战,打开大模型MCP工具新方式!(一)

Qwen3+Qwen Agent 智能体开发实战,打开大模型MCP工具新方式!(一)

系列文章目录 一、Qwen3+Qwen Agent 智能体开发实战,打开大模型MCP工具新方式!(一) 二、Qwen3+Qwen Agent +MCP智能体开发实战(二)—10分钟打造"MiniManus" 前言 要说最近人工智能界最火热的开源大模型,必定是阿里发布不久的Qwen3系列模型。Qwen3模型凭借赶超DeepSeek-V3/R1的优异性能,创新的混合推理模式,以及极强的MCP能力迅速成为AI Agent开发的主流基座模型。大家可参考我的文章一文解析Qwen3大模型详细了解Qwen3模型的核心能力。有读者私信我: “Qwen3官网特地强调增强了Agent和代码能力,同时加强了对MCP的支持,那么我该如何利用Qwen3快速开发MCP应用呢?” 这就就需要使用我们今天的主角——Qwen官方推荐的开发工具Qwen-Agent ,本期分享我们就一起学习快速使用Qwen3+QwenAgent 接入MCP服务端,快速开发AI Agent应用! 一、注册 Qwen3 API-Key 本次分享通过阿里云百炼大模型服务平台API Key请求方式调用Qwen3大模型,获取服务平台

By Ne0inhk