第 12 课:集合框架、泛型与迭代器

3 阅读21分钟

第 12 课:集合框架、泛型与迭代器

本课目标

学完后,你应该能够理解集合与数组的区别,选择 ListSetMap,使用泛型获得编译期类型安全,使用迭代器安全删除,并完成标签去重、词频统计和成绩映射。

1. 集合为什么存在

数组长度创建后固定,适合大小明确、索引访问频繁的场景。集合通常提供动态增删、去重、键值映射和更丰富的操作。

基本关系是:Iterable 下有 CollectionCollection 下常见 ListSetMap 是独立的键值结构。

2. List:有序、可重复、可索引

List 保留插入顺序、允许重复、可以按索引访问。常见实现是 ArrayListLinkedList

核心方法包括 addgetsetremovecontainssizeisEmpty

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 适合简单读取。遍历中需要删除当前元素时,使用 Iteratorremove();直接修改集合可能触发 ConcurrentModificationException

7. 不可修改集合

Java 9+ 的 List.ofSet.ofMap.of 创建不可修改集合。修改会抛出 UnsupportedOperationException;需要动态增删时创建可变副本。

8. 集合选择速查

需求推荐起点
有序、可重复、按索引ArrayList
去重、不关心顺序HashSet
去重、保留插入顺序LinkedHashSet
去重并排序TreeSet
key-value、无顺序要求HashMap
key-value、保留插入顺序LinkedHashMap
key-value、按 key 排序TreeMap
固定不可修改数据List.ofSet.ofMap.of

8.1 先学会“按问题建模”,再背集合名称

选择集合不是凭感觉,而是先问三个问题:

  1. 我是否需要通过位置访问元素?需要就偏向 List
  2. 我是否只关心“有没有”和“不能重复”?需要就偏向 Set
  3. 我是否要用一个标识查找另一个值?需要就偏向 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 时必须同时正确重写 equalshashCode:相等对象必须产生相同哈希值。参与这两个方法的字段最好在放入 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) == 0TreeSet 会把它们当成同一个位置,即使 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 + 1if (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 多出 addsethasPrevious 等能力。

增强 for 本质上通常也是通过迭代器遍历。遍历时 set(index, value) 只是替换已有元素,通常不改变集合结构;但 addremoveclear 会改变结构,容易触发 fail-fast。并发环境不要把 fail-fast 当作线程安全,应按场景选择 ConcurrentHashMapCopyOnWriteArrayList 或同步策略。

8.11 不可修改、不可变与防御性复制

List.of 返回的是不可修改集合,调用 addsetremove 会抛 UnsupportedOperationExceptionCollections.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)有序、可重复
LinkedListO(n)O(1)找到节点后快有序、可重复
HashSet平均 O(1)平均 O(1)平均 O(1)唯一、无顺序保证
TreeSetO(log n)O(log n)O(log n)唯一、排序
HashMap平均 O(1)平均 O(1)平均 O(1)key 唯一、无顺序保证
TreeMapO(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

这里的关键是“视图”:keysmap 指向同一份逻辑数据。不要把它当成与原 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 确认;equalstrue 时 hash 必须相同,但 hash 相同不代表 equals 一定为 true。只重写 equals 不重写 hashCode 会破坏这个约定,导致“看起来相等”的对象无法去重或查找。

放入 Hash 集合后,不要修改参与相等判断的字段。比如 id 若不是 final,放入后改了 id,集合仍把对象放在旧哈希桶中,此时 containsremove 可能都找不到它。

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 遍历。

这段程序请这样读

  1. List<String> 中的 String 是“只允许放字符串”的约束;new ArrayList<>() 是真正负责存数据的对象。
  2. names 最后是 [小李, 小明]set 替换了小红,remove("小明") 删除了第一个小明,第二个小明还在。
  3. 三个 Set 都会去重,但输出顺序不同:HashSet 不承诺顺序,LinkedHashSet 保留插入顺序,TreeSet 按字典序排列。
  4. Map 的 key 不能重复;第二次 put("Java", 95) 不是新增一门课,而是修改 Java 的成绩。
  5. 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]

  1. 第一次 next() 得到 Java,不是 Legacy,不删除。
  2. 第二次 next() 得到第一个 Legacy,调用 iterator.remove(),列表变成 [Java, Spring, Legacy]
  3. 继续 next() 得到 Spring,不删除。
  4. 最后一次 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

HashSetHashMap 依赖元素的 equals()hashCode() 判断逻辑相等。规则是:如果 a.equals(b) 为 true,a.hashCode() 必须等于 b.hashCode()

放入集合后不要随意修改参与 equals/hashCode 的字段,否则元素可能再也找不到。这个主题会在对象模型复习中专门练习。

14. 常见错误

  1. 依赖 HashSetHashMap 的遍历顺序。
  2. Map.get() 得到 null 后直接拆箱或计算。
  3. 使用原始类型 List list,丢失泛型安全。
  4. 增强 for 中直接修改集合,应使用 Iterator.remove()
  5. 修改 List.of 创建的不可修改集合。
  6. TreeSet 中放入无法比较的对象。
  7. 忘记自定义对象的 equals/hashCode 规则。

15. 检查题与答案

问题

  1. ListSetMap 的核心差异是什么?
  2. ArrayList 是否允许重复元素?
  3. HashSet 是否保证遍历顺序?
  4. LinkedHashSetHashSet 多保证什么?
  5. TreeSet 的元素为什么需要可比较?
  6. Map 中同一个 key 再次 put 会怎样?
  7. getgetOrDefault 的区别是什么?
  8. entrySet() 适合什么场景?
  9. 泛型主要把错误提前到什么时候?
  10. 为什么泛型不能写 List<int>
  11. ? extends Number 通常适合读取还是写入?
  12. 为什么遍历时直接从 List 删除可能抛异常?
  13. Iterator 的安全删除方法是什么?
  14. List.of 创建的列表能否 add?
  15. HashMapLinkedHashMap 的顺序区别是什么?
  16. 为什么自定义对象放 HashSet 要重写 equals/hashCode?
  17. 词频统计为什么适合 Map?
  18. 什么时候优先选择 ArrayList 而不是 LinkedList?

答案

  1. List 有序可重复可索引;Set 唯一;Map 保存 key-value 映射。
  2. 允许。
  3. 不保证。
  4. 保留插入顺序。
  5. 它需要判断元素先后关系并排序。
  6. 旧 value 被新 value 覆盖,key 仍只有一个。
  7. get 找不到返回 nullgetOrDefault 找不到返回指定默认值。
  8. 同时需要 key 和 value 遍历 Map 时。
  9. 编译期。
  10. 泛型参数必须是引用类型,整数使用 Integer
  11. 读取;具体子类型未知时通常不能添加具体对象。
  12. 集合结构被外部修改,迭代器可能抛出 ConcurrentModificationException
  13. iterator.remove()
  14. 不能,修改会抛 UnsupportedOperationException
  15. HashMap 不保证顺序,LinkedHashMap 保留插入顺序。
  16. Hash 集合依赖它们判断逻辑相等和计算哈希位置。
  17. key 表示单词,value 表示累计次数,正好是键值映射。
  18. 需要按索引随机读取、通常尾部追加、且没有明确链表场景时。

16. 本课完成标准

  • 能根据语义选择 List、Set 或 Map。
  • 能说明 Hash、LinkedHash、Tree 实现的顺序差异。
  • 能使用泛型避免强制类型转换。
  • 能用 getOrDefault 完成词频统计。
  • 能用 entrySet 遍历 key-value。
  • 能使用 Iterator 安全删除。
  • 能解释 ? extends 的读取语义。
  • 能识别不可修改集合和原始类型的风险。
  • 能说明自定义对象与 equals/hashCode 的关系。

下一课学习 Lambda、函数式接口、方法引用和 Stream。