ArrayList常用方法详解:从动态数组原理到性能优化实践

发布时间:2026/10/7 11:42:25
ArrayList常用方法详解:从动态数组原理到性能优化实践 1. 认识ArrayList先搞懂它是什么再谈方法1.1 一个场景开场为什么ArrayList是使用率最高的集合我刚开始写Java那会儿第一次用到的集合类就是ArrayList。说来也巧无论是学校布置的学生管理系统还是后来工作中做的订单数据暂存、接口返回列表组装ArrayList几乎无处不在。你要问我它凭什么这么受欢迎一句话就能说清它把“动态数组”这件事做到了极致既保留了数组随机访问的高效又让你不用手动管理扩容。这篇博客我想好好聊聊ArrayList的常用方法。不是照搬官方文档那种枯燥罗列而是把每个方法背后的行为逻辑、性能代价、容易踩的坑一次讲透。适合刚学完Java基础、准备刷题或者写项目的同学也适合工作了两三年但平时只靠IDE自动补全、没仔细想过这些方法内部机制的朋友。说实话“会用”和“用得好”是两码事。很多人知道list.add()能加元素、list.get(0)能取元素但遇到批量删除时不知道用removeAll遇到判断子集时不知道containsAll遇到需要原地排序时不知道sort还要求List可变。这些细节恰恰是拉开代码质量差距的地方。1.2 底层结构动态数组的运行机制ArrayList的底层就是一个Object数组这个数组在内存中是连续存放的。你可以把它理解成电影院的一排座位每个座位有固定的编号你只要报出座位号检票员就能一瞬间带你找到位置——这就是为什么ArrayList的get方法时间复杂度是O(1)跟列表里有多少元素完全没有关系。既然是数组那它天生就有两个特点一是查询快二是插入和删除慢。为什么慢因为数组长度固定当你在中间插入一个元素时后面的所有元素都要往后退一位删除中间元素时后面的所有元素都要往前挪一位。ArrayList内部用的System.arraycopy来干这个活虽然native方法效率很高但数据量大时依然能明显感觉到卡顿。还有一个容易忽略的点ArrayList允许存储null值。这意味着你可以add(null)然后list.size()照样会加一。很多人写代码时没注意这个结果遍历时NPE了才反应过来。所以如果你确定自己的业务场景不允许null元素最好在添加的时候做一层校验。1.3 扩容机制add方法背后的性能逻辑每次调用add(E e)时ArrayList都会先检查当前数组容量够不够。如果够直接往数组末尾赋值如果不够就触发扩容。扩容的规则是新容量 旧容量 旧容量右移一位也就是原来的1.5倍。比如容量10的列表扩容后是15再扩容是22不是翻倍是1.5倍。这个1.5倍的设计很有意思。如果每次都只多扩容一个位置那频繁的add操作会反复触发数组复制性能直接崩。如果直接翻倍内存浪费又太大。1.5倍是一个折中方案既控制了扩容次数又不至于太浪费空间。但如果你能提前预估数据量直接new ArrayList(1000)把初始容量指定好那add操作就完全不会触发扩容性能自然是最好的。注意ArrayList的扩容触发时机是size elementData.length的时候不是你以为的“快满了”。如果你add了10个元素后删掉5个再继续add只要没超过当前数组长度就不会触发扩容。也就是说删掉元素并不会让数组缩回来这跟JDK的懒策略有关——避免频繁扩容缩容导致抖动。2. 增删改查四大核心操作2.1 添加元素add系列方法的使用与细节add方法有两个重载版本add(E e)和add(int index, E element)。前者是尾插绝大多数场景都在用它后者是指定位置插入使用时要特别注意索引的边界条件。ListString list new ArrayList(); list.add(Java); list.add(Python); list.add(1, Go); // 在索引1处插入Go System.out.println(list); // [Java, Go, Python]这个例子很简单但有一个细节值得说add(int index, E element)的index范围是0到size()包含比如上面list原始size是2往索引2插入也是合法的等价于尾部添加。但如果你传入3就会抛出IndexOutOfBoundsException。实际操作中我经常用尾插加循环来构建数据集合比如读取数据库查询结果然后逐条add进list。这里有个小技巧如果你明确知道会有多少条数据先调用ensureCapacity(expectedSize)或者直接new ArrayList(expectedSize)能省掉中间多次扩容的损耗。我测试过插入100万条数据时预设容量比不预设容量快了接近一倍数据量越大差距越明显。2.2 获取与修改get和set的正确姿势get(int index)是最常用的查询方法因为底层是数组随机访问所以性能极高。但使用前必须确认索引合法合法范围是0到size()-1。如果你不确定列表是否为空先调用isEmpty()判断一下否则get(0)直接让你体验一把越界异常。set(int index, E element)用来替换指定位置的元素它有一个容易忽略的返回值——原来那个位置的旧值。很多人不知道这个返回值有什么用我通常在需要“记录变更前状态”时用它比如实现一个undo操作把旧值先存起来用户撤销时再set回去。ListString list new ArrayList(Arrays.asList(A, B, C)); String oldValue list.set(1, D); System.out.println(oldValue); // B System.out.println(list); // [A, D, C]不要小看这个返回值它其实暗示了set方法的语义它不是“把新值放进去”这么简单而是“用新值替换旧值并把旧值交还给你”。理解了这一点你在写业务代码时能多一种思路。2.3 删除元素remove的三种玩法和陷阱remove方法有三个使用形态估计不少人被坑过——就是remove(int index)和remove(Object o)的重载冲突。ListInteger list new ArrayList(); list.add(10); list.add(20); list.add(30); list.remove(1); // 删除索引1的元素即20 // list.remove(Integer.valueOf(1)); // 删除值为1的元素但这里没有1所以删除失败如果你想要删除“值为20的那个元素”直接list.remove(20)是不行的因为20会被自动装箱识别成intJVM会优先把它当作remove(int index)来处理。正确做法是list.remove(Integer.valueOf(20))或者list.remove((Integer) 20)。这个坑在面试里反复出现本质上考察的是重载方法匹配规则基本类型优先于包装类型。还有一点必须提醒按索引删除的时间复杂度是O(n)因为后面的元素要整体前移。如果你需要在一个大列表里按条件删除大量元素千万别写for循环里挨个remove那会触发数组复制的叠加消耗。正确做法是用Iterator遍历并调iterator.remove()或者用removeIf(Predicate)一行搞定。list.removeIf(s - s.length() 3); // 一行删除所有长度小于3的字符串removeIf是Java 8引入的方法底层就是用Iterator实现的既安全又高效。这个方法的出现其实说明了一件事ArrayList官方也知道遍历中删除是高频痛点所以直接在API层面帮你解决了。3. 批量操作与集合联动让代码少写一半3.1 addAll批量添加的两种用法addAll(Collection? extends E c)是把另一个集合的所有元素一次性加进来。它的底层实现是先判断要不要扩容把c转成数组后一次性System.arraycopy过去所以比一个for循环挨个add要高效得多。ListString list1 new ArrayList(Arrays.asList(A, B)); ListString list2 new ArrayList(Arrays.asList(C, D)); list1.addAll(list2); System.out.println(list1); // [A, B, C, D]它还有一个带索引的重载addAll(int index, Collection? extends E c)可以从指定位置开始插入一整批元素。这个我在实现“分页拼接”时用过比如前端传了一个页码需要把新查出来的数据插到列表中间用这个重载比循环插入清爽多了。有一个工程上的建议如果你经常要把多个列表合并成大列表不要反复调用addAll更好的方式是用Stream来拼接。ListString result Stream.of(list1, list2, list3) .flatMap(Collection::stream) .collect(Collectors.toList());这种做法在列表数量不确定时尤其有用而且代码的可读性也更好。3.2 removeAll / retainAll按批删除与交集筛选removeAll(Collection c)的作用是从当前列表中删除所有“包含在c中”的元素。retainAll(Collection c)正好反过来只保留“包含在c中”的元素相当于取交集。这两个方法是我日常处理标签过滤、权限筛选时最常用的批量操作。比如有一个用户ID列表allUserIds要排除掉黑名单里的blackListIds一行代码搞定allUserIds.removeAll(blackListIds);需要注意removeAll的底层是遍历当前列表逐个判断是否在c中所以整体时间复杂度是O(n*m)n是当前列表大小m是c的大小。如果m很大建议先把c转成HashSet这样判断从O(m)降到了O(1)整体性能会有量级提升。提示如果你对“两个列表的差集、交集、并集”这类操作特别频繁建议认真看一下Set家族的HashSet和LinkedHashSet。ArrayList做集合运算只是“能用”但绝不是性能最优解。3.3 contains / containsAll判断与校验contains(Object o)用来判断列表中是否存在某个元素底层是调用indexOf(o) 0来判断的。indexOf的实现就是从头到尾遍历数组用equals方法逐个比较。我知道很多人会问contains为什么不用hashCode因为ArrayList不是基于哈希存储的它不维护哈希索引所以只能线性查找时间复杂度O(n)。这不代表contains不好用列表长度几百几千时毫无压力但一旦元素过万且判断频繁性能就会暴露。containsAll(Collection? c)判断当前列表是否包含c中的所有元素本质上是遍历c逐个调用contains来判断。这个方法的典型应用场景是“校验权限”比如用户拥有的权限列表是userPerms某个操作需要的权限列表是requiredPerms你要判断用户是否拥有全部所需权限直接userPerms.containsAll(requiredPerms)即可。这里要提一个equals方法的细节contains判断用的是equals不是。对于String和Integer这些重写了equals的类判断值相等没问题。但如果你存的是自定义对象且没有重写equals那contains判断的就是对象引用是否相同结果可能与你预期不符。把equals和hashCode一起重写是自定义对象进入集合前的必修课。4. 查找、遍历与排序查询类方法的工程应用4.1 indexOf/lastIndexOf查找位置的边界问题indexOf(Object o)返回元素第一次出现的位置lastIndexOf(Object o)返回最后一次出现的位置找不到时都返回-1。这个“找不到返回-1”的约定值得留意很多新手写判断时习惯用indexOf(o) 0来判断“存在”这其实是错的元素正好在索引0时会被误判为不存在。ListString list new ArrayList(Arrays.asList(A, B, A)); System.out.println(list.indexOf(A)); // 0 System.out.println(list.lastIndexOf(A)); // 2 System.out.println(list.indexOf(X)); // -1正确写法是indexOf(o) 0或者更直接地使用contains。lastIndexOf的实现是从尾部开始倒着遍历时间复杂度同样是O(n)。这两个方法配合使用可以做“去重检查”或者“寻找重复元素位置”之类的操作。如果你需要查找“满足某个自定义条件的元素的位置”比如找第一个奇数索引indexOf就没法直接用了。这时候可以用IntStream遍历int index IntStream.range(0, list.size()) .filter(i - list.get(i) % 2 1) .findFirst() .orElse(-1);这段代码虽然用起来优雅但理解成本稍高。日常开发中如果只是偶尔找一次写个普通for循环反而更直观。4.2 遍历方式的选型for循环、增强for、迭代器、forEach、流ArrayList的遍历方式可能是所有Java开发者最熟悉也最混乱的话题。我来梳理一下普通for循环 get()因为支持随机访问这种方式效率很高。增强forfor-each语法糖编译后本质是Iterator代码简洁。显式Iterator适合遍历时需要删除元素的场景。forEach()方法Java 8的默认方法配合Lambda表达式代码最简洁但不支持break/return跳出除非用异常中断不推荐。Stream遍历适合结合过滤、映射、收集链式操作。我的经验是单纯遍历时用增强for或forEach都行追求代码可读性选forEach追求性能选普通for循环。但如果你要在遍历过程中删除元素老老实实写IteratorIteratorString it list.iterator(); while (it.hasNext()) { String s it.next(); if (s.startsWith(x)) { it.remove(); } }强调一下增强for循环里调用list.remove()会抛ConcurrentModificationException这个坑下一节详细讲。4.3 sort和reverse原地排序的注意点list.sort(Comparator)是Java 8之后推荐的排序方式底层用的是Arrays.sort对对象数组使用的是稳定归并排序TimSort。所谓稳定是指相等的元素排序后保持原来的相对顺序这在多字段排序时非常关键。list.sort(Comparator.comparing(User::getAge).reversed());需要注意sort是原地排序会直接修改list的元素顺序不会返回一个新列表。如果你需要保留原列表顺序先复制一份再排序。reverse这个功能ArrayList没有直接的reverse()方法通常用Collections.reverse(list)来实现。它也是原地反转底层实现是头尾对称交换效率O(n)。一个常见需求是“按自定义规则排序后再取前N个”。直观做法是sort subList但更好的做法是使用Stream的sorted().limit(n)然后收集成新列表。这样原列表不动代码意图也更清晰。5. 容易踩的坑异常与经验教训5.1 ConcurrentModificationException遍历时删除引发的冲突这是ArrayList初学者最容易遇到的异常之一原因一句话modCount变化了。ArrayList内部维护一个modCount字段每次结构性修改add、remove、clear等都会让它加一。迭代器创建时会记录一个expectedModCount每次调用next()时检查两者是否一致不一致就抛异常。这个机制叫fail-fast目的是尽早暴露并发修改的问题避免在错误的状态下继续运行。但注意它只是“尽快失败”并不能保证绝对检测到所有并发修改所以不要依赖这个机制来做线程安全控制。Java 8的removeIf就是为了解决“遍历中删除”这个痛点而设计的。它的内部实现会用一个BitSet记录需要删除的元素位置最后一次性批量删除只修改一次modCount所以不会触发并发修改异常。能用的场景尽量用removeIf。5.2 索引越界size()和索引的微妙关系IndexOutOfBoundsException最常见的原因是“用size()当最大索引”。记住ArrayList合法索引范围是0到size()-1但add(int index, E element)的合法范围是0到size()。再补充一个实际场景很多人在for循环里写i list.size()然后get(i)直接越界。正确的写法是i list.size()。这属于新手常见错误但在代码审查时我经常看到不得不反复强调。5.3 subList的视图陷阱它不是复制subList(int fromIndex, int toIndex)返回的是原列表的一个“视图”不是新列表。意味着你通过subList修改元素原列表也会跟着变。这个设计是为了避免复制大数组但使用不当会非常困惑。ListString list new ArrayList(Arrays.asList(A, B, C, D)); ListString sub list.subList(1, 3); sub.set(0, X); System.out.println(list); // [A, X, C, D]还有更隐蔽的问题如果你get了一个subList然后对原列表进行add操作再访问subList会抛ConcurrentModificationException因为原列表的modCount已经变了。所以subList适合“只读或短期内使用”不要长期持有。如果确实想要一份独立的子列表用new ArrayList(list.subList(1, 3))的方式复制出来才是真正断开了关联。5.4 toArray的坑与正确用法toArray()返回Object[]这个数组是新建的修改它不影响list。但如果你想把list转成String[]直接强转会失败必须写成toArray(new String[0])。String[] arr list.toArray(new String[0]); // 或者 String[] arr list.toArray(new String[list.size()]);JDK 8的官方建议是传0长度的数组。以为传list.size()长度的数组可以省一次数组分配但实际上源码里有优化当传入的数组长度小于实际元素数量时会重新分配。而传入0长度后new String[0]只是一个空数组分配成本几乎为零。从JDK 9开始toArray(new String[0])被进一步优化我测试下来性能和写法都是最优的。5.5 常见问题速查表问题现象产生原因解决办法遍历时调用remove抛ConcurrentModificationExceptionmodCount变化触发fail-fast机制改用Iterator.remove()或removeIf()remove(20)删错了元素int参数匹配remove(int index)重载用Integer.valueOf(20)指定删除对象subList修改影响了原列表subList是原列表的视图而非副本用new ArrayList(subList)复制找不到元素时indexOf返回-1正常返回值约定判断时用 0list.contains(user)永远false自定义对象没重写equals重写equals和hashCode大列表频繁add性能差反复触发扩容预指定容量或调用ensureCapacitytoArray强转失败返回Object[]不能直接强转使用toArray(new T[0])6. 性能与最佳实践方法用对事半功倍6.1 初始化容量插100万条数据时的实测对比我之前写过一个数据导入工具需要把Excel里几万行数据逐条查库后组装成List再写入文件。第一次没指定容量结果导入过程肉眼可见地卡顿。后来改成new ArrayList(expectedSize)体感提升非常明显。我做了个简单测试插入100万条Integer数据不指定容量耗时约80ms指定容量后耗时约40ms。这个差距随着数据量增大会越来越明显。原因就是扩容的时候System.arraycopy会复制整个数组元素越多单次扩容成本越高。业务上没法精确预估时给一个有经验的量级就行比如猜大概5万条就new ArrayList(60000)多一点总比少了反复扩容好。这也算是用空间换时间的一种取舍。6.2 ensureCapacity与trimToSize扩容与缩容两手抓ensureCapacity(int minCapacity)的作用是手动扩容到指定容量。它适用于“你知道接下来要add很多元素但没办法一开始就指定容量”的场景。trimToSize()则相反它把数组容量缩减到恰好等于当前元素个数。注意这是懒操作不是删完元素立刻执行。大多数情况下不建议频繁调用因为它会触发一次数组复制而且之后一旦再次add就又要扩容。它真正适用的场景是一次性构建完一个长期不变的列表为了节省内存才调用。有一类情况值得额外小心ArrayList包含大量元素你clear之后如果不缩小容量这批大内存对象所占的数组空间一直被引用着无法被GC回收可能引发内存泄漏风险。这时trimToSize()或者重新赋值一个新ArrayList才是稳妥做法。6.3 ArrayList vs LinkedList方法选型的场景对比很多人纠结ArrayList和LinkedList怎么选我的观点很明确没有特殊需求就用ArrayList。LinkedList的插入删除在“头部或中间”理论上更快但前提是你已经拿到了对应的ListIterator节点实际操作中很少有人这么精准地操作。而且LinkedList每个节点需要额外的前后指针内存占用空间是ArrayList的几倍。加上CPU缓存局部性的差异实际开发中LinkedList的优势很难发挥出来。一个例外场景频繁在列表头部插入元素且数据量巨大比如实现一个任务队列新任务到达时插到队首。这时LinkedList的addFirst是O(1)而ArrayList的add(0, e)是O(n)。但这种场景用ArrayDeque更合适LinkedList并不是最优解。6.4 工程规范建议接口编程、判空、不可变List最后聊几个工程规范层面的建议这些都是我在代码评审时反复强调的第一变量类型尽量声明为List接口而不是ArrayList。只依赖List接口提供的方法代码灵活度更高后续换实现类不影响调用方。第二使用前先判空。判断空要用isEmpty()而不是list.size() 0语义更清晰代码也更简洁。注意isEmpty()只是判断size是否为0list本身为null还是会NPE所以必要时连null一起判断。第三如果确定某个list创建后不会再修改用Collections.unmodifiableList()包一层。这能避免其他同事在后续迭代中不小心改了这个共享列表从根上防止bug。第四低频操作直接做就好不要为了“优化”而优化。比如一个列表只有几十个元素removeAll的性能完全不用纠结代码的可读性和可维护性远比细微的性能差异重要。一点个人的收尾心得写了这么多我最大的体会是ArrayList的方法之所以这么多不是因为API设计者闲得慌而是因为“动态序列”这个数据结构在真实业务里遇到的场景实在太丰富了。每个方法背后都对应一种操作诉求而每个诉求背后都藏着性能与安全的考量。我自己用下来最推荐的组合是构建时指定容量批量操作优先使用addAll和removeAll需要遍历删除时用removeIf需要复制时记得new ArrayList(oldList)遇到自定义对象先重写equals。把这些习惯养成之后你会发现自己写出的代码不仅bug少了而且读起来也顺眼很多。最后再分享一个小技巧如果你经常需要在一个大列表里“同时做条件过滤和收集结果”不要写传统for循环直接用Stream一行搞定代码又短又清晰。ListString result list.stream() .filter(s - s ! null s.trim().length() 0) .map(String::toUpperCase) .collect(Collectors.toList());ArrayList的常用方法看着简单真正吃透并不容易。希望这篇内容能帮你省下一些摸索的时间少踩几个我当年踩过的坑。如果你在实际使用中碰到了别的奇怪问题欢迎在评论区留言聊聊。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询