第15章 数组和 List:从固定容器到动态数据集合
上一章我们写了一个图书借阅模型。为了不提前引入集合框架,Library 里用的是数组:
private final Book[] books;
private int bookCount;
这种写法能让你看清对象引用和下标管理,但它也暴露了一个非常现实的问题:真实程序几乎总是在处理一批数据,而一批数据的数量往往不是固定的。
图书馆今天有 100 本书,明天可能有 101 本;联系人管理器今天有 3 个联系人,过几天可能有 300 个;订单系统每分钟都在新增订单。你不可能每次都提前准确知道数组应该开多大。
这一章开始进入第三阶段:集合、泛型和常用工具类。
我们先从数组讲起,再过渡到 List 和 ArrayList。不要急着背 API。你要先明白:
- 数组到底好在哪里。
- 数组为什么在业务程序里会吃力。
- List 解决的是什么问题。
- ArrayList 的增删改查怎么写。
- 为什么 List<Book> 比 Book[] 更适合表示动态数据。
- 删除元素时为什么容易出错。
- 如何把上一章的图书馆改成集合版本。
一、数组仍然重要,不是学了 List 就不用数组
很多人学到集合后会觉得数组过时了,这是错误理解。
数组仍然是 Java 里非常基础的数据结构。它有几个特点:
- 长度固定。
- 按下标访问。
- 同一种数组只能放同一种类型的数据。
- 访问速度直接。
- 适合数量明确、结构稳定的数据。
比如保存一周的温度:
double[] temperatures = new double[7];
一周就是 7 天,这个长度天然固定。数组很合适。
再比如保存棋盘:
char[][] board = new char[3][3];
井字棋就是 3 行 3 列。数组也很合适。
再比如方法接收命令行参数:
public static void main(String[] args) {
}
args 本身就是字符串数组。
所以问题不是“数组不好”,而是“数组适不适合当前场景”。如果数据数量固定,数组简单直接;如果数据数量会变化,集合通常更自然。
二、数组的核心概念再讲透
创建一个整数数组:
int[] scores = new int[3];
它在内存里大概像这样:
下标: 0 1 2
值: 0 0 0
int 数组默认值是 0。
赋值:
scores[0] = 90;
scores[1] = 75;
scores[2] = 88;
现在是:
下标: 0 1 2
值: 90 75 88
读取:
System.out.println(scores[1]); // 75
数组长度:
System.out.println(scores.length); // 3
注意,数组长度是字段,不是方法,所以写 scores.length,不是 scores.length()。
遍历:
for (int i = 0; i < scores.length; i++) {
System.out.println(scores[i]);
}
增强 for:
for (int score : scores) {
System.out.println(score);
}
如果你只需要读取每个元素,增强 for 很舒服。如果你需要下标,或者要根据下标修改元素,用普通 for 更合适。
三、引用类型数组的默认值是 null
看这个数组:
String[] names = new String[3];
它不是:
["", "", ""]
而是:
[null, null, null]
所以这段代码会报错:
String[] names = new String[3];
System.out.println(names[0].length());
报错:
NullPointerException
因为 names[0] 不是空字符串,而是 null。null 表示没有对象,不能调用方法。
正确做法:
String[] names = new String[3];
names[0] = "Tom";
if (names[0] != null) {
System.out.println(names[0].length());
}
对象数组也是一样:
Book[] books = new Book[100];
刚创建时,100 个位置都是 null。只有你写入:
books[0] = new Book("001", "Java入门", "作者A", 3);
第 0 个位置才真正指向一个 Book 对象。
这就是上一章为什么必须有 bookCount。
for (int i = 0; i < bookCount; i++) {
System.out.println(books[i].getTitle());
}
不能直接遍历到 books.length,因为后面很多位置还没有对象。
四、数组新增元素:你其实在手动维护 size
假设我们要保存学生姓名,最多 5 个:
String[] names = new String[5];
int size = 0;
添加第一个:
names[size] = "Tom";
size++;
添加第二个:
names[size] = "Lucy";
size++;
现在:
数组长度 length = 5
实际元素 size = 2
下标: 0 1 2 3 4
值: Tom Lucy null null null
length 是数组容量,size 是实际数量。
这两个概念必须分清。
如果你用 length 当实际数量,就会读到一堆 null。
如果你忘记更新 size,新元素可能覆盖旧元素。
错误写法:
names[size] = "Tom";
names[size] = "Lucy";
因为没有 size++,两次都写到同一个位置。
正确封装成方法:
public static int addName(String[] names, int size, String name) {
if (size >= names.length) {
throw new IllegalStateException("数组已满");
}
names[size] = name;
return size + 1;
}
调用:
String[] names = new String[5];
int size = 0;
size = addName(names, size, "Tom");
size = addName(names, size, "Lucy");
你会发现,数组一旦用来表示“动态增长的一批数据”,就必须自己管理容量、数量、下标和越界。
五、数组查找元素:本质是循环
根据名字查找:
public static int findNameIndex(String[] names, int size, String target) {
for (int i = 0; i < size; i++) {
if (names[i].equals(target)) {
return i;
}
}
return –1;
}
返回下标是常见写法。找到了返回 0、1、2 等下标;没找到返回 -1。
为什么不是返回 0 表示没找到?
因为 0 是合法下标。第一个元素的下标就是 0。
查找对象也是一样:
public static Book findBookByIsbn(Book[] books, int bookCount, String isbn) {
for (int i = 0; i < bookCount; i++) {
if (books[i].getIsbn().equals(isbn)) {
return books[i];
}
}
return null;
}
这里返回 Book 对象。找不到时返回 null。
这两种方式都常见:
- 想知道位置,返回下标。
- 想直接使用对象,返回对象。
六、数组删除元素:不是把位置设成 null 就完事
假设数组里有 4 个名字:
下标: 0 1 2 3 4
值: Tom Lucy Jack Anna null
size = 4
现在删除下标 1 的 Lucy。
如果只是:
names[1] = null;
size—;
数组会变成:
下标: 0 1 2 3 4
值: Tom null Jack Anna null
size = 3
这样中间出现了空洞。后续遍历 0 到 size – 1 时,会读到 names[1] == null,很容易空指针。
更常见的做法是把后面的元素往前移:
public static int removeAt(String[] names, int size, int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("删除下标非法:" + index);
}
for (int i = index; i < size – 1; i++) {
names[i] = names[i + 1];
}
names[size – 1] = null;
return size – 1;
}
删除下标 1 后:
下标: 0 1 2 3 4
值: Tom Jack Anna null null
size = 3
这就是数组删除麻烦的地方。你要自己移动元素,自己清理最后一个位置,自己维护 size。
ArrayList 的价值之一,就是把这些重复细节封装起来。
七、List 是什么
List 是 Java 集合框架里的一个接口,表示“有顺序、可以重复、可以按下标访问的一组元素”。
先记住这句话:
List 像一个长度可以变化的数组。
创建一个 ArrayList:
import java.util.ArrayList;
public class ListDemo {
public static void main(String[] args) {
ArrayList<String> names = new ArrayList<>();
names.add("Tom");
names.add("Lucy");
names.add("Jack");
System.out.println(names.size()); // 3
System.out.println(names.get(0)); // Tom
}
}
这里有几个新东西。
第一,必须导入:
import java.util.ArrayList;
ArrayList 不在 java.lang 包里,所以不能像 String 那样直接用。
第二,ArrayList<String> 表示这个列表里放的是字符串。
如果你写:
ArrayList<String> names = new ArrayList<>();
就不能往里面放整数:
names.add(123); // 编译错误
这就是泛型的第一层作用:让容器知道自己应该放什么类型的数据。泛型后面会专门讲,这一章先会用即可。
第三,size() 是方法。
数组长度写:
namesArray.length
列表数量写:
names.size()
数组用字段,集合用方法。
八、用 List 接口接住 ArrayList 对象
更常见的写法是:
import java.util.ArrayList;
import java.util.List;
List<String> names = new ArrayList<>();
左边是 List<String>,右边是 new ArrayList<>()。
这和前面学过的接口、多态有关。
List 是接口,它规定“列表”应该有什么能力,比如:
- 添加元素。
- 根据下标获取元素。
- 删除元素。
- 查询数量。
- 遍历元素。
ArrayList 是具体实现。它真的把数据存起来。
用接口接住实现类的好处是:你的代码主要依赖“列表能力”,不死绑某个具体实现。
现阶段你可以先写:
List<String> names = new ArrayList<>();
不用急着深究所有集合实现。下一章还会见到 Set 和 Map。
九、List 的增删改查
1. 添加元素
List<String> names = new ArrayList<>();
names.add("Tom");
names.add("Lucy");
names.add("Jack");
列表会按添加顺序保存元素:
0 -> Tom
1 -> Lucy
2 -> Jack
也可以插入到指定位置:
names.add(1, "Anna");
结果:
0 -> Tom
1 -> Anna
2 -> Lucy
3 -> Jack
插入不是替换。原来下标 1 及后面的元素会往后移动。
2. 获取元素
String first = names.get(0);
System.out.println(first);
下标仍然从 0 开始。
如果下标越界:
names.get(99);
会报:
IndexOutOfBoundsException
集合越界和数组越界很像,只是异常类型通常是 IndexOutOfBoundsException。
3. 修改元素
names.set(1, "Lily");
set 是替换指定下标的元素。
注意区分:
names.add(1, "Anna"); // 插入,列表变长
names.set(1, "Anna"); // 替换,列表长度不变
4. 删除元素
按下标删除:
names.remove(0);
按对象删除:
names.remove("Lucy");
如果有多个相同元素,按对象删除通常只删除第一个匹配项。
例如:
List<String> names = new ArrayList<>();
names.add("Tom");
names.add("Lucy");
names.add("Lucy");
names.remove("Lucy");
System.out.println(names); // [Tom, Lucy]
5. 查询数量和是否为空
System.out.println(names.size());
System.out.println(names.isEmpty());
不要写:
if (names == null || names.size() == 0) {
}
如果你自己创建列表,尽量让列表永远不是 null:
List<String> names = new ArrayList<>();
没有数据时就是空列表,不要用 null 表示空列表。
if (names.isEmpty()) {
System.out.println("暂无数据");
}
这样调用者少很多空指针风险。
十、遍历 List
1. 普通 for:需要下标时使用
for (int i = 0; i < names.size(); i++) {
System.out.println(i + ": " + names.get(i));
}
适合场景:
- 需要打印序号。
- 需要根据下标修改元素。
- 需要从后往前删除。
2. 增强 for:只读遍历最舒服
for (String name : names) {
System.out.println(name);
}
适合场景:
- 只关心元素本身。
- 不需要下标。
- 不在遍历过程中删除元素。
3. while:需要更自由控制时使用
int index = 0;
while (index < names.size()) {
System.out.println(names.get(index));
index++;
}
while 在集合里不如 for 常见,但当循环步长不是简单 +1 时会有用。
十一、删除元素时最容易踩坑
先看一个错误例子:删除所有不及格成绩。
List<Integer> scores = new ArrayList<>();
scores.add(90);
scores.add(45);
scores.add(50);
scores.add(80);
for (int i = 0; i < scores.size(); i++) {
if (scores.get(i) < 60) {
scores.remove(i);
}
}
System.out.println(scores);
你可能以为结果是:
[90, 80]
但实际可能是:
[90, 50, 80]
为什么 50 没删掉?
过程是这样的:
原始: [90, 45, 50, 80]
i = 1,删除 45
列表变成: [90, 50, 80]
i++ 变成 2
此时下标 1 的 50 被跳过了
正向遍历删除时,后面的元素会往前移,而 i 又继续增加,所以会跳过元素。
常见解决办法是从后往前删:
for (int i = scores.size() – 1; i >= 0; i—) {
if (scores.get(i) < 60) {
scores.remove(i);
}
}
从后往前删,前面的下标不会受影响。
还有一种写法是手动控制 i:
for (int i = 0; i < scores.size(); ) {
if (scores.get(i) < 60) {
scores.remove(i);
} else {
i++;
}
}
删除后不递增,保留后再递增。
不要在增强 for 里直接删除:
for (Integer score : scores) {
if (score < 60) {
scores.remove(score); // 容易出错
}
}
这可能触发:
ConcurrentModificationException
这个异常后面讲 Iterator 时会细说。现阶段先记住:增强 for 适合读,不适合一边遍历一边删除。
十二、ArrayList 的底层直觉
ArrayList 名字里有 Array。它底层主要就是数组。
你可以把它理解成一个帮你管理数组的对象:
ArrayList
内部数组 elementData
实际数量 size
add 时自动放到 size 位置
容量不够时自动扩容
当你写:
names.add("Tom");
ArrayList 大概做了这些事:
检查内部数组是否还有位置
如果有,放到 size 下标
size + 1
如果没位置,创建更大的数组,把旧元素复制过去,再添加
所以 ArrayList 不是魔法,它只是把你上一节手写的那些细节封装好了。
这也解释了两个性能直觉。
第一,按下标读取很快:
names.get(100);
因为底层数组可以直接定位下标。
第二,在中间插入或删除可能比较慢:
names.add(0, "First");
names.remove(0);
因为很多元素需要移动。
对初学阶段来说,不需要过度纠结性能。你只要先形成判断:
- 经常按顺序保存、按下标读取,用 ArrayList 很合适。
- 经常在中间大量插入删除,要留意成本。
- 查找某个编号是否存在,后面会用 HashMap 或 HashSet 更合适。
十三、数组和 List 的对比
| 长度 | 创建后固定 | 可以动态增长 |
| 访问方式 | arr[i] | list.get(i) |
| 数量 | arr.length 是容量 | list.size() 是实际数量 |
| 新增元素 | 自己维护下标 | list.add(value) |
| 删除元素 | 自己移动元素 | list.remove(index) |
| 基本类型 | 可以直接放 int[] | 不能放 List<int>,要用 List<Integer> |
| 适合场景 | 固定长度、底层结构 | 动态业务数据 |
有一个点要注意:泛型里不能写基本类型。
错误:
List<int> scores = new ArrayList<>();
正确:
List<Integer> scores = new ArrayList<>();
Integer 是 int 的包装类型。包装类型后面会在常用类里详细讲。现在先知道:集合里放基本类型数据时,要用对应的包装类型。
常见对应关系:
| int | Integer |
| long | Long |
| double | Double |
| boolean | Boolean |
| char | Character |
十四、用 List 改写成绩统计器
第一阶段做过成绩统计器。数组版大概是:
int[] scores = new int[count];
如果改成 List<Integer>,我们不需要提前知道学生数量:
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
public class ScoreListApp {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
List<Integer> scores = new ArrayList<>();
while (true) {
System.out.print("请输入成绩,输入-1结束:");
int score = scanner.nextInt();
if (score == –1) {
break;
}
if (score < 0 || score > 100) {
System.out.println("成绩必须在0到100之间");
continue;
}
scores.add(score);
}
if (scores.isEmpty()) {
System.out.println("没有输入成绩");
return;
}
int max = scores.get(0);
int min = scores.get(0);
int sum = 0;
int passedCount = 0;
for (int score : scores) {
if (score > max) {
max = score;
}
if (score < min) {
min = score;
}
if (score >= 60) {
passedCount++;
}
sum += score;
}
double average = sum * 1.0 / scores.size();
System.out.println("人数:" + scores.size());
System.out.println("最高分:" + max);
System.out.println("最低分:" + min);
System.out.println("平均分:" + average);
System.out.println("及格人数:" + passedCount);
}
}
这段代码和数组版相比,最大的变化是:
scores.add(score);
我们不需要维护 size,也不需要担心数组满了。
但是统计逻辑没有消失。最高分、最低分、求和、及格人数依然要靠循环完成。集合解决的是“容器管理”问题,不会替你理解业务。
十五、把上一章图书馆改成 List 版
上一章 Library 的字段是:
private final Book[] books;
private final Reader[] readers;
private final BorrowRecord[] records;
private int bookCount;
private int readerCount;
private int recordCount;
用 List 后,可以变成:
private final List<Book> books;
private final List<Reader> readers;
private final List<BorrowRecord> records;
不再需要 bookCount、readerCount、recordCount。因为列表自己知道实际数量。
下面是改写后的 LibraryWithList。它复用上一章的 Book、Reader、BorrowRecord 类。
import java.util.ArrayList;
import java.util.List;
public class LibraryWithList {
private final String name;
private final List<Book> books;
private final List<Reader> readers;
private final List<BorrowRecord> records;
private int nextRecordNumber;
public LibraryWithList(String name) {
if (isBlank(name)) {
throw new IllegalArgumentException("图书馆名称不能为空");
}
this.name = name;
this.books = new ArrayList<>();
this.readers = new ArrayList<>();
this.records = new ArrayList<>();
this.nextRecordNumber = 1;
}
public void addBook(Book book) {
if (book == null) {
throw new IllegalArgumentException("图书不能为空");
}
if (findBookByIsbn(book.getIsbn()) != null) {
throw new IllegalArgumentException("图书已经存在,ISBN:" + book.getIsbn());
}
books.add(book);
}
public void registerReader(Reader reader) {
if (reader == null) {
throw new IllegalArgumentException("读者不能为空");
}
if (findReaderById(reader.getReaderId()) != null) {
throw new IllegalArgumentException("读者已经存在,编号:" + reader.getReaderId());
}
readers.add(reader);
}
public BorrowRecord borrowBook(String isbn, String readerId, String borrowDate) {
if (isBlank(isbn)) {
throw new IllegalArgumentException("ISBN不能为空");
}
if (isBlank(readerId)) {
throw new IllegalArgumentException("读者编号不能为空");
}
if (isBlank(borrowDate)) {
throw new IllegalArgumentException("借出日期不能为空");
}
Book book = findBookByIsbn(isbn);
if (book == null) {
throw new IllegalArgumentException("图书不存在,ISBN:" + isbn);
}
Reader reader = findReaderById(readerId);
if (reader == null) {
throw new IllegalArgumentException("读者不存在,编号:" + readerId);
}
if (!reader.isActive()) {
throw new IllegalStateException("读者账号已停用,不能借书:" + reader.getName());
}
if (!book.isAvailable()) {
throw new IllegalStateException("图书暂无可借副本:" + book.getTitle());
}
if (findActiveRecord(isbn, readerId) != null) {
throw new IllegalStateException("同一读者不能重复借同一本未归还图书");
}
book.borrowOneCopy();
BorrowRecord record = new BorrowRecord(generateRecordId(), book, reader, borrowDate);
records.add(record);
return record;
}
public void returnBook(String isbn, String readerId, String returnDate) {
if (isBlank(isbn)) {
throw new IllegalArgumentException("ISBN不能为空");
}
if (isBlank(readerId)) {
throw new IllegalArgumentException("读者编号不能为空");
}
if (isBlank(returnDate)) {
throw new IllegalArgumentException("归还日期不能为空");
}
BorrowRecord record = findActiveRecord(isbn, readerId);
if (record == null) {
throw new IllegalStateException("没有找到未归还的借阅记录");
}
record.markReturned(returnDate);
record.getBook().returnOneCopy();
}
public Book findBookByIsbn(String isbn) {
for (Book book : books) {
if (book.getIsbn().equals(isbn)) {
return book;
}
}
return null;
}
public Reader findReaderById(String readerId) {
for (Reader reader : readers) {
if (reader.getReaderId().equals(readerId)) {
return reader;
}
}
return null;
}
public BorrowRecord findActiveRecord(String isbn, String readerId) {
for (BorrowRecord record : records) {
boolean sameBook = record.getBook().getIsbn().equals(isbn);
boolean sameReader = record.getReader().getReaderId().equals(readerId);
if (sameBook && sameReader && !record.isReturned()) {
return record;
}
}
return null;
}
public void printStatus() {
System.out.println("========== " + name + " ==========");
printBooks();
printReaders();
printRecords();
}
private void printBooks() {
System.out.println("– 图书列表 –");
if (books.isEmpty()) {
System.out.println("暂无图书");
return;
}
for (Book book : books) {
System.out.println(book.getStatusText());
}
}
private void printReaders() {
System.out.println("– 读者列表 –");
if (readers.isEmpty()) {
System.out.println("暂无读者");
return;
}
for (Reader reader : readers) {
System.out.println(reader.getStatusText());
}
}
private void printRecords() {
System.out.println("– 借阅记录 –");
if (records.isEmpty()) {
System.out.println("暂无借阅记录");
return;
}
for (BorrowRecord record : records) {
System.out.println(record.getStatusText());
}
}
private String generateRecordId() {
String recordId = "BR" + nextRecordNumber;
nextRecordNumber++;
return recordId;
}
private boolean isBlank(String text) {
return text == null || text.trim().isEmpty();
}
}
对比数组版,你会发现业务逻辑几乎没变,变化主要集中在容器操作上。
数组版添加图书:
books[bookCount] = book;
bookCount++;
List 版:
books.add(book);
数组版判断空:
if (bookCount == 0) {
}
List 版:
if (books.isEmpty()) {
}
数组版遍历:
for (int i = 0; i < bookCount; i++) {
Book book = books[i];
}
List 版:
for (Book book : books) {
}
集合不是让业务消失,而是让你少写很多容器管理细节。
十六、为什么 findBookByIsbn 还是循环
有人会疑惑:用了 List,为什么查找图书还是要循环?
因为 List 的核心能力是按顺序保存元素。它没有自动知道“ISBN 是唯一编号”。对 List<Book> 来说,每个元素只是一个 Book 对象。
所以你要根据业务字段查找,仍然需要遍历:
for (Book book : books) {
if (book.getIsbn().equals(isbn)) {
return book;
}
}
如果图书很多,这种查找会越来越慢。因为最坏情况下要从头找到尾。
下一章会讲 Map。到时候可以用:
Map<String, Book> bookMap = new HashMap<>();
这样通过 ISBN 查找图书会更直接。
现阶段先把 List 用熟。不要一上来把 List、Set、Map 全混在一起。
十七、对象放进 List 后,修改的是同一个对象
看这段代码:
List<Book> books = new ArrayList<>();
Book book = new Book("001", "Java入门", "作者A", 2);
books.add(book);
book.borrowOneCopy();
System.out.println(books.get(0).getAvailableCopies());
输出是:
1
为什么?
因为 books.add(book) 放进去的是对象引用。列表里保存的不是复制品,而是指向同一个 Book 对象的引用。
可以想象成:
#mermaid-svg-orrvEjJqPla810HM{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-orrvEjJqPla810HM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-orrvEjJqPla810HM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-orrvEjJqPla810HM .error-icon{fill:#552222;}#mermaid-svg-orrvEjJqPla810HM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-orrvEjJqPla810HM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-orrvEjJqPla810HM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-orrvEjJqPla810HM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-orrvEjJqPla810HM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-orrvEjJqPla810HM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-orrvEjJqPla810HM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-orrvEjJqPla810HM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-orrvEjJqPla810HM .marker.cross{stroke:#333333;}#mermaid-svg-orrvEjJqPla810HM svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-orrvEjJqPla810HM p{margin:0;}#mermaid-svg-orrvEjJqPla810HM .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-orrvEjJqPla810HM .cluster-label text{fill:#333;}#mermaid-svg-orrvEjJqPla810HM .cluster-label span{color:#333;}#mermaid-svg-orrvEjJqPla810HM .cluster-label span p{background-color:transparent;}#mermaid-svg-orrvEjJqPla810HM .label text,#mermaid-svg-orrvEjJqPla810HM span{fill:#333;color:#333;}#mermaid-svg-orrvEjJqPla810HM .node rect,#mermaid-svg-orrvEjJqPla810HM .node circle,#mermaid-svg-orrvEjJqPla810HM .node ellipse,#mermaid-svg-orrvEjJqPla810HM .node polygon,#mermaid-svg-orrvEjJqPla810HM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-orrvEjJqPla810HM .rough-node .label text,#mermaid-svg-orrvEjJqPla810HM .node .label text,#mermaid-svg-orrvEjJqPla810HM .image-shape .label,#mermaid-svg-orrvEjJqPla810HM .icon-shape .label{text-anchor:middle;}#mermaid-svg-orrvEjJqPla810HM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-orrvEjJqPla810HM .rough-node .label,#mermaid-svg-orrvEjJqPla810HM .node .label,#mermaid-svg-orrvEjJqPla810HM .image-shape .label,#mermaid-svg-orrvEjJqPla810HM .icon-shape .label{text-align:center;}#mermaid-svg-orrvEjJqPla810HM .node.clickable{cursor:pointer;}#mermaid-svg-orrvEjJqPla810HM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-orrvEjJqPla810HM .arrowheadPath{fill:#333333;}#mermaid-svg-orrvEjJqPla810HM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-orrvEjJqPla810HM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-orrvEjJqPla810HM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-orrvEjJqPla810HM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-orrvEjJqPla810HM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-orrvEjJqPla810HM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-orrvEjJqPla810HM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-orrvEjJqPla810HM .cluster text{fill:#333;}#mermaid-svg-orrvEjJqPla810HM .cluster span{color:#333;}#mermaid-svg-orrvEjJqPla810HM div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-orrvEjJqPla810HM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-orrvEjJqPla810HM rect.text{fill:none;stroke-width:0;}#mermaid-svg-orrvEjJqPla810HM .icon-shape,#mermaid-svg-orrvEjJqPla810HM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-orrvEjJqPla810HM .icon-shape p,#mermaid-svg-orrvEjJqPla810HM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-orrvEjJqPla810HM .icon-shape .label rect,#mermaid-svg-orrvEjJqPla810HM .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-orrvEjJqPla810HM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-orrvEjJqPla810HM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-orrvEjJqPla810HM :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
变量 book
Book对象
books.get(0)
所以通过变量改对象,列表里看到的也是改后的对象;通过列表取出对象再改,变量看到的也是同一个对象。
例如:
books.get(0).returnOneCopy();
System.out.println(book.getAvailableCopies());
这里 book 的可借数量也会变化。
理解这一点很重要。集合不是对象仓库的复印机,它保存的是对象引用。
十八、List 里可以放重复元素
List 允许重复:
List<String> names = new ArrayList<>();
names.add("Tom");
names.add("Tom");
names.add("Tom");
System.out.println(names.size()); // 3
这对有些业务是合理的。比如考试成绩:
List<Integer> scores = new ArrayList<>();
scores.add(90);
scores.add(90);
两个学生都考 90 分,当然可以重复。
但对图书 ISBN 来说,重复可能不合理。注意,这不是 List 能自动判断的,它是业务规则。你必须在添加图书时自己检查:
if (findBookByIsbn(book.getIsbn()) != null) {
throw new IllegalArgumentException("图书已经存在");
}
集合提供通用能力,业务规则仍然要你写。
下一章会讲 Set,它天然不希望元素重复。但即使用 Set,也不代表所有业务重复问题都自动解决。对象是否重复,和 equals、hashCode 又有关,那是后面的重点。
十九、List 变量不要随便设成 null
不推荐:
private List<Book> books;
然后某些时候才初始化:
if (needBooks) {
books = new ArrayList<>();
}
这样其他方法里就要处处判断:
if (books != null && !books.isEmpty()) {
}
更好的做法是创建对象时就初始化:
private final List<Book> books = new ArrayList<>();
或者在构造方法里初始化:
public LibraryWithList(String name) {
this.books = new ArrayList<>();
}
没有图书时,books 是空列表,不是 null。
空列表表示:有一个列表,只是里面没有元素。
null 表示:连列表对象都没有。
在大多数业务代码里,空列表比 null 好处理。
二十、List 和封装
如果类内部有一个列表,不要轻易直接返回它。
错误设计:
public class LibraryWithList {
private final List<Book> books = new ArrayList<>();
public List<Book> getBooks() {
return books;
}
}
外部拿到列表后可以随便改:
library.getBooks().clear();
library.getBooks().add(null);
这样就绕过了 addBook 的校验规则。
更稳的设计是:先不给这个 getter,只提供明确业务方法:
public void addBook(Book book)
public Book findBookByIsbn(String isbn)
public void printStatus()
如果以后确实需要返回图书列表,也要谨慎。后面学到更多工具类后,可以返回不可修改视图,或者返回副本。现在先记住原则:类内部的可变集合是内部状态,不要随便暴露出去。
二十一、ArrayList 和 LinkedList 先怎么理解
你可能会看到另外一个列表实现:
LinkedList<String> names = new LinkedList<>();
它也是 List 的实现。
现阶段你先这样理解:
- ArrayList 底层更像数组。
- LinkedList 底层更像一串节点。
- 大多数普通业务列表,先用 ArrayList。
不要在初学阶段过早纠结“到底哪个性能好”。很多情况下,ArrayList 就是默认选择。等你真的遇到大量中间插入删除、队列结构、性能瓶颈,再专门分析。
初学者更需要先写对业务规则,而不是提前优化容器。
二十二、一个更完整的小例子:任务清单
现在写一个任务清单,练习 List 的增删改查。
任务类:
public class TodoItem {
private final String title;
private boolean done;
public TodoItem(String title) {
if (title == null || title.trim().isEmpty()) {
throw new IllegalArgumentException("任务标题不能为空");
}
this.title = title;
}
public String getTitle() {
return title;
}
public boolean isDone() {
return done;
}
public void markDone() {
this.done = true;
}
public String getStatusText() {
String status = done ? "已完成" : "未完成";
return title + " – " + status;
}
}
任务清单:
import java.util.ArrayList;
import java.util.List;
public class TodoList {
private final List<TodoItem> items = new ArrayList<>();
public void add(String title) {
items.add(new TodoItem(title));
}
public void markDone(int index) {
if (index < 0 || index >= items.size()) {
throw new IllegalArgumentException("任务下标不存在:" + index);
}
items.get(index).markDone();
}
public void removeDoneItems() {
for (int i = items.size() – 1; i >= 0; i—) {
if (items.get(i).isDone()) {
items.remove(i);
}
}
}
public void printAll() {
if (items.isEmpty()) {
System.out.println("暂无任务");
return;
}
for (int i = 0; i < items.size(); i++) {
System.out.println(i + ". " + items.get(i).getStatusText());
}
}
}
运行:
public class TodoApp {
public static void main(String[] args) {
TodoList todoList = new TodoList();
todoList.add("学习数组");
todoList.add("学习ArrayList");
todoList.add("重写图书馆项目");
todoList.markDone(0);
todoList.printAll();
todoList.removeDoneItems();
todoList.printAll();
}
}
这个例子包含了几个重要动作:
- items.add(…) 添加任务。
- items.get(index) 获取任务。
- items.size() 判断下标是否合法。
- 从后往前删除已完成任务。
- TodoList 不把 items 直接暴露出去。
它比单纯演示 List<String> 更接近真实项目,因为真实程序的集合里更多放的是对象,而不是简单字符串。
二十三、常见错误
1. 把数组 length 和 List size 混用
错误:
for (int i = 0; i < names.length; i++) {
}
如果 names 是 List<String>,没有 length。
正确:
for (int i = 0; i < names.size(); i++) {
}
2. 用 get 访问不存在的下标
错误:
List<String> names = new ArrayList<>();
names.add("Tom");
System.out.println(names.get(1));
只有一个元素时,合法下标只有 0。
3. 忘记导包
错误:
List<String> names = new ArrayList<>();
但文件开头没有:
import java.util.List;
import java.util.ArrayList;
编译器会提示找不到符号。
4. 泛型类型不匹配
错误:
List<String> names = new ArrayList<>();
names.add(100);
names 只能放字符串。
正确:
List<Integer> scores = new ArrayList<>();
scores.add(100);
5. 误以为 remove 一定按内容删除
对 List<String>:
names.remove("Tom");
是按对象删除。
对下标:
names.remove(0);
是删除第 0 个。
对 List<Integer> 有一个容易混淆的地方:
List<Integer> numbers = new ArrayList<>();
numbers.add(1);
numbers.add(2);
numbers.add(3);
numbers.remove(1);
这会删除下标 1 的元素,也就是删除 2,不是删除数字 1。
如果你要删除数字 1,可以写:
numbers.remove(Integer.valueOf(1));
这个细节后面讲包装类型时会更清楚。
6. 在增强 for 中删除
错误:
for (String name : names) {
if (name.startsWith("A")) {
names.remove(name);
}
}
先用倒序普通 for:
for (int i = names.size() – 1; i >= 0; i—) {
if (names.get(i).startsWith("A")) {
names.remove(i);
}
}
后面会学习 Iterator 和更现代的删除方式。
二十四、练习
练习 1:把姓名数组改成 List
写一个程序,循环输入姓名,输入 exit 结束。用 List<String> 保存所有姓名,最后输出总人数和所有姓名。
要求:
- 空字符串不允许添加。
- 姓名可以重复。
- 使用增强 for 输出。
练习 2:删除所有低于 60 的成绩
创建 List<Integer>:
90, 45, 50, 80, 59, 100
删除所有低于 60 的成绩,最后输出列表。
要求:
- 先故意用正向 for 删除,观察错误结果。
- 再改成倒序 for。
- 写出为什么正向删除会跳过元素。
练习 3:任务清单增加按标题查找
给 TodoList 增加方法:
public TodoItem findByTitle(String title)
要求:
- 找到返回任务对象。
- 找不到返回 null。
- 标题比较使用 equals。
练习 4:图书馆增加打印可借图书
给 LibraryWithList 增加:
public void printAvailableBooks()
只输出 book.isAvailable() 为 true 的图书。
思考:如果没有任何可借图书,应该输出什么?
练习 5:限制读者最多借 3 本书
在 LibraryWithList 中实现:
private int countActiveRecordsByReader(String readerId)
然后在 borrowBook 中判断:
if (countActiveRecordsByReader(readerId) >= 3) {
throw new IllegalStateException("读者最多只能同时借3本书");
}
这个练习会强化“集合遍历 + 业务规则”的组合能力。
二十五、本章小结
这一章你从数组走到了 List。
数组让你理解了容器最基本的事情:下标、长度、默认值、遍历、查找、删除和移动元素。ArrayList 并不是突然出现的魔法,它是在数组思想上封装出来的动态列表。
你现在应该掌握:
- 数组长度固定,适合固定规模数据。
- 对象数组默认值是 null。
- 用数组保存动态数据时,需要额外维护实际数量。
- 删除数组元素时要移动后续元素。
- List 表示有顺序、可重复、可按下标访问的一组数据。
- ArrayList 是最常用的 List 实现。
- add、get、set、remove、size、isEmpty 是必须熟练的基本方法。
- 删除列表元素时,正向遍历容易跳过元素,倒序删除更稳。
- 集合里保存对象引用,不是复制对象。
- 不要随便把内部可变列表暴露给外部。
下一章会讲 Set 和 Map。如果说 List 适合保存“按顺序排列的一批数据”,那么 Set 更关注“不能重复”,Map 更关注“根据 key 快速找到 value”。到那一章,你会把图书馆里的 ISBN 查找改得更自然。





