news 2026/10/17 7:52:18

ArrayList深度解析:从底层原理到实战避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ArrayList深度解析:从底层原理到实战避坑

学 Java 集合框架的人,基本都会在某一天和 ArrayList 打个照面。我自己的打卡记录里,ArrayList 刚好排在第二十九天,说巧也巧——它既不像数组那样“裸奔”,也没有后续那些并发容器那样自带层层锁,但几乎每个项目里都有它的身影。很多时候你甚至不用主动去“学”它,因为写代码时随手就会用到,这就是标题里那句“命中有时终须有”的第一层意思:这东西你迟早要正面面对。第二层意思更有意思:ArrayList 看起来无所不能,但它骨子里受限于“数组”这个宿命,查找快、插入慢、扩容有代价,这些特性决定了你什么时候该选它、什么时候要绕开它。

这篇笔记不适合完全零基础的人直接啃,但你只要已经掌握数组和基本面向对象语法,就能跟着走完。我会重点讲清楚三件事:ArrayList 底层到底怎么工作、为什么它有些操作那么快而另一些操作那么慢、实战里踩过的那些坑是怎么排查和解决的。搞懂这些,后面再学 LinkedList、HashMap 乃至源码阅读,都会顺很多。

1. 先从设计思路上认识 ArrayList

1.1 它到底是个什么东西

说穿了,ArrayList 就是一个“会自动扩容的数组”。它的英语名字拆开看:Array(数组)+ List(列表),所以它本质是一个用数组存储元素的列表。日常写业务代码时,你会频繁地创建列表、往里面加东西、用 for 循环遍历它,ArrayList 就是干这个用的,而且用起来比原生数组舒服太多。

原生数组有个问题是声明后长度固定:

String[] arr = new String[10]; arr[0] = "hello"; // 可以 // arr[10] = "world"; // 数组越界,直接异常

ArrayList 则允许你无脑 add:

ArrayList<String> list = new ArrayList<>(); list.add("hello"); list.add("world"); list.add("java"); System.out.println(list.size()); // 3

这背后发生了什么?很简单:ArrayList 内部维护了一个Object[] elementData,当你 add 的内容超过当前数组容量时,它自己创建一个更大的新数组,再把旧数据拷贝过去。你感知不到扩容过程,但它确实在做这件事。

从“是什么”这个层面,我建议你建立这样一个认知框架:ArrayList 是 Java 集合框架里最基础的顺序表实现,它用连续内存空间存储元素,支持随机访问,是学习其他集合类的跳板。理解了它,再去学 Vector、LinkedList、CopyOnWriteArrayList 时,你会有强烈的对照感,学起来快得多。

1.2 为什么选数组而不是链表

这是个好问题,也是很多面试爱问的。我自己的理解是:ArrayList 选数组作为底层结构,核心是为了“随机访问”的效率。

数组在内存里是连续的,每个元素占用固定大小的空间,所以只要知道首地址,就能通过首地址 + 下标 * 元素大小直接算出某个下标对应的内存位置。这就是 O(1) 随机访问的来源。你在业务代码里写list.get(5),底层就是一次数组下标取值,速度极快。

而链表(比如 LinkedList)靠节点之间的引用串联,要找第 6 个元素,必须从第一个节点逐个 next 过去,时间复杂度是 O(n)。对于“按索引取值”这个场景,链表天生吃亏。

但是“连续内存”也带来代价:插入和删除元素时,为了保持连续性,需要搬动后续所有元素。你在数组中间插一个值,它后面的元素全要往后挪一格;删除一个元素,后面的全要往前挪一格。这个搬移操作是顺序执行的,最坏情况下是 O(n)。

所以 ArrayList 的设计哲学非常明确:牺牲“中部插入删除”的性能,换取“按下标访问”的高效。这也是为什么如果你频繁在列表头部插入数据,我建议你换 LinkedList,或者用 ArrayDeque。没有哪种数据结构是银弹,关键是搞清楚你手头的业务更看重哪个操作。

1.3 继承体系里还藏着哪些能力

很多人学 ArrayList 只盯着它本身,不去看它的继承关系,结果后面匿名内部类、方法引用、流操作这些概念跟集合搅在一起时就开始懵了。这里给你捋一条主线:

Iterable <-- Collection <-- List <-- ArrayList

把这个链条拆开看:

  • Iterable:可迭代的,意味着你可以用增强 for 循环遍历它;
  • Collection:集合的通用规范,定义了 add、remove、size、isEmpty 这些操作;
  • List:有序、可重复、支持索引访问;
  • ArrayList:具体实现。

除了继承关系,ArrayList 还实现了几个接口,最需要注意的是RandomAccess。这个接口里一个方法都没定义,纯粹是个标记,表示“我这个类支持快速随机访问”。它的存在会影响 Java 底层一些工具类的策略选择:比如Collections.binarySearch(),遇到实现 RandomAccess 的 List 会走索引版二分查找,否则走迭代器版,两者性能差异巨大。

还有Cloneable和java.io.Serializable这两个标记接口,决定了 ArrayList 可以被克隆、可以被序列化。日常你用不到太多,但搞清楚它们是干什么的,后续看源码时不会发怵。

2. 核心细节:扩容机制与底层操作

2.1 三种构造器分别怎么用

ArrayList 有三个构造器,很多人只写过无参版本,其实另外两个也有它的使用场景。

无参构造器:

ArrayList<String> list = new ArrayList<>();

这段代码底层创建了一个空的Object[],也就是elementData初始为空数组。你第一次 add 的时候,它才真正分配一个默认容量为 10 的数组。这叫“懒加载”,不是 new 出来就有 10 个坑位。

指定初始容量构造器:

ArrayList<Integer> list = new ArrayList<>(100);

如果你大概知道数据量级,建议直接用这个。比如你要从数据库查一万条记录放进列表,一上来就给足容量,可以省掉后续多轮扩容的拷贝。这个习惯越早养成越好。

用其他集合初始化:

List<String> source = Arrays.asList("a", "b", "c"); ArrayList<String> list = new ArrayList<>(source);

这个构造器先把传入集合转成数组,再拷贝到 elementData 里。注意它调用的是Arrays.copyOf,不是简单的引用赋值,所以后续修改原集合,不会影响新 ArrayList。

2.2 扩容到底怎么扩的

我见过不少工作两三年的同学,问到扩容机制只会说“不够就扩容”,问具体扩多少,就卡壳了。其实这个细节非常重要,它直接影响你对性能和代码可靠性的判断。

先看 add 的流程:每次 add 时,它会先检查当前数组容量elementData.length是否还能容纳新元素。如果不够,就调用 grow 方法扩容。新容量计算公式在 JDK 8 及之后是:

新容量 = 旧容量 + 旧容量 >> 1

也就是增长原来的 1.5 倍。为什么是 1.5 倍而不是翻倍?我个人的理解是:1.5 倍是时间与空间的折中。翻倍扩容虽然扩容次数更少,但内存浪费更严重;1.5 倍扩容更保守一些,在大多数场景下内存利用率更高。JDK 官方没有给出很玄学的原因说明,但从工程角度看,这就是一个经过权衡的数值。

如果 oldCapacity 是 10,那新容量就是 15。注意这个>> 1是位运算右移一位,等价于除以 2。新容量算出来后,调用Arrays.copyOf(elementData, newCapacity),底层实际上是System.arraycopy,把旧数组元素拷贝到新数组,然后将 elementData 引用指向新数组。

有个极端场景要额外小心:如果新容量算出来比我需要的还小,JDK 会直接用需要的容量作为新容量;如果需要的容量已经超过MAX_ARRAY_SIZE(通常是Integer.MAX_VALUE - 8),还会走hugeCapacity分支,强行给到Integer.MAX_VALUE。这些细节平时遇不到,但在做“一次性装几千万条数据”的批处理任务时,理解它就能避免 OutOfMemoryError 误判。

2.3 增删改查的时间代价

搞懂 ArrayList 的时间复杂度,对你选型很重要。我直接给一张图式结论:

操作时间复杂度原因
get(index)O(1)直接数组下标访问
set(index, value)O(1)同样直接定位,替换旧值
add(E e) 尾部追加均摊 O(1)偶尔扩容,但大部分情况直接赋值
add(index, E e) 中间插入O(n)需要搬移 index 及其后元素
remove(index)O(n)需要搬移 index 之后所有元素向前一位
remove(Object o)O(n)先线性查找对象位置,再搬移
contains(Object o)O(n)线性遍历 equals 比较
indexOf(Object o)O(n)线性遍历查找

重点解释一下“尾部追加均摊 O(1)”。单独某一次 add 可能触发扩容,扩容要 O(n);但扩容不是每次都发生,假设从容量 10 开始,连续 add 10 次,前 9 次都是 O(1),第 10 次扩容 O(10);随后容量变成 15,又能免扩容地 add 5 次……把这些次数摊到每一次 add 上,平均复杂度仍然是个常数。这就是“均摊”的含义。

中间插入为什么是 O(n)?看底层代码就能理解。比如你在 index=2 的位置插入新元素,JDK 会调用:

System.arraycopy(elementData, index, elementData, index + 1, size - index);

意思是把 index 开始的所有元素整体向后移一位,腾出空位。注意,如果插入位置正好是末尾(index == size),那size - index等于 0,不会移动任何元素,所以尾部 add 才那么快。

删除同理:

System.arraycopy(elementData, index + 1, elementData, index, size - index - 1);

把 index 后面所有元素整体前移一位,然后把最后一个位置置 null,帮助 GC 回收。这也是一个 O(n) 操作。

3. 手写一个可跑的成绩管理 Demo

3.1 场景设计与数据结构选择

光看原理容易走神,我们直接上手一个简单的成绩管理案例。假设你要维护一个班级的学生成绩,需求是:支持新增学生成绩、按姓名查找成绩、删除某位学生、统计全班平均分。我把这些操作全部用 ArrayList 实现一遍,你跟着敲一遍,会对前面讲的特性有非常直观的体感。

这个场景用 ArrayList 是合适的:数据量不大(几十人)、主要是遍历和尾部添加、偶尔按姓名查。如果你硬要在这里用 LinkedList,不仅代码不会更短,按索引操作反而更慢;如果你用 HashMap,按姓名查确实更快,但有序性就没了。这里的取舍刚好能验证选型逻辑。

先建一个简单的学生类:

public class Student { private String name; private int score; public Student(String name, int score) { this.name = name; this.score = score; } public String getName() { return name; } public int getScore() { return score; } @Override public String toString() { return "Student{name='" + name + "', score=" + score + "}"; } }

3.2 核心代码逐段说明

接下来是管理类,我用 ArrayList 保存学生对象:

import java.util.ArrayList; public class ScoreManager { private ArrayList<Student> students = new ArrayList<>(); // 1. 新增学生 public void addStudent(Student student) { students.add(student); System.out.println("添加成功:" + student.getName()); } // 2. 按姓名查找成绩(找到第一人就返回) public Integer findScoreByName(String name) { for (Student s : students) { if (s.getName().equals(name)) { return s.getScore(); } } return null; } // 3. 按姓名删除学生(删除第一个匹配的) public boolean removeByName(String name) { for (int i = 0; i < students.size(); i++) { if (students.get(i).getName().equals(name)) { students.remove(i); System.out.println("已删除:" + name); return true; } } return false; } // 4. 计算平均分 public double averageScore() { if (students.isEmpty()) { return 0.0; } int sum = 0; for (Student s : students) { sum += s.getScore(); } return (double) sum / students.size(); } // 5. 打印全部学生 public void printAll() { for (Student s : students) { System.out.println(s); } } }

这里有几个细节我再啰嗦一下。

第一,findScoreByName用的是增强 for 循环,它底层就是迭代器。你在循环里只读不写,非常安全。

第二,removeByName我特意用了传统 for 循环加下标,而不是增强 for,因为增强 for 循环里直接调用students.remove(i)会抛ConcurrentModificationException。至于为什么,下一章会详细拆解,这里你先记住这个写法差异。

第三,averageScore里先调isEmpty()判断,避免除数为零。isEmpty 底层其实就是return size == 0,比students.size() == 0语义更清晰。

3.3 运行效果与扩展方向

写一个 Main 类跑一下:

public class Main { public static void main(String[] args) { ScoreManager manager = new ScoreManager(); manager.addStudent(new Student("张三", 88)); manager.addStudent(new Student("李四", 75)); manager.addStudent(new Student("王五", 93)); System.out.println("全部学生:"); manager.printAll(); Integer score = manager.findScoreByName("李四"); System.out.println("李四的成绩:" + score); manager.removeByName("张三"); System.out.println("删除后平均分:" + manager.averageScore()); } }

运行结果大致是:

添加成功:张三 添加成功:李四 添加成功:王五 全部学生: Student{name='张三', score=88} Student{name='李四', score=75} Student{name='王五', score=93} 李四的成绩:75 已删除:张三 删除后平均分:84.0

这个 Demo 跑通之后,你可以自己往这几个方向扩展:

  • 增加“按姓名修改成绩”的方法,其实就是 get + set 的组合;
  • 增加“按分数排序”,可以用Collections.sort结合 Comparator;
  • 增加“成绩不及格名单”,用removeIf一行搞定;
  • 增加“输入一批初始学生”,用new ArrayList<>(另一个集合)批量初始化。

每次扩展你都会发现,ArrayList 的各种 API 都是在“数组”这个底座上加糖,用得越多,你对它的脾气就越了解。

4. 常见问题与排查技巧实录

4.1 遍历时删除元素为何抛异常

这个问题我敢说每个 Java 开发者都至少碰到过一次。代码长这样:

ArrayList<String> list = new ArrayList<>(); list.add("a"); list.add("b"); list.add("c"); for (String s : list) { if (s.equals("b")) { list.remove(s); // 可能抛 ConcurrentModificationException } }

为什么会抛异常?关键在于增强 for 循环底层用的是迭代器Iterator,而迭代器内部维护了一个expectedModCount字段,它期望这个集合在你遍历期间没有被外部结构性地修改过。结构性修改指的是 add、remove、clear 这些改变元素个数的操作。每次迭代器调用 next 方法时,它会检查modCount != expectedModCount,一旦发现“集合被改过了,但迭代器不知道”,立刻抛出异常。

这里有个容易混淆的点:为什么有时候不抛?比如列表中只有两个元素,你 remove 掉第一个就立刻结束循环,恰好没有触发下一次 next 的校验,程序就“侥幸”通过了。这种不确定行为非常坑,你绝不能依赖它。

正确做法有三种,我按推荐程度排序:

第一种:使用迭代器自己的 remove 方法:

Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String s = iterator.next(); if (s.equals("b")) { iterator.remove(); } }

迭代器内部的 remove 会同步更新modCount和expectedModCount,所以不会抛异常。

第二种:用 JDK 8 之后的 removeIf:

list.removeIf(s -> s.equals("b"));

这是最简洁的写法,底层已经帮你处理好了。

第三种:先收集再统一删除:

ArrayList<String> toRemove = new ArrayList<>(); for (String s : list) { if (s.equals("b")) { toRemove.add(s); } } list.removeAll(toRemove);

适用场景是删除条件非常复杂、且需要在遍历过程中依赖其他变量做判断时。

4.2 subList 返回的是视图不是副本

接着来一个我真正踩过坑的地方。subList(fromIndex, toIndex)看起来像是“取一个子列表副本”,但实际上它返回的是原列表的视图(view)。什么意思?你在 subList 上做修改,会直接影响原列表。

ArrayList<String> list = new ArrayList<>(); list.add("A"); list.add("B"); list.add("C"); list.add("D"); List<String> sub = list.subList(1, 3); sub.set(0, "X"); System.out.println(list); // [A, X, C, D]

看到没,改 sub 的第一个元素,原列表的第二个元素也跟着变了。因为 subList 底层记录的是 parent 列表和区间范围,所有读写操作最终都落到那个共享的 elementData 数组上。

更危险的是,如果你在对 subList 进行遍历的同时对原列表做了结构修改(比如 add 或 remove),会直接抛出ConcurrentModificationException。这是 JDK 的一层保护机制:subList 和原列表的 modCount 必须保持一致,一旦发现不一致,就认为处于并发修改状态。

实操建议:如果你只是拿 subList 做只读操作,比如打印或统计,那没问题;如果你想得到一份独立副本,请这样写:

ArrayList<String> copy = new ArrayList<>(list.subList(1, 3));

我刚开始学集合时吃了不少亏,后来养成的习惯是:只在方法参数传递和只读场景里用 subList,其他一律走拷贝。

4.3 remove 的整数下标与对象删除之争

这是个“看起来简单、踩坑还特别隐蔽”的经典题。看代码:

ArrayList<Integer> numbers = new ArrayList<>(); numbers.add(1); numbers.add(2); numbers.add(3); numbers.remove(2); System.out.println(numbers); // 结果是什么?

直觉告诉我,如果不假思索的人可能会说“删掉了元素 2”。但实际输出是[1, 2],它删的是下标为 2 的元素,也就是数字 3。原因很简单:remove(int index)和remove(Object o)是两个重载方法。你传一个 int 进去,编译器优先匹配remove(int),所以它被当成下标了。

那如果你想删“元素 2”怎么办?把它转成 Integer 对象再传:

numbers.remove(Integer.valueOf(2));

这样编译器就会匹配到remove(Object),通过 equals 比较后删除。

这个细节在从文件中读取数字、再根据值删除时特别容易出错。我的习惯是:如果列表存的是 Integer,删除时一律显式用Integer.valueOf包装,或者先indexOf找到下标再拿下标删除。两种方式都可读,但后者更稳:

int index = numbers.indexOf(2); if (index >= 0) { numbers.remove(index); }

4.4 性能优化:批量操作与初始容量

很多人在用 ArrayList 时会有一种错觉:反正它会自动扩容,我无脑 add 就好了。对于几十上百条数据的 demo 场景,确实无所谓;但如果你的数据量是百万级、千万级的,没规划好扩容次数会严重拖慢执行速度。

我做了一次本地实验:往两个 ArrayList 里分别 add 一千万条 Integer 数据。一个用无参构造,一个用new ArrayList<>(10000000)。结果差异非常明显:前者因为扩容次数太多,总耗时比后者明显更长。原因就在于每次扩容都要new Object[]加System.arraycopy,一次性拷贝千万级数组的开销可不小。

所以要点就一句话:只要你能预估数据量,就在创建 ArrayList 时把初始容量传进去。拿不准就传一个偏大的估计值,最坏情况只是多占一点内存,但换来的是更少的扩容拷贝。

另外批量添加也会踩扩容坑。addAll在添加大量元素时,会先计算新数组需要多大,再一次性扩容到位,而不是元素一个个加、一个个扩。比如:

ArrayList<String> list = new ArrayList<>(); list.addAll(Collections.nCopies(10000, "x"));

它内部先计算出需要的总容量,直接扩到目标大小,省掉中间多次复制。日常如果你在循环里频繁调用单个 add,不妨改成先组装一个临时集合,再一次性 addAll。

5. 从 ArrayList 出发还能再看点什么

学完 ArrayList 之后,我建议你不要立刻跳到 HashMap,而是顺着两个方向把知识织成网。

第一个方向是横向对比。把 ArrayList、LinkedList、Vector 放在一起对比:底层结构、初始容量、扩容策略、线程安全性、适合场景,列成一张大表自己填一遍。你会发现 ArrayList 和 Vector 非常相似,区别主要在于 Vector 的方法大多用 synchronized 修饰,所以在单线程环境下反而有额外开销;LinkedList 则完全换了个实现思路。这种对比不是为了应付面试,而是为了让你在真正写代码时能一秒判断“这个场景该选谁”。

第二个方向是读源码。读 ArrayList 源码其实是读 Java 集合框架最好的入口,因为它结构清晰、代码量适中。重点看这几个方法:grow、add、remove、iterator。每看一个方法,就把它和“数组特性”挂钩:为什么这里要拷贝?为什么这里要判断 range?为什么这里要抛异常?这样读下来,比死记硬背十篇博客都有用。

我个人在实际操作中的体会是,ArrayList 最大的价值不仅是“好用”,而是让你反复锻炼一种思维:任何数据结构都是权衡的结果,没有银弹。数组快在访问,慢在插入;链表反过来;哈希表牺牲顺序换查找;树牺牲部分空间换有序性。你越早接受“没有完美的数据结构”这个事实,后面学习任何集合类就越从容。

最后再分享一个小技巧:如果你觉得自己总是记不住这些 API 细节,建议给自己规定一个“手写不查”的小任务——不查资料,直接用记事本写一个 ArrayList 的 add 和 remove 核心逻辑,写完再对着源码改。这个过程不用太长,两三次就能见效。等你写完再回头看那些报错和排查经历,会发现那些曾经“命中注定”要踩的坑,其实都是逼你把底层搞明白的必经之路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 7:51:20

AI辅助教材写作:从低查重到高原创的完整实操指南

做教材的人&#xff0c;大多体会过一种尴尬&#xff1a;翻开市面上同类教材&#xff0c;知识点大差不差&#xff0c;章节顺序、案例选择&#xff0c;甚至措辞口吻都高度雷同。这个行业并不缺内容&#xff0c;缺的是有辨识度的内容。所以当“AI写教材的秘密武器”“低查重生成”…

作者头像 李华
网站建设 2026/10/17 7:51:19

大模型多轮对话的上下文管理:context-mode设计思路与工程实践

说实话&#xff0c;第一次看到"context-mode"这个词的时候&#xff0c;我愣了一下。它不是一个新框架&#xff0c;也不是某个开源库的名字&#xff0c;而更像是一种设计思路的浓缩。我做完这个项目之后才意识到&#xff0c;越是这种看似简单的概念&#xff0c;背后牵…

作者头像 李华
网站建设 2026/10/17 7:51:02

从空标题到成长伙伴:技能管理项目的数据模型与核心功能设计

1. 当“skills”成为一个项目标题&#xff1a;我看到的不是空壳&#xff0c;而是最诚实的起点第一次看到“skills”这个项目标题时&#xff0c;我的反应和大多数人一样——这也太宽泛了。没有正文&#xff0c;没有关键词&#xff0c;没有摘要描述&#xff0c;连一个限定词都没有…

作者头像 李华
网站建设 2026/10/17 7:49:57

Vue2项目创建避坑指南:脚手架选型、依赖冲突与工程化配置

说实话&#xff0c;"vue2创建项目"看起来是个特别入门的话题&#xff0c;但我带过的小组里&#xff0c;几乎每一个新人都在这件事上栽过跟头。不是vue create跑不起来&#xff0c;而是跑起来之后发现各种版本冲突、依赖报错、目录混乱、联调环境配不明白——等到项目…

作者头像 李华
网站建设 2026/10/17 7:48:30

用Python批量调用darknet实现YOLOv4检测结果保存方案

两周前&#xff0c;我手里有一套训练好的YOLOv4权重&#xff0c;测试集里躺着一千多张图片。需要做的事听起来很简单&#xff1a;批量喂给darknet&#xff0c;跑完检测&#xff0c;把每个带检测框的结果图保存下来。结果我在darknet自带命令上卡了一整个晚上。darknet detector…

作者头像 李华