很多同学学到数组那一章的时候,其实都会有一个隐隐的困惑:数组确实好用,但它的长度在创建那一刻就定死了。你说班级里有50人,给它开60个位置,肉眼可见地浪费;开40个位置,第41个同学就进不来了。现实里的业务数据,哪有这么听话?用户注册量、订单数量、学生名单,全是动态变化的。ArrayList就是专门来收拾这个烂摊子的。
这一天的内容在整个Java基础课程里,算是一个很明显的分水岭。课程标题写得很直白——ArrayList概述、构造方法、常用方法、遍历,外加一个简易学生管理系统。前半段是在补标准库的功课,后半段是把前面学的面向对象、封装、集合、流程控制全部串起来,做一个真正能跑起来的控制台小系统。它适合所有刚学完数组和类、想验证自己到底有没有真正掌握这些知识点的同学。你把这一天的代码从头到尾自己敲一遍,对ArrayList的理解会远远超过死记文档。
1. 项目概述与整体设计思路
1.1 这一天的内容到底在解决什么问题
先说痛点。数组的痛点其实就两个:长度固定不变,增删中间元素极其麻烦。你写一个学生名单管理,今天3个人,明天来5个,后天走1个,数组根本没法优雅地处理这种变化。而要解决这个问题,最直接的想法就是:找一个"能自动变长的数组"来用。
ArrayList的定位就是这么来的。它内部维护了一个Object数组,当你往里面塞元素塞到快满的时候,它会自己申请一块更大的新数组,把老数据搬过去,然后接着用。对外暴露的接口完全不需要你操心扩容这回事。
这个设计思路在计算机领域其实很常见,叫"动态数组"。你在很多语言里都能看到类似的东西,Java里最典型的代表就是ArrayList。这一天的任务就是把这玩意儿用熟、用透,并且在一个真实的小项目里体会到它比起数组到底强在哪。
1.2 ArrayList在Java集合框架里的位置
在Java集合框架里,ArrayList实现了List接口。List接口的核心语义是"有序、可重复、可以通过索引访问"。什么叫有序?你添加元素的顺序,决定了它在集合里的顺序,遍历的时候会按照这个顺序输出。什么叫可重复?同一个值你可以往里面放好多次,后面那个不会覆盖前面那个。
ArrayList底层就是Object[],所以它天然自带数组的优势:按下标的随机访问非常快,因为你根本不需要遍历,直接靠索引就能定位到内存位置。但代价是,往数组中间插入或者删除元素时,它需要让后面的所有元素整体挪动位置。这个特性在后面学生管理系统的增删操作里会直接影响你的代码设计。
很多人会纠结,Java集合那么多,为什么偏偏用ArrayList?因为这一阶段你不需要键值对查找,不需要排序规则,不需要多线程安全,也没有海量数据要求。ArrayList是这个阶段最合适的工具,没有之一。工具选型这事,永远要把"够用"放在"花哨"前面。
1.3 为什么这个学生管理系统选择ArrayList而不是数组
回到项目本身。我们要管理一批学生,学生会添加、会被删除、会被修改信息,数量完全不确定。这如果还硬用数组,每次增删都要手动维护一个计数器、手动搬数据,代码会写得又臭又长,而且极易出错。
用ArrayList之后,结构就清爽了:
- 添加学生直接add。
- 删除学生找到索引之后remove。
- 修改学生用set替换那个位置的对象。
- 查看全部学生用for循环配合get逐个取出来。
这套操作完全对应这一天的核心API,你每用一个方法,都是在复习刚学的知识点。所以这个系统不是为了炫技,它就是给ArrayList量身定做的练手场景,让你把每个方法的用法、参数、返回值、边界情况都亲手过一遍。
2. 构造方法与底层原理:先搞清楚构造器再谈使用
2.1 三种构造方法,各管什么场景
很多人学ArrayList,上来就只知道new ArrayList<>()这么一种写法,然后一直用到天荒地老。其实ArrayList一共给我们提供了三个构造方法,每个都有自己的适用场景,而且你理解它们之后,对这个类的内存行为会有更准确的认识。
| 构造方法 | 说明 | 使用场景 |
|---|---|---|
ArrayList() | 构造一个初始容量为10的空列表,但底层实际先不分配数组 | 绝大多数情况用这个就够了 |
ArrayList(int initialCapacity) | 按指定初始容量分配数组 | 预先能估算数据量时,直接指定容量避免反复扩容 |
ArrayList(Collection<? extends E> c) | 用另一个集合来初始化 | 需要快速复制一份现有集合数据时 |
第一种最常用,底层细节值得多说一句。你调用new ArrayList<>()的时候,它内部其实并没有立刻给你new一个长度为10的数组,而是先指向一个共享的空数组。只有当你第一次往里面add元素的时候,它才真正分配出那10个位置。
第二种构造方法,说白了就是给扩容机制提前打预防针。你要是知道系统里大概要装500个学生,直接new ArrayList<>(500),它从一开始就准备了500个位置,全程不需要扩容,省掉大量数组复制的时间。这个细节在数据量大的时候收益是很明显的。
第三种构造方法相对用得少,但遇到需要"拷贝一份集合"的情况很香。比如你有一个列出了所有学生的集合,想临时复制一份再做修改,直接new ArrayList<>(oldList)就搞定,比自己for循环里add一遍省事得多。
2.2 扩容机制:ArrayList的灵魂所在
你要是不理解ArrayList的扩容机制,后面很多性能上的疑问都会是一团雾。我建议把这个当作这一章的重点来啃,它也是面试里特别爱问的细节之一。
当你添加第11个元素时,ArrayList会发现自己当前10个位置满了,于是进入扩容流程。算法很简单,新容量等于旧容量加上旧容量右移一位的结果,也就是1.5倍。10扩容到15,15扩容到22,22扩容到33,就这么一路涨上去。
为什么选1.5倍而不是直接2倍?官方给的算法是oldCapacity + (oldCapacity >> 1),这种方式比乘以1.5更高效,因为位运算比浮点乘法快。至于为什么不是3倍甚至更大,那是一个空间和时间折中的问题:扩得太大,内存浪费严重;扩得太小,数组复制频繁发生,影响性能。1.5倍是经过大量实践检验的均衡值。
实际扩容时的动作就三步:申请新数组、把旧数组内容复制过去、让内部引用指向新数组。整个过程对使用者完全透明,你只管add,它替你跑腿。但如果你能预估规模,直接指定容量绕过扩容,就是一种很实用的性能优化思路。
2.3 一个容量预估的实战案例
我见过不少人在学习阶段根本不关心初始容量,觉得反正功能能跑就行。但当你数据量上来之后,扩容的开销就没那么无所谓了。
假设你的系统要往ArrayList里灌1000条学生数据,而你没有指定初始容量。那么ArrayList会从默认的10开始:10、15、22、33、49、74、111、166、249、373、559、838、1257。也就是说,它前前后后扩容了12次。每一次扩容,都意味着一次旧数组的完整复制,旧数组里面那几百上千个元素被整个搬一次家。12次复制加起来的开销,比你一次性指定1000容量的开销要高得多。
而且这里有个容易忽略的点:扩容时数组复制完之后,旧数组就等着被垃圾回收,这个过程中内存占用也会短暂飙升。在生产环境里,这种操作如果发生在高并发请求路径上,很有可能触发明显的GC压力。
所以我的建议很简单:只要能估算数量级,就尽量用带初始容量的构造方法。比如分页查询结果、批量导入的Excel数据、用户上传的文件行数,都是可以先算出长度的。当然,如果实在估不准,用无参构造也没问题,ArrayList的设计本来就能照顾多数场景,但"心里有数"和"心里没数"写出来的代码,差距就在这里体现出来了。
3. 常用方法与遍历:把API用到顺手
3.1 四类核心方法速查与源码级解析
ArrayList的方法虽然多,但学习阶段真正高频的就那么几个。我整理了一个速查表,每个方法都值得亲手敲一遍:
| 方法名 | 作用 | 注意点 |
|---|---|---|
add(E e) | 往列表末尾添加元素 | 这是最常用的方法,绝大多数情况都用它 |
add(int index, E e) | 往指定位置插入元素 | index不能超过size,否则报越界 |
remove(int index) | 删除指定位置的元素 | 注意别和remove(Object)混淆 |
remove(Object o) | 删除指定内容第一次出现的位置 | 只删第一个匹配的,需要equals配合 |
set(int index, E e) | 替换指定位置的元素 | 这个方法会返回被替换掉的旧值 |
get(int index) | 获取指定位置的元素 | index范围是0到size-1 |
size() | 返回列表长度 | 这是方法,和数组的length属性不一样 |
我特别想聊一下remove(int index)的底层动作。ArrayList删除某个位置元素的时候,会把被删元素之后的所有元素整体向前移动一格,这个动作用的是System.arraycopy,效率很高,但代价是这个方法的时间复杂度是O(n)。也就是说,如果你在一个大列表里不停地删头部元素,成本是很高的。
还有一个特别容易踩坑的地方——重载方法的混淆。假设你有一个ArrayList<Integer>,想删除值为1的元素。你要是直接写list.remove(1),它匹配的其实是remove(int index),删的是索引1位置的元素,而不是值为1的元素。想按内容删,必须写list.remove(Integer.valueOf(1))。这个坑在面试和实际开发里都反复出现,值得你亲手验证一遍。
set方法被很多人忽略了它"会返回旧值"这个特性。在我们的学生管理系统里,修改学生信息时顺便把旧信息打印出来,就是直接用set方法的返回值做的,既简洁又免去自己手动查找的麻烦。
3.2 三种遍历方式的选择
遍历ArrayList的方式有三大类,写法不同,适用的场景也不同。我建议全部掌握,并且头脑清楚它们的区别。
第一种是最普通的for循环加索引:
for (int i = 0; i < students.size(); i++) { Student s = students.get(i); System.out.println(s.getName()); }这种方式的主动权在你手里,你能拿到i这个索引,想在中间停下来或者跳过某个位置都很方便。尤其是在你需要根据索引联动其他数据结构时,这种写法是唯一的选择。
第二种是增强for循环:
for (Student s : students) { System.out.println(s.getName()); }增强for是简化版的遍历,它不需要你手动维护计数器,代码清爽很多。底层它其实是通过迭代器完成的,所以在遍历过程中不要对集合本身做结构性修改,否则会触发并发修改异常。
第三种是显式使用迭代器:
Iterator<Student> it = students.iterator(); while (it.hasNext()) { Student s = it.next(); System.out.println(s.getName()); }迭代器最大的优势在于,它自带一个remove()方法,可以在遍历过程中安全地删除当前元素。这是前两种方式做不到的。
从性能上讲,ArrayList本身就支持随机访问,这三种方式在它上面跑出来的差距在这个量级基本可以忽略。真正决定你用哪种的,是你要不要索引、要不要在遍历时删数据这两个场景需求。
3.3 边遍历边删除这个经典坑
我敢说,十个写Java的人里,至少有八个都在增强for里删过元素然后遇到ConcurrentModificationException。这个异常的原因很简单:增强for底层用的迭代器,而迭代器内部维护了一个modCount(修改次数)计数器。你每遍历一次,它都会检查集合的修改次数有没有发生变化。一旦你用list.remove()直接删元素,集合的modCount变了,迭代器发现和自己记录的期待值对不上,立刻罢工抛异常。
那怎么办?如果你只需要删一个元素,最稳妥的思路是先找到它的索引,退出循环后再单独remove。如果需要删多个满足条件的元素,有三种可行方案,按推荐度排序:
第一种,用迭代器的remove()方法,这是语法层面被设计好的正规姿势:
Iterator<Student> it = students.iterator(); while (it.hasNext()) { if ("002".equals(it.next().getId())) { it.remove(); } }第二种,倒序遍历加索引删除。因为删除一个元素时,后面所有元素都会往前提一位,正序遍历会导致你跳过本该检查的元素;倒序则完全不受影响:
for (int i = students.size() - 1; i >= 0; i--) { if ("002".equals(students.get(i).getId())) { students.remove(i); } }第三种,用普通for正序遍历,删完后把索引减回去,避免跳过相邻元素。这个写法也能成立,但代码观感比前两种要差点。
总之,这个坑不是靠背异常信息解决的,而是靠理解迭代器和修改计数器的机制。你把机制想明白了,换成什么样的集合都能举一反三。
4. 简易学生管理系统完整实现:从零到能跑
4.1 需求拆解:这个系统到底要做什么
一个简易学生管理系统,功能不追求多,但核心的增删改查必须有。我把它拆成四个核心功能加一个退出入口:
- 查看所有学生:遍历ArrayList,逐行打印学生信息。
- 添加学生:手动输入学号、姓名、年龄、班级,封装成Student对象后add进列表。
- 删除学生:根据用户输入的学号,找到对应索引后remove。
- 修改学生:根据学号定位,用新信息替换旧信息。
- 退出系统:结束while主循环。
这里有一个隐含的设计原则:学生学号是唯一标识。删除和修改操作都依赖学号来定位,因为姓名可能重名,年龄更不可能当标识,只有学号这种业务上天然唯一的字段才适合做查找依据。
4.2 Student类的设计
既然要管理学生,学生这个对象得先定义好。一个基本的Student类需要四个字段:学号、姓名、年龄、班级。类里必须有完整的封装——私有字段、构造方法、getter和setter。
public class Student { private String id; private String name; private int age; private String className; public Student() { } public Student(String id, String name, int age, String className) { this.id = id; this.name = name; this.age = age; this.className = className; } public String getId() { return id; } public void setId(String id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } public String getClassName() { return className; } public void setClassName(String className) { this.className = className; } }这里我必须提醒一句:写完类之后,把getter和setter都补完整。很多新手偷懒,只给用到的那几个字段写getter,结果后面想在别的地方用就发现没有对应方法,又得回头补。基础类的接口完整性,直接影响你后面代码的顺畅程度。构造函数提供空参和全参两个版本,也是一个好习惯,空参构造在反射和框架场景里经常要用到,现在养成习惯不亏。
4.3 主入口与菜单循环的设计
系统的骨架是一个死循环加switch分支。循环保证用户完成一个操作之后,还能继续回到主菜单。switch根据用户输入的数字进入对应功能。退出用return直接结束main方法。
import java.util.ArrayList; import java.util.Scanner; public class StudentManager { private static ArrayList<Student> students = new ArrayList<>(); private static Scanner sc = new Scanner(System.in); public static void main(String[] args) { while (true) { System.out.println("========欢迎使用简易学生管理系统========"); System.out.println("1. 查看所有学生"); System.out.println("2. 添加学生"); System.out.println("3. 删除学生"); System.out.println("4. 修改学生"); System.out.println("5. 退出系统"); System.out.print("请选择操作:"); int choice = sc.nextInt(); sc.nextLine(); switch (choice) { case 1: findAllStudents(); break; case 2: addStudent(); break; case 3: deleteStudent(); break; case 4: updateStudent(); break; case 5: System.out.println("感谢使用,再见!"); return; default: System.out.println("输入有误,请重新选择!"); } } } }这里有个非常经典的细节是sc.nextInt()之后必须紧跟一句sc.nextLine()。原因是nextInt只读取了数字,没有消费掉输入行尾的换行符。如果你不处理这个换行符,下一个nextLine()会直接读到一个空字符串,表现就是"班级还没输呢,程序就跳过去了"。很多人在这一步卡了半天,其实就是差这一行代码的事。
数据集合用private static ArrayList<Student>放在成员位置,而不是在main方法里局部创建,是为了让多个方法都能共享访问。如果你把students放在main里,其他方法根本拿不到这个集合,那就得频繁传参,代码会很别扭。
4.4 五个核心业务方法逐个实现
查看所有学生,最核心的是判断空列表和格式化输出。size为0时直接提示,比打印一个空表头友好得多。
private static void findAllStudents() { if (students.size() == 0) { System.out.println("当前系统还没有学生,请先添加。"); return; } System.out.println("学号\t姓名\t年龄\t班级"); for (int i = 0; i < students.size(); i++) { Student s = students.get(i); System.out.println(s.getId() + "\t" + s.getName() + "\t" + s.getAge() + "\t" + s.getClassName()); } }添加学生时,第一件事是校验学号是否重复。这里就提前用到了一个专门设计的工具方法findIndexById,它返回学号在列表里的索引,找不到就返回-1。如果学号已存在,直接提示添加失败,不做后面的输入操作,这属于业务逻辑上的防呆设计。
private static void addStudent() { System.out.print("请输入学号:"); String id = sc.nextLine(); if (findIndexById(id) != -1) { System.out.println("学号已存在,添加失败!"); return; } System.out.print("请输入姓名:"); String name = sc.nextLine(); System.out.print("请输入年龄:"); int age = sc.nextInt(); sc.nextLine(); System.out.print("请输入班级:"); String className = sc.nextLine(); students.add(new Student(id, name, age, className)); System.out.println("添加成功!"); }删除学生和修改学生都依赖findIndexById这个查找方法。很多人一开始会想到用contains判断学号是否存在,但contains只能告诉你"有没有",不能告诉你"在第几格"。而删除和修改都需要索引——删除要remove(index),修改要set(index, newObj)。所以这个工具方法才是整套CRUD的润滑剂。
private static int findIndexById(String id) { for (int i = 0; i < students.size(); i++) { if (students.get(i).getId().equals(id)) { return i; } } return -1; }这里还有一个隐藏知识点:为什么不能用students.remove(student)?因为ArrayList的remove(Object)方法靠equals判断是否相等,而Student类没有重写equals,默认走的是地址比较。你new一个相同属性的对象,地址和列表里那个不一样,删不掉。所以按业务标识去找索引,是正确且好扩展的方案。
删除方法:
private static void deleteStudent() { System.out.print("请输入要删除的学号:"); String id = sc.nextLine(); int index = findIndexById(id); if (index == -1) { System.out.println("查无此人,删除失败!"); return; } students.remove(index); System.out.println("删除成功!"); }修改方法相对复杂一点,需要先定位,再拿到新信息,然后set回去。set的返回值是被替换的旧对象,正好可以用来提示"原数据是什么":
private static void updateStudent() { System.out.print("请输入要修改的学号:"); String id = sc.nextLine(); int index = findIndexById(id); if (index == -1) { System.out.println("查无此人,修改失败!"); return; } System.out.print("请输入新的姓名:"); String name = sc.nextLine(); System.out.print("请输入新的年龄:"); int age = sc.nextInt(); sc.nextLine(); System.out.print("请输入新的班级:"); String className = sc.nextLine(); Student old = students.set(index, new Student(id, name, age, className)); System.out.println("修改成功,原数据为:" + old.getId() + " " + old.getName()); }这套代码把ArrayList的get、add、set、remove、size全部用上了,每一行都有落点,不存在"学了不知道干嘛用"的情况。你把这五个方法自己敲出来跑通,这天的核心目标就基本拿下了。
4.5 运行效果与关键流程演示
把代码全部跑起来,控制台交互大概是这样的感觉:
========欢迎使用简易学生管理系统======== 1. 查看所有学生 2. 添加学生 3. 删除学生 4. 修改学生 5. 退出系统 请选择操作:2 请输入学号:001 请输入姓名:张三 请输入年龄:18 请输入班级:Java09班 添加成功!再选1查看:
请选择操作:1 学号 姓名 年龄 班级 001 张三 18 Java09班接着可以试一下重复添加同一个学号,系统会提示学号已存在。删除不存在的学号,系统会提示查无此人。这些边界情况不是多余的,它们验证的是你代码里的判断逻辑是否健壮。一个成熟的系统,不只是"能跑通主流程",更要能在用户错误操作时不崩、不卡、给出清晰提示。
5. 常见问题排查与避坑实录
5.1 常见报错与原因速查
下面是学生在练习这一天内容时最经常碰到的问题,直接做成一张速查表:
| 现象 | 大概率原因 | 解决办法 |
|---|---|---|
IndexOutOfBoundsException | 访问了超出size-1范围的索引 | 检查遍历边界,确认remove下标不越界 |
ConcurrentModificationException | 增强for或迭代器遍历中直接调list.remove | 改用Iterator自带的remove方法 |
| 删完元素还剩一半 | 正序遍历时remove导致元素前移,索引自增跳过 | 倒序遍历,或删除后手动减索引 |
| 想删值为1的元素却删错了位置 | Integer列表里remove(1)匹配了remove(int) | 用remove(Integer.valueOf(1)) |
| 班级信息还没输就跳过了 | nextInt之后没处理残留换行符 | 加一句sc.nextLine() |
| 打印出来全是同一个对象 | 反复add同一个Student引用 | 每次add都new一个新对象 |
| equals比较不生效 | Student没重写equals,按地址比较 | 按业务id查找索引,或重写equals |
每个问题都不是凭空出现的,全都来自真实调试现场。建议你遇到问题先自己对着这个表排查一遍,比直接查搜索引擎效率高得多,因为你身边没有搜索引擎能结合你自己的代码上下文给答案。
5.2 实战中总结的几条ArrayList心得
最后再分享几条我在实际开发里摸爬滚打出来的心得。
第一,不要一遇到"列表"就默认用ArrayList。虽然它足够强,但如果你有大量中间插入、删除的操作,LinkedList在某些场景下有优势。不过在学习阶段,你先把ArrayList吃透,再去横向对比其他List实现,效果会比贪多嚼不烂好得多。
第二,能用初始容量的地方尽量用。这个前面已经算过账了,指定容量不是炫技,而是实实在在能减少数组复制的次数。我写批量处理类代码时,几乎都会先算一下数据量再new。
第三,findIndexById这种"查找索引"的方法值得多练。很多新手习惯了contains这种只返回布尔值的方法,但在需要增删改的真实场景里,索引才是你操作的核心。从contains思维转换到index思维,是写业务代码一个很重要的进阶点。
第四,每次学完一个新集合,都要拿它写一个小项目练手。光看不练,一个礼拜之后你连add的参数都记不清;动手写一个这么点儿大的学生管理系统,ArrayList的整体印象就深深刻在脑子里了。这套系统的写法还能延伸到后面的图书管理、商品管理、订单管理,代码架构几乎是一模一样的——到时候你只需要换一个实体类,就能快速做一个新的管理系统出来。