第 12 课:集合框架、泛型与迭代器
本课目标
学完后,你应该能够理解集合与数组的区别,选择 List、Set 或 Map,使用泛型获得编译期类型安全,使用迭代器安全删除,并完成标签去重、词频统计和成绩映射。
1. 集合为什么存在
数组长度创建后固定,适合大小明确、索引访问频繁的场景。集合通常提供动态增删、去重、键值映射和更丰富的操作。
基本关系是:Iterable 下有 Collection,Collection 下常见 List 与 Set;Map 是独立的键值结构。
2. List:有序、可重复、可索引
List 保留插入顺序、允许重复、可以按索引访问。常见实现是 ArrayList 与 LinkedList。
核心方法包括 add、get、set、remove、contains、size 和 isEmpty。
ArrayList 底层是动态数组,随机按索引读取快,通常是默认选择。LinkedList 是链表,适合明确的首尾插入删除场景,但按索引读取需要遍历。
3. Set:唯一性优先
Set 不允许重复元素,也不提供按索引读取。HashSet 不保证顺序,LinkedHashSet 保留插入顺序,TreeSet 按自然顺序或比较器排序。不要依赖 HashSet 偶然的输出顺序。
4. Map:键值映射
Map<K,V> 保存 key-value。相同 key 再次 put 会覆盖旧 value。get 找不到可能返回 null,需要默认值时使用 getOrDefault。同时需要 key 和 value 时使用 entrySet()。
HashMap 不保证顺序,LinkedHashMap 保留插入顺序,TreeMap 按 key 排序。
5. 泛型
List<String> 把元素类型固定为 String,错误会在编译期暴露,也减少强制类型转换。泛型参数必须是引用类型,List<Integer> 正确,List<int> 错误。
泛型类使用类型参数,例如 Box<T>;使用时具体化为 Box<String> 或 Box<Integer>。
? extends Number 表示某个未知的 Number 子类型,适合读取 List<Integer>、List<Double> 等列表。记忆原则:Producer Extends,主要读取时使用 extends;Consumer Super,主要写入时常用 super。
6. Iterator 与安全删除
增强 for 适合简单读取。遍历中需要删除当前元素时,使用 Iterator 的 remove();直接修改集合可能触发 ConcurrentModificationException。
7. 不可修改集合
Java 9+ 的 List.of、Set.of、Map.of 创建不可修改集合。修改会抛出 UnsupportedOperationException;需要动态增删时创建可变副本。
8. 集合选择速查
| 需求 | 推荐起点 |
|---|---|
| 有序、可重复、按索引 | ArrayList |
| 去重、不关心顺序 | HashSet |
| 去重、保留插入顺序 | LinkedHashSet |
| 去重并排序 | TreeSet |
| key-value、无顺序要求 | HashMap |
| key-value、保留插入顺序 | LinkedHashMap |
| key-value、按 key 排序 | TreeMap |
| 固定不可修改数据 | List.of、Set.of、Map.of |
8.1 先学会“按问题建模”,再背集合名称
选择集合不是凭感觉,而是先问三个问题:
- 我是否需要通过位置访问元素?需要就偏向
List。 - 我是否只关心“有没有”和“不能重复”?需要就偏向
Set。 - 我是否要用一个标识查找另一个值?需要就偏向
Map。
例如,购物车中的商品有购买顺序,而且同一种商品可能买多件,适合 List<CartItem>;课程标签要去重,适合 Set<String>;学生编号对应学生对象,适合 Map<String, Student>。真实项目中常常三者嵌套:Map<String, List<String>> 可以表示“班级 -> 学生名单”。
8.2 ArrayList 的内部思路:动态数组而不是“魔法盒子”
ArrayList 内部可以理解为一个会自动扩容的数组。get(index) 通过下标直接计算地址,因此平均时间复杂度是 O(1)。尾部 add 大多数时候也是 O(1),但容量不足时会申请更大的数组并复制旧元素,这一次操作是 O(n);把扩容成本平均到很多次添加上,称为摊销 O(1)。
中间插入或删除要移动后面的元素,所以是 O(n)。这解释了为什么“经常按索引读、尾部追加”的场景首选 ArrayList,而不应因为听到“链表插入快”就盲选 LinkedList。
List<String> tasks = new ArrayList<>();
tasks.add("编译"); // 尾部追加:tasks = [编译]
tasks.add(0, "打开项目"); // 下标 0 插入:原来的元素向后移动
String first = tasks.get(0); // 直接按下标读取第一个元素
tasks.remove(0); // 删除头部:后面的元素向前搬移
这四行正好展示了 ArrayList 的优点和代价:按下标读很快,尾部追加通常很快;但头部或中间插入、删除要移动元素,数据量很大时会变慢。
如果事先知道大致元素数量,可以 new ArrayList<>(1000) 减少扩容次数;这只是容量优化,不是把列表长度预先填成 1000。
8.3 LinkedList 为什么通常不是默认选择
LinkedList 的每个节点保存前驱、元素和后继引用。已知节点位置时,改变链接确实很快;但是调用 get(5000) 时,仍要从头或尾逐节点走过去,访问是 O(n),并且节点对象带来额外内存和缓存不友好问题。
LinkedList<String> queue = new LinkedList<>();
queue.addLast("任务 A");
queue.addLast("任务 B");
String current = queue.removeFirst(); // 队列语义清晰
如果需求是队列或双端队列,优先声明为 Deque<String>,实现可选 ArrayDeque;这样表达的是“需要首尾进出”,而不是把实现细节暴露给调用者。
8.4 HashSet/HashMap 的核心:哈希、桶与 equals
哈希集合不是简单地“随机存放”。添加 key 时,Java 先计算 hashCode(),再根据哈希定位桶;桶中可能有多个哈希冲突的元素,最后还要调用 equals() 确认是否是同一个逻辑 key。平均情况下查找、添加、删除接近 O(1),但糟糕的哈希分布会增加冲突成本。
Map<String, Integer> stock = new HashMap<>();
stock.put("pen", 10);
stock.put("pen", 12); // 同一个逻辑 key,旧值 10 被覆盖
stock.merge("book", 1, Integer::sum); // 不存在时放 1,存在时累加
因此,自定义类作为 key 时必须同时正确重写 equals 和 hashCode:相等对象必须产生相同哈希值。参与这两个方法的字段最好在放入 Map 后保持不变,否则对象可能已经在旧桶中,却无法用新状态定位。
8.5 LinkedHash 与 Tree:顺序是额外能力,也有成本
HashMap/HashSet 只承诺基于哈希的存取,不承诺遍历顺序;今天看起来有序,不代表下一次运行仍有同样顺序。LinkedHashMap/LinkedHashSet 额外维护链表,因此能保留插入顺序(LinkedHashMap 还可配置为访问顺序,适合 LRU 思路)。
TreeMap/TreeSet 使用平衡搜索树维护排序,基本操作是 O(log n)。元素必须能比较:要么实现 Comparable,要么提供 Comparator。
Set<Integer> ages = new TreeSet<>(Comparator.reverseOrder());
ages.add(20);
ages.add(18);
ages.add(20);
System.out.println(ages); // [20, 18]
比较器还决定“是否视为重复”:如果 compare(a, b) == 0,TreeSet 会把它们当成同一个位置,即使 equals 返回 false。这是使用 TreeSet 存自定义对象时最容易忽略的语义差异。
8.6 Map 的四种常用写法与 null 陷阱
Map<String, Integer> counts = new HashMap<>();
counts.put("java", 1);
counts.putIfAbsent("java", 99); // 已有值,不覆盖
counts.computeIfAbsent("spring", key -> 0); // 没有时创建默认值
counts.merge("java", 1, Integer::sum); // 1 + 1
if (counts.containsKey("git")) {
int value = counts.get("git");
}
int safe = counts.getOrDefault("git", 0);
get 返回 null 可能表示“没有 key”,也可能表示“这个 key 显式映射到 null”。如果业务上必须区分两者,使用 containsKey。不要直接写 int n = map.get("x");,因为 Integer null 自动拆箱为 int 会触发 NullPointerException。
8.7 泛型的本质:把类型检查交给编译器
没有泛型时,容器只能保存 Object,取出后必须强转,错误推迟到运行期;有泛型时,错误在编译期暴露。
List<String> messages = new ArrayList<>();
messages.add("hello");
// messages.add(100); // 编译期直接拒绝
String message = messages.get(0); // 不需要强制转换
泛型参数不能写基本类型,因为泛型基于引用类型;List<Integer> 中的 Integer 会发生自动装箱和拆箱。泛型只提供编译期约束,运行时会发生类型擦除,因此不能写 new T()、T.class,也不能创建 new List<String>[10] 这样的泛型数组。
8.8 泛型不变性与 PECS:最容易混淆的部分
List<Integer> 不是 List<Number> 的子类型。假设它可以赋值过去,就能通过 List<Number> 塞入 Double,破坏原列表的类型安全。
List<Integer> integers = List.of(1, 2, 3);
// List<Number> numbers = integers; // 编译错误
List<? extends Number> producer = integers;
Number first = producer.get(0); // 可以读成 Number
// producer.add(4); // 不能安全写入
List<Number> consumer = new ArrayList<>();
consumer.add(1.5);
consumer.add(2);
List<? super Integer> integerSink = consumer;
integerSink.add(10); // 可以写入 Integer
Object value = integerSink.get(0); // 读取只能保证是 Object
记忆方法是 PECS:Producer Extends,作为生产者、主要读取时用 ? extends T;Consumer Super,作为消费者、主要写入 T 时用 ? super T。通配符表达“未知类型”,而不是“任何类型都可以随便写”。
8.9 泛型方法与类型推断
类型参数也可以只存在于方法上,调用者通常不需要显式写出类型。
public static <T> T first(List<T> values) {
if (values.isEmpty()) {
throw new IllegalArgumentException("列表不能为空");
}
return values.get(0);
}
public static <T extends Comparable<? super T>> T max(List<T> values) {
T result = values.get(0);
for (T value : values) {
if (value.compareTo(result) > 0) {
result = value;
}
}
return result;
}
<T extends Comparable<? super T>> 表示 T 必须能够和自身(或它的父类型)比较。这种上界让方法在编译期拒绝不可排序的类型,比运行时才发现问题更安全。
8.10 Iterator 的状态机与失效规则
迭代器可以看成一个游标:初始位于第一个元素之前;hasNext() 只询问是否还有元素,next() 才真正前进并返回元素。remove() 删除最近一次 next() 返回的元素,因此不能在第一次 next() 前调用,也不能连续调用两次。
Iterator<String> it = skills.iterator();
while (it.hasNext()) {
String skill = it.next();
if (skill.length() < 4) {
it.remove();
}
}
大多数 JDK 集合的迭代器是 fail-fast:集合被迭代器之外的结构性操作修改后,后续操作通常抛出 ConcurrentModificationException。这是尽早暴露错误的检查,不是并发安全保证;多线程场景要使用并发集合或显式同步。只想删除满足条件的元素时,也可以使用 removeIf,它表达的意图更直接:
skills.removeIf(skill -> skill.equals("Legacy"));
Iterator.remove() 有严格的调用状态:必须先成功执行一次 next(),并且同一次 next() 后最多删除一次;否则会抛出 IllegalStateException。如果需要在遍历中插入元素、替换元素或前后移动游标,应使用 ListIterator,它比普通 Iterator 多出 add、set、hasPrevious 等能力。
增强 for 本质上通常也是通过迭代器遍历。遍历时 set(index, value) 只是替换已有元素,通常不改变集合结构;但 add、remove、clear 会改变结构,容易触发 fail-fast。并发环境不要把 fail-fast 当作线程安全,应按场景选择 ConcurrentHashMap、CopyOnWriteArrayList 或同步策略。
8.11 不可修改、不可变与防御性复制
List.of 返回的是不可修改集合,调用 add、set、remove 会抛 UnsupportedOperationException。Collections.unmodifiableList(list) 是只读视图:通过视图不能修改,但原始 list 变化时视图仍会看到变化。若要隔离外部修改,应复制后再暴露:
List<String> source = new ArrayList<>(List.of("A", "B"));
List<String> readOnlySnapshot = List.copyOf(source);
source.add("C");
System.out.println(readOnlySnapshot); // [A, B],是快照
8.12 复杂度与选择总表
| 实现 | 查找/索引 | 尾部添加 | 中间插入删除 | 顺序/约束 |
|---|---|---|---|---|
ArrayList | 索引 O(1),按值 O(n) | 摊销 O(1) | O(n) | 有序、可重复 |
LinkedList | O(n) | O(1) | 找到节点后快 | 有序、可重复 |
HashSet | 平均 O(1) | 平均 O(1) | 平均 O(1) | 唯一、无顺序保证 |
TreeSet | O(log n) | O(log n) | O(log n) | 唯一、排序 |
HashMap | 平均 O(1) | 平均 O(1) | 平均 O(1) | key 唯一、无顺序保证 |
TreeMap | O(log n) | O(log n) | O(log n) | key 唯一、按 key 排序 |
复杂度只是起点,还要考虑可读性、内存、线程安全、是否允许 null、是否需要稳定顺序。先说清楚业务语义,再选实现类。
8.13 集合框架的接口全貌与 Map 视图
可以把主要关系记成:
Iterable
└─ Collection
├─ List
├─ Set
└─ Queue / Deque
Map<K, V>(独立体系,不继承 Collection)
Map 虽然不是 Collection,却能通过 keySet()、values()、entrySet() 提供三个集合视图。视图通常与原 Map 联动,不是自动复制:
Map<String, Integer> map = new HashMap<>();
map.put("Java", 90); // 先放入一个键值对
Set<String> keys = map.keySet(); // keys 是 Map 的 key 视图,不是独立复制品
map.put("SQL", 85); // 修改原 Map
System.out.println(keys); // 视图能看到新 key:Java、SQL
keys.remove("Java"); // 从视图删除,也会删除 map 中的 Java
这里的关键是“视图”:keys 和 map 指向同一份逻辑数据。不要把它当成与原 Map 完全无关的新 Set。
只需要 key 时遍历 keySet();只需要 value 时遍历 values();同时需要两者时使用 entrySet(),避免先取 key 再调用一次 get()。
8.14 equals、hashCode 与 TreeSet 的完整案例
final class Student {
private final String id; // 学号是身份字段,设为 final 防止放入集合后改变
private String name; // 姓名不是本例判断身份的依据
Student(String id, String name) {
this.id = id;
this.name = name;
}
@Override
public boolean equals(Object object) {
if (this == object) return true; // 同一个对象,必然相等
if (!(object instanceof Student other)) return false; // 类型不同,不相等
return id.equals(other.id); // 业务规则:学号相同就是同一学生
}
@Override
public int hashCode() {
return id.hashCode(); // 必须与 equals 使用同一个身份字段
}
}
Set<Student> students = new HashSet<>(); // HashSet 会调用 equals/hashCode 判断重复
students.add(new Student("S001", "小明"));
students.add(new Student("S001", "明明"));
System.out.println(students.size()); // 1
HashSet 先用 hash 定位,再用 equals 确认;equals 为 true 时 hash 必须相同,但 hash 相同不代表 equals 一定为 true。只重写 equals 不重写 hashCode 会破坏这个约定,导致“看起来相等”的对象无法去重或查找。
放入 Hash 集合后,不要修改参与相等判断的字段。比如 id 若不是 final,放入后改了 id,集合仍把对象放在旧哈希桶中,此时 contains、remove 可能都找不到它。
TreeSet 的重复规则来自比较结果而不是 equals。例如 BigDecimal("1.0") 与 BigDecimal("1.00") 的 equals 为 false,但 compareTo 为 0,放入 TreeSet 时只能留下一个。自定义比较器最好让 compare(a, b) == 0 与业务上的相等含义一致。
8.15 高频陷阱与小实验
List<Integer>.remove 的重载陷阱
List<Integer> numbers = new ArrayList<>(List.of(10, 20, 30));
numbers.remove(1); // 删除下标 1,结果 [10, 30]
numbers.remove(Integer.valueOf(30)); // 删除数值 30
因为 remove(1) 匹配 remove(int index),而 remove(Integer.valueOf(1)) 才匹配 remove(Object)。
getOrDefault 不会替换显式 null
Map<String, Integer> map = new HashMap<>();
map.put("Java", null);
System.out.println(map.getOrDefault("Java", 0)); // null
System.out.println(map.containsKey("Java")); // true
固定大小、只读视图和不可变副本不是一回事
Arrays.asList(array) 不能 add/remove,但可以 set,并且与原数组共享数据;Collections.unmodifiableList(list) 不能通过视图修改,但原列表变化会反映出来;List.copyOf(list) 创建独立的不可修改快照,并且不允许元素为 null。
需要排序时避免减法比较器
record StudentScore(String name, int score) {}
List<StudentScore> result = new ArrayList<>();
result.sort(Comparator.comparingInt(StudentScore::score));
// 不推荐 (a, b) -> a.score() - b.score(),可能整数溢出
HashMap 允许一个 null key 和多个 null value,HashSet 允许一个 null;使用自然排序的 TreeMap/TreeSet 通常不能处理 null。不要把任何哈希集合当前的打印顺序当成业务规则。
9. 完整源码一:CollectionBasics.java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
public class CollectionBasics {
public static void main(String[] args) {
// List 有顺序、允许重复,可以通过索引访问。
List<String> names = new ArrayList<>(); // 左边写接口,右边选择实现类
names.add("小明"); // 下标 0:小明
names.add("小红"); // 下标 1:小红
names.add("小明"); // 允许重复,下标 2 仍可放小明
names.set(1, "小李"); // 把下标 1 的小红替换为小李
names.remove("小明"); // 按“内容”删除,只删除第一个匹配项
System.out.println("List:" + names);
System.out.println("List 第一个元素:" + names.get(0));
// Set 关注唯一性;HashSet 不保证遍历顺序。
Set<String> hashTags = new HashSet<>(); // Set 的核心规则:同一个元素只保留一份
hashTags.add("Java"); // 第一次添加成功
hashTags.add("Spring");
hashTags.add("Java"); // 重复添加,集合大小不增加
System.out.println("HashSet:" + hashTags);
// LinkedHashSet 去重的同时保留插入顺序。
Set<String> orderedTags = new LinkedHashSet<>(); // 去重 + 记住第一次出现的顺序
orderedTags.add("Java");
orderedTags.add("Spring");
orderedTags.add("Java");
System.out.println("LinkedHashSet:" + orderedTags);
// TreeSet 按自然顺序保存 String。
Set<String> sortedTags = new TreeSet<>(orderedTags); // 复制元素,并按 String 自然顺序排序
System.out.println("TreeSet:" + sortedTags);
// Map 保存 key-value;同一个 key 再 put 会覆盖旧值。
Map<String, Integer> scores = new HashMap<>(); // key 是课程名,value 是成绩
scores.put("Java", 90);
scores.put("SQL", 85);
scores.put("Java", 95); // key 已存在,所以 90 被 95 覆盖
System.out.println("Java 成绩:" + scores.get("Java"));
System.out.println("不存在的成绩:" + scores.getOrDefault("Git", 0));
// 两种 Map 实现分别展示“真实插入顺序”和 key 排序。
// 不要从 HashMap 复制到 LinkedHashMap 来推断插入顺序:
// HashMap 本身没有插入顺序承诺。
Map<String, Integer> orderedScores = new LinkedHashMap<>();
orderedScores.put("Java", 90);
orderedScores.put("SQL", 85);
orderedScores.put("Java", 95); // 更新 value,不改变 Java 的首次位置
Map<String, Integer> sortedScores = new TreeMap<>(orderedScores); // 按 key:Java、SQL 排序
System.out.println("LinkedHashMap:" + orderedScores);
System.out.println("TreeMap:" + sortedScores);
// entrySet 同时得到 key 和 value。
for (Map.Entry<String, Integer> entry : sortedScores.entrySet()) {
// entry 同时携带 key 和 value;不用再通过 sortedScores.get(key) 查一次
System.out.println(entry.getKey() + " -> " + entry.getValue());
}
// Java 9+ 工厂方法创建的集合通常不可修改。
List<String> fixedNames = List.of("小明", "小红"); // 固定集合;调用 add 会抛异常
System.out.println("固定列表:" + fixedNames);
}
}
阅读顺序:先观察 List 的重复和索引,再观察三种 Set 的唯一性与顺序,最后观察 Map 的 key 覆盖、默认值、三种顺序和 entrySet 遍历。
这段程序请这样读
List<String>中的String是“只允许放字符串”的约束;new ArrayList<>()是真正负责存数据的对象。names最后是[小李, 小明]:set替换了小红,remove("小明")删除了第一个小明,第二个小明还在。- 三个 Set 都会去重,但输出顺序不同:
HashSet不承诺顺序,LinkedHashSet保留插入顺序,TreeSet按字典序排列。 - Map 的 key 不能重复;第二次
put("Java", 95)不是新增一门课,而是修改 Java 的成绩。 getOrDefault("Git", 0)的意思是:Git 存在就返回真实成绩,不存在就临时返回 0。
10. 完整源码二:GenericBox.java
import java.util.List;
public class GenericBox<T> {
// T 是类型参数,使用 GenericBox 时由调用者指定具体类型。
private T value; // T 不是具体类型名,而是“由使用者决定的占位符”
public GenericBox(T value) {
this.value = value; // 把传入的对象保存起来
}
public T getValue() {
return value; // 返回类型仍然是 T,调用者能拿到准确类型
}
public void setValue(T value) {
this.value = value; // 只能设置与 T 兼容的值
}
// 泛型方法的 E 独立于类 GenericBox<T> 的 T。
public static <E> void swap(List<E> values, int first, int second) {
E temporary = values.get(first); // 先暂存第一个位置的元素
values.set(first, values.get(second)); // 第二个元素放到第一个位置
values.set(second, temporary); // 暂存的第一个元素放到第二个位置
}
public static void main(String[] args) {
// 这里 T 被替换为 String。
GenericBox<String> textBox = new GenericBox<>("Java"); // T = String
String text = textBox.getValue(); // 不需要强制类型转换
System.out.println("文字盒子:" + text);
// 这里 T 被替换为 Integer;泛型不能直接使用 int。
GenericBox<Integer> numberBox = new GenericBox<>(100); // T = Integer
int number = numberBox.getValue(); // Integer 自动拆箱为 int
System.out.println("数字盒子:" + number);
// numberBox.setValue(1.5); // 编译错误
List<String> topics = new java.util.ArrayList<>(List.of("List", "Set"));
swap(topics, 0, 1); // E 被推断为 String,交换过程仍然类型安全
System.out.println("交换后的主题:" + topics);
}
}
GenericBox<T> 用一份类代码安全保存不同类型。GenericBox<Integer> 取值时返回 Integer,随后自动拆箱为 int。不要使用原始类型 GenericBox box,否则会失去泛型检查。
这段程序请这样读
把 T 想象成一个“尚未填写的类型空格”。创建 GenericBox<String> 时,空格被填写为 String;创建 GenericBox<Integer> 时,空格被填写为 Integer。同一份类代码因此可以服务不同类型,但每个盒子内部仍然保持类型安全。
swap 里的 E 是方法自己的类型参数,与类上的 T 没有关系。传入 List<String> 时 E 就是 String;传入 List<Integer> 时 E 就是 Integer。这就是“泛型方法”。
11. 完整源码三:IteratorDemo.java
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class IteratorDemo {
public static void main(String[] args) {
List<String> skills = new ArrayList<>();
skills.add("Java");
skills.add("Legacy");
skills.add("Spring");
skills.add("Legacy");
Iterator<String> iterator = skills.iterator(); // 游标位于第一个元素之前
while (iterator.hasNext()) {
String skill = iterator.next(); // 游标前进,并取出当前元素
// 遍历时删除当前元素,必须使用 iterator.remove()。
// 直接 skills.remove(skill) 可能触发 ConcurrentModificationException。
if ("Legacy".equals(skill)) {
iterator.remove(); // 删除刚刚 next() 取出的 Legacy
}
}
System.out.println("删除后的技能:" + skills);
}
}
每次 next() 移动迭代器并返回元素,hasNext() 检查是否还有元素,remove() 删除最近一次 next() 返回的元素。
用一个过程理解迭代器
假设列表是 [Java, Legacy, Spring, Legacy]:
- 第一次
next()得到Java,不是 Legacy,不删除。 - 第二次
next()得到第一个Legacy,调用iterator.remove(),列表变成[Java, Spring, Legacy]。 - 继续
next()得到Spring,不删除。 - 最后一次
next()得到第二个Legacy,再次通过同一个迭代器删除。
最终结果是 [Java, Spring]。删除动作必须交给迭代器,因为迭代器内部也维护着当前位置;直接调用 skills.remove(skill) 会让集合和迭代器的状态不同步。
12. 独立练习完整答案:CollectionPractice.java
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class CollectionPractice {
public static void main(String[] args) {
// 1. 去重并保持标签第一次出现的顺序。
String[] rawTags = {"Java", "Spring", "Java", "MySQL", "Spring", "Git"}; // 原始标签,含重复
Set<String> uniqueTags = new LinkedHashSet<>(); // 选择“去重 + 保序”
Collections.addAll(uniqueTags, rawTags); // 逐个添加,重复项自动被忽略
System.out.println("去重标签:" + uniqueTags);
// 2. 统计单词频率。
String[] words = {"java", "spring", "java", "git", "spring", "java"};
Map<String, Integer> frequency = new HashMap<>(); // 单词 -> 出现次数
for (String word : words) {
int oldCount = frequency.getOrDefault(word, 0); // 第一次没有时从 0 开始
frequency.put(word, oldCount + 1); // 当前次数加 1 后写回
}
System.out.println("词频:" + frequency);
// 3. 保存成绩并按插入顺序遍历;若需要按姓名排序可改用 TreeMap。
Map<String, Integer> scores = new LinkedHashMap<>();
scores.put("小明", 88); // key 是姓名,value 是成绩
scores.put("小红", 92);
scores.put("小李", 76);
for (Map.Entry<String, Integer> entry : scores.entrySet()) {
// entry.getKey() 取姓名,entry.getValue() 取成绩
System.out.println(entry.getKey() + ":" + entry.getValue());
}
// 4. Collections 工具类可以对可变 List 排序和反转。
List<Integer> numbers = new ArrayList<>();
Collections.addAll(numbers, 7, 2, 9, 2, 5); // List 允许重复数字
Collections.sort(numbers); // 升序排列原列表
System.out.println("升序:" + numbers);
Collections.reverse(numbers); // 把当前顺序反过来,不是重新排序
System.out.println("反转后:" + numbers);
// 5. ? extends Number 表示可以读取 Number 的任意子类型列表。
printNumbers(numbers);
}
// 方法只需要遍历,因此面向 Iterable 最小接口,而不是强依赖 List。
// ? extends Number 允许传入 List<Integer>、List<Double> 等不同具体类型。
public static void printNumbers(Iterable<? extends Number> numbers) {
for (Number number : numbers) {
System.out.println("数字:" + number);
}
}
}
词频算法逐步拆解
java 第一次出现时,getOrDefault("java", 0) 得到 0,然后保存 1;第二次得到 1,再保存 2;第三次得到 2,再保存 3。Map 的 key 定位单词,value 保存累计次数。
练习代码的学习顺序
先看需求,再决定数据结构:标签需求是“唯一且保留第一次顺序”,所以用 LinkedHashSet;词频需求是“根据单词查次数”,所以用 Map;成绩需求还要求按录入顺序展示,所以用 LinkedHashMap。最后的 printNumbers 只需要逐个读取数字,因此参数写成 Iterable<? extends Number>,表示它可以接收各种数字类型的可遍历集合。
13. 自定义对象放入 Set/Map
HashSet 和 HashMap 依赖元素的 equals() 与 hashCode() 判断逻辑相等。规则是:如果 a.equals(b) 为 true,a.hashCode() 必须等于 b.hashCode()。
放入集合后不要随意修改参与 equals/hashCode 的字段,否则元素可能再也找不到。这个主题会在对象模型复习中专门练习。
14. 常见错误
- 依赖
HashSet或HashMap的遍历顺序。 Map.get()得到null后直接拆箱或计算。- 使用原始类型
List list,丢失泛型安全。 - 增强
for中直接修改集合,应使用Iterator.remove()。 - 修改
List.of创建的不可修改集合。 TreeSet中放入无法比较的对象。- 忘记自定义对象的
equals/hashCode规则。
15. 检查题与答案
问题
List、Set、Map的核心差异是什么?ArrayList是否允许重复元素?HashSet是否保证遍历顺序?LinkedHashSet比HashSet多保证什么?TreeSet的元素为什么需要可比较?- Map 中同一个 key 再次 put 会怎样?
get和getOrDefault的区别是什么?entrySet()适合什么场景?- 泛型主要把错误提前到什么时候?
- 为什么泛型不能写
List<int>? ? extends Number通常适合读取还是写入?- 为什么遍历时直接从 List 删除可能抛异常?
- Iterator 的安全删除方法是什么?
List.of创建的列表能否 add?HashMap和LinkedHashMap的顺序区别是什么?- 为什么自定义对象放 HashSet 要重写 equals/hashCode?
- 词频统计为什么适合 Map?
- 什么时候优先选择 ArrayList 而不是 LinkedList?
答案
- List 有序可重复可索引;Set 唯一;Map 保存 key-value 映射。
- 允许。
- 不保证。
- 保留插入顺序。
- 它需要判断元素先后关系并排序。
- 旧 value 被新 value 覆盖,key 仍只有一个。
get找不到返回null;getOrDefault找不到返回指定默认值。- 同时需要 key 和 value 遍历 Map 时。
- 编译期。
- 泛型参数必须是引用类型,整数使用
Integer。 - 读取;具体子类型未知时通常不能添加具体对象。
- 集合结构被外部修改,迭代器可能抛出
ConcurrentModificationException。 iterator.remove()。- 不能,修改会抛
UnsupportedOperationException。 - HashMap 不保证顺序,LinkedHashMap 保留插入顺序。
- Hash 集合依赖它们判断逻辑相等和计算哈希位置。
- key 表示单词,value 表示累计次数,正好是键值映射。
- 需要按索引随机读取、通常尾部追加、且没有明确链表场景时。
16. 本课完成标准
- 能根据语义选择 List、Set 或 Map。
- 能说明 Hash、LinkedHash、Tree 实现的顺序差异。
- 能使用泛型避免强制类型转换。
- 能用
getOrDefault完成词频统计。 - 能用
entrySet遍历 key-value。 - 能使用 Iterator 安全删除。
- 能解释
? extends的读取语义。 - 能识别不可修改集合和原始类型的风险。
- 能说明自定义对象与 equals/hashCode 的关系。
下一课学习 Lambda、函数式接口、方法引用和 Stream。