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

直击复杂 SQL 瓶颈:基于代价的连接条件下推技术落地

直击复杂 SQL 瓶颈:基于代价的连接条件下推技术落地

一、引言 在数据库理论的学习过程中,我们常常接触到简洁优美的SQL示例——单表查询、简单连接、基础过滤,这些案例清晰地展示了关系代数的基本原理。然而,当我们步入真实的业务系统,面对的SQL语句往往如同缠绕的线团:公用表表达式(CTE)层层嵌套,子查询彼此交织,窗口函数与聚集计算随处可见。 这种复杂性并非开发人员的炫技,而是业务逻辑的自然映射。遗憾的是,这种为提升可读性而组织的SQL结构,却给查询优化器带来了严峻考验。在众多性能瓶颈中,有一个问题尤为突出:高选择性的连接条件无法穿透复杂的子查询结构,导致数据过滤发生在错误的时间点。本文将深入探讨这一问题的本质,并介绍一种基于代价模型的连接条件下推解决方案,展示如何让优化器既懂“安全”,又知“成本”。 二、性能困境:过滤迟到的代价 2.1 真实场景的切面分析 在大量客户业务系统中,一种常见的SQL编写模式反复出现:开发人员习惯先在子查询或CTE中完成复杂的预处理逻辑——去重、排序、窗口计算,然后再将这些预处理结果与其它表进行连接,最后施加过滤条件。从业务语义角度看,这种写法清晰自然;但从执行效率角度看,却暗藏危机。 考虑

By Ne0inhk
PHP常见中高面试题汇总

PHP常见中高面试题汇总

一、 PHP部分 1、PHP如何实现静态化 PHP的静态化分为:纯静态和伪静态。其中纯静态又分为:局部纯静态和全部纯静态。 PHP伪静态:利用Apache mod_rewrite实现URL重写的方法; PHP纯静态,就是生成HTML文件的方式,我们须要开启PHP自带的缓存机制,即ob_start来开启缓存。 2、PHP经典四大排序算法 PHP的四种基本排序算法为:冒泡排序、插入排序、选择排序和快速排序。 冒泡排序:对数组进行多轮冒泡,每一轮对数组中的元素两两比较,调整位置,冒出一个最大的数来。 插入排序:假设组前面的元素是排好序的,遍历数组后面的元素,在已排好序的元素队列中找到合适的位置,插入其中。 选择排序:进行多次选择,每次选出最大元素放入指定位置。 快速排序:递归算法。先选择数组的第一个元素作为标准,然后把小于或等于它和大于它的数分别放入两个数组中,对这两个数组也进行相同的处理,最后合并这两个数组和第一个元素。 3、PHP常见运行模式 1)CGI(通用网关接口/ Common Gateway Interface)

By Ne0inhk
Flutter 第三方库 spa 的鸿蒙适配实战 - 打造单页应用架构、动态渲染路由状态及鸿蒙大屏多窗体验优化方案

Flutter 第三方库 spa 的鸿蒙适配实战 - 打造单页应用架构、动态渲染路由状态及鸿蒙大屏多窗体验优化方案

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.ZEEKLOG.net Flutter 第三方库 spa 的鸿蒙适配实战 - 打造单页应用架构、动态渲染路由状态及鸿蒙大屏多窗体验优化方案 前言 随着移动端交互的日益复杂,用户对 App 的流畅度要求已不仅仅停留在“帧率”上,更多的是关于页面切换的“无缝感”。单页应用(Single Page Application, SPA)模式,通过在一个长生命周期的视图内动态替换内容节点,有效地避免了频繁的页面推栈(Push/Pop)带来的布局重绘开销。 spa 库是 Flutter 生态中一个非常独特且高效的路由增强工具。它将路由状态抽象为一套可观察的树状结构,让开发者能像管理 Web 应用一样管理 Flutter 的页面状态。 在鸿蒙系统(OpenHarmony)适配实战中,面对折叠屏的灵活切换和平板的多窗协同,spa 提供了一种天然的“响应式分发”基座。

By Ne0inhk
基于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