29. 【Java】集合框架(上):List 与 Set

摘要: 本文系统讲解Java集合框架中List和Set两大核心接口,深入剖析ArrayList、LinkedList、HashSet、LinkedHashSet、TreeSet等常用实现类的特性、使用场景与性能差异。通过对比数组痛点、集合体系结构、遍历方式及元素相等性判断,帮助开发者掌握集合框架的核心概念与实战应用,并介绍JDK 21的Sequenced Collections新特性,为构建高效Java应用奠定基础。

关键词: Java集合框架, List接口, Set接口, ArrayList, HashSet


恭喜你完成了第三阶段的“学生管理系统”实战项目。在那个项目里,我们大量使用了 ArrayList——用来存放学生、老师、课程、成绩记录。你可能已经感受到了:ArrayList 比数组好用太多了。

  • 数组:大小固定,一旦创建就不能改变。你没法往一个满的数组里加新元素,除非新建一个更大的数组,再把旧数据复制过去。
  • ArrayList:大小是动态的,你可以随便往里面加元素,它自己会扩容。你还可以随时删除、插入、查找……方便得像一个“智能容器”。

这种能自动伸缩、能随意增删改查的容器,在 Java 的世界里有一个统称——集合(Collection)

从今天开始,我们要花几篇文章系统地学习 Java 集合框架(Collections Framework)。这是 JDK 中最重要的类库之一,几乎每个程序都会用到它。今天我们先来认识其中最常用的两大家族:ListSet


1. 为什么需要集合?—— 数组的痛点

在学集合之前,我们得先理解:数组到底哪里不够用?

让我们回顾一下数组的几个“硬伤”:

// 数组的痛点
String[] names = new String[10];   // 必须指定大小,而且不能变
names[0] = "张三";
// 如果我想加第 11 个人,怎么办?—— 得新建一个更大的数组,复制旧数据

数组的局限性:

  1. 长度固定:创建后无法动态扩展。
  2. 删除/插入麻烦:删除中间一个元素,需要手动移动后面所有元素。
  3. 操作少:没有直接的方法来排序、查找、判断是否包含某个元素。
  4. 类型单一:只能存同一种类型(不过这是优点,泛型集合也这样)。

而集合框架就是为了解决这些问题而设计的。它提供了一系列可动态扩展、功能丰富、类型安全的数据结构,让你能轻松地管理一组对象。


2. 集合框架体系概览 —— 一张“家族树”

Java 集合框架的核心是两大接口:

  • Collection:表示一组对象的集合(单个元素的容器)。
  • Map:表示一组“键值对”的集合(我们下一篇会讲)。

Collection 接口又有三个主要的子接口:

Collection (接口)
├── List (接口) —— 有序、可重复
│   ├── ArrayList (类)
│   ├── LinkedList (类)
│   └── Vector (类,历史遗留,不推荐)
├── Set (接口) —— 无序、不可重复
│   ├── HashSet (类)
│   ├── TreeSet (类)
│   └── LinkedHashSet (类)
└── Queue (接口) —— 队列(FIFO)
    ├── LinkedList (也实现了 Queue)
    └── PriorityQueue (类)

今天,我们重点讲 ListSet,它们是最常用的两个分支。


3. List 接口 —— 有序的、可重复的“列表”

List 的特点是:

  • 有序:元素按照你插入的顺序排列,你可以通过索引(位置)访问每个元素。
  • 可重复:允许存储相同的元素(即 equals() 返回 true 的两个元素可以同时存在)。

我们最常用的两个 List 实现是 ArrayListLinkedList

3.1 ArrayList —— 基于数组的动态列表

ArrayList 内部使用一个动态数组来存储元素。它支持快速随机访问(通过索引取元素很快),但在中间插入或删除元素时,需要移动后面的元素,效率较低。

创建和使用 ArrayList

import java.util.ArrayList;
import java.util.List;

public class ArrayListDemo {
    public static void main(String[] args) {
        // 创建一个 ArrayList,默认初始容量为 10(但你可以不关心这个)
        List<String> names = new ArrayList<>();
        
        // 添加元素
        names.add("张三");
        names.add("李四");
        names.add("王五");
        names.add("张三");   // 可以重复
        
        // 通过索引访问
        System.out.println("索引0:" + names.get(0));   // 张三
        
        // 获取大小
        System.out.println("大小:" + names.size());    // 4
        
        // 插入元素到指定位置(索引1处插入"赵六")
        names.add(1, "赵六");
        
        // 删除元素(按索引)
        names.remove(2);   // 删除索引2的元素(原"李四")
        
        // 删除元素(按对象)
        names.remove("张三");   // 只删除第一个匹配的
        
        // 判断是否包含
        System.out.println("是否包含王五:" + names.contains("王五")); // true
        
        // 遍历方式1:增强 for
        for (String name : names) {
            System.out.println(name);
        }
        
        // 遍历方式2:带索引的 for
        for (int i = 0; i < names.size(); i++) {
            System.out.println(i + ": " + names.get(i));
        }
    }
}

ArrayList 的特点总结:

  • 适合频繁随机访问get()set())的场景。
  • 不适合频繁在中间插入或删除,因为需要移动大量元素。
  • 尾部添加(add())效率高,因为大多数情况下不需要移动元素。
3.2 LinkedList —— 基于链表的动态列表

LinkedList 内部使用双向链表来存储元素。它的每个元素都存储了对前一个和后一个元素的引用。因此,在列表中间插入或删除元素非常高效(只需要修改相邻节点的引用),但随机访问较慢(需要从头或尾遍历)。

import java.util.LinkedList;
import java.util.List;

public class LinkedListDemo {
    public static void main(String[] args) {
        // LinkedList 也实现了 List 接口,所以可以用 List 引用
        List<String> names = new LinkedList<>();
        
        names.add("张三");
        names.add("李四");
        names.addFirst("王五");   // LinkedList 特有方法:在头部添加
        names.addLast("赵六");    // 在尾部添加
        
        // 可以用同样的 List 方法
        System.out.println(names.get(0));    // 王五
        System.out.println(names.size());    // 4
        
        // 删除第一个元素
        names.remove(0);
        
        // 还可以用队列操作(因为 LinkedList 也实现了 Queue 接口)
        // 这个我们后面讲 Queue 时会看到
    }
}

LinkedList 的特点总结:

  • 适合频繁在头部或中间插入/删除的场景。
  • 随机访问较慢get(index) 需要遍历)。
  • 实现了 Deque 接口,可以作为栈或队列使用。
3.3 ArrayList vs LinkedList —— 怎么选?
场景推荐
经常通过索引访问元素ArrayList
经常在尾部添加/删除元素ArrayList
经常在头部或中间插入/删除元素LinkedList
需要作为队列或栈使用LinkedList(它实现了 Deque
遍历所有元素(不按索引)两者差不多

在实际开发中,ArrayList 的使用频率远高于 LinkedList,因为大多数场景下我们主要是遍历和尾部添加。除非你明确知道需要频繁在中间插入,否则默认选 ArrayList


4. Set 接口 —— 无序的、不可重复的“集合”

Set 的特点是:

  • 无序:元素的顺序不保证(具体取决于实现,HashSet 无序,TreeSet 按自然顺序排序,LinkedHashSet 按插入顺序)。
  • 不可重复:不允许包含重复的元素(即 equals() 返回 true 的两个元素不能同时存在)。

Set 就像数学中的集合概念:去重、不关心顺序。

最常用的 Set 实现有 HashSetLinkedHashSetTreeSet

4.1 HashSet —— 基于哈希表的集合

HashSet 内部使用 HashMap 来存储元素(键),它通过元素的 hashCode()equals() 来判断是否重复。

import java.util.HashSet;
import java.util.Set;

public class HashSetDemo {
    public static void main(String[] args) {
        Set<String> fruits = new HashSet<>();
        
        fruits.add("苹果");
        fruits.add("香蕉");
        fruits.add("橘子");
        fruits.add("苹果");   // 重复,不会添加
        
        System.out.println("大小:" + fruits.size());   // 3
        System.out.println("是否包含香蕉:" + fruits.contains("香蕉")); // true
        
        // 遍历顺序不保证(每次可能不同)
        for (String fruit : fruits) {
            System.out.println(fruit);
        }
        // 可能的输出:橘子、苹果、香蕉(顺序不定)
    }
}

HashSet 特点:

  • 基于哈希表,查找、添加、删除的平均时间复杂度为 O(1)。
  • 不保证顺序
  • 元素必须正确重写 hashCode()equals()(对于 JDK 类如 String 已经做好的)。
4.2 LinkedHashSet —— 保留插入顺序的 HashSet

LinkedHashSetHashSet 的子类,它在哈希表的基础上增加了一个链表来维护插入顺序。

Set<String> orderedSet = new LinkedHashSet<>();
orderedSet.add("苹果");
orderedSet.add("香蕉");
orderedSet.add("橘子");
// 遍历顺序永远是:苹果、香蕉、橘子(插入顺序)
4.3 TreeSet —— 基于红黑树的有序集合

TreeSet 内部使用红黑树(一种自平衡二叉搜索树)来存储元素,因此它会对元素进行自然排序(或通过自定义比较器排序)。

import java.util.Set;
import java.util.TreeSet;

public class TreeSetDemo {
    public static void main(String[] args) {
        Set<Integer> numbers = new TreeSet<>();
        numbers.add(5);
        numbers.add(1);
        numbers.add(10);
        numbers.add(3);
        
        // 遍历时会按照自然顺序(升序)输出
        for (int num : numbers) {
            System.out.print(num + " ");   // 1 3 5 10
        }
    }
}

TreeSet 特点:

  • 元素有序(默认自然升序,可通过 Comparator 定制)。
  • 基于红黑树,查找、添加、删除的时间复杂度为 O(log n)。
  • 元素必须实现 Comparable 接口(或者传入 Comparator)。
4.4 Set 的选择指南
需求推荐
只关心去重,不关心顺序HashSet
需要去重,且要保留插入顺序LinkedHashSet
需要去重,且要按某种顺序排序TreeSet

5. 集合的遍历 —— 三种方式

遍历集合中的元素是最高频的操作之一。Java 提供了三种主流方式:

① 增强 for 循环(最常用)
List<String> list = new ArrayList<>();
// ... 添加元素
for (String item : list) {
    System.out.println(item);
}

适用于所有实现了 Iterable 接口的集合(Collection 都实现了)。

② 普通 for 循环 + 索引(仅 List
for (int i = 0; i < list.size(); i++) {
    System.out.println(list.get(i));
}

适用于需要知道索引或修改元素的情况。

③ 迭代器(Iterator
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String item = it.next();
    System.out.println(item);
    // 可以在遍历时安全删除元素:it.remove()
}

迭代器是早期 Java 的通用遍历方式,现在增强 for 更简洁,但迭代器在需要删除元素时很有用。

④ JDK 8+ 的 forEach 方法(Lambda)
list.forEach(item -> System.out.println(item));
// 或使用方法引用
list.forEach(System.out::println);

这是函数式风格的遍历,我们会在 Lambda 和 Stream 那篇详细讲。


6. 集合中元素相等性的判断 —— equals()hashCode()

在使用 Listcontains()remove(Object o) 以及所有 Set 操作时,Java 会调用元素的 equals() 方法来判断两个元素是否相等。

对于自定义类,如果你希望在集合中去重或判断包含,必须重写 equals()hashCode()

public class Person {
    private String id;
    private String name;

    public Person(String id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return id.equals(person.id);   // 以 id 为唯一标识
    }

    @Override
    public int hashCode() {
        return id.hashCode();
    }

    // getter/setter...
}

如果没有重写,equals() 默认比较引用地址,两个内容相同的对象会被视为不同,Set 就会允许重复,contains() 也会返回 false

💡 对于 StringInteger 等 JDK 类,它们已经正确重写了 equals()hashCode(),所以可以直接使用。


7. JDK 21 的新特性:Sequenced Collections(有序集合)

在 JDK 21 中,引入了一个新的接口族:SequencedCollectionSequencedSetSequencedMap。它们为有序集合提供了统一的方法来访问“第一个”和“最后一个”元素。

在此之前,Listget(0)get(size()-1),但 Set(如 LinkedHashSet)没有直接的方法获取第一个或最后一个元素。JDK 21 统一了这些操作。

// 在 JDK 21 中,LinkedHashSet 实现了 SequencedSet
import java.util.LinkedHashSet;
import java.util.SequencedSet;

public class SequencedDemo {
    public static void main(String[] args) {
        SequencedSet<String> set = new LinkedHashSet<>();
        set.add("A");
        set.add("B");
        set.add("C");

        // 新方法:获取第一个和最后一个
        System.out.println(set.getFirst());   // A
        System.out.println(set.getLast());    // C

        // 反转顺序
        SequencedSet<String> reversed = set.reversed();
        for (String s : reversed) {
            System.out.print(s + " ");   // C B A
        }
    }
}

SequencedCollectionListDeque 和有序 Set 提供了统一的操作接口,让代码更一致、更清晰。


8. 综合示例 —— 在“学生管理系统”中使用集合

在上一篇文章的学生管理系统中,我们已经在用 List 了。现在让我们用 Set 来增强它:确保同一个学生不能重复选同一门课(我们之前用 enrolledCourses.contains(course) 检查,但 containsList 是 O(n) 遍历,如果我们用 Set 存选修课程,那 contains 就是 O(1) 了,而且自动去重)。

我们修改 Student 类中的 enrolledCoursesSet<Course>

// 之前用 List
// private List<Course> enrolledCourses = new ArrayList<>();

// 改为 Set(使用 HashSet,不关心顺序)
private Set<Course> enrolledCourses = new HashSet<>();

// 选课方法
public void enroll(Course course) throws DuplicateEnrollmentException {
    if (enrolledCourses.contains(course)) {
        throw new DuplicateEnrollmentException(studentId, course.getCourseId());
    }
    enrolledCourses.add(course);
    course.addStudent(this);
}

// 获取选修课程列表(返回不可修改的 Set,或复制一份)
public Set<Course> getEnrolledCourses() {
    return new HashSet<>(enrolledCourses);
}

这样改之后,contains 检查更快,而且 Set 保证了自动去重,即使不小心调用了两次 add,也不会重复添加。


9. 常见陷阱与注意事项

  • ArrayListremove(int index) vs remove(Object o):如果你传入的是一个 int,它会被当作索引;如果想删除值为某个整数的对象,需要传入 Integer.valueOf(num)
  • Set 的元素不可重复依赖于 equals()hashCode():自定义类必须正确重写这两个方法。
  • 遍历时修改集合:使用增强 for 遍历时,不能直接调用 remove(),否则会抛出 ConcurrentModificationException。可以使用迭代器的 remove() 或使用 removeIf() 方法(JDK 8+)。
  • TreeSet 要求元素可比较:如果元素没有实现 Comparable,构造 TreeSet 时需要传入 Comparator
  • 集合的初始容量ArrayListHashMap 等都有初始容量参数,如果你能预估元素数量,指定初始容量可以减少扩容开销,提升性能。

10. 今天的总结

今天我们初步接触了 Java 集合框架:

  • 集合框架 解决了数组长度固定、操作不便的问题,提供了丰富的数据结构。
  • List 有序、可重复,常用实现 ArrayList(随机访问快)和 LinkedList(插入删除快)。
  • Set 无序、不可重复,常用实现 HashSet(最快、无序)、LinkedHashSet(保留插入顺序)、TreeSet(自动排序)。
  • 遍历方式:增强 for、普通 for(仅 List)、迭代器、forEach(Lambda)。
  • 元素相等性依赖 equals()hashCode(),自定义类必须重写。
  • JDK 21 新增了 SequencedCollection 接口,统一了有序集合的 getFirst()getLast()reversed() 等操作。

集合框架的内容非常丰富,今天你先掌握 ListSet 的基本用法。下一篇文章,我们将重点讲 Map——它存储键值对,是另一种极其重要的集合类型。


动手试试

  1. 创建一个 ArrayList,添加 10 个随机整数,然后删除所有偶数(提示:用迭代器或 removeIf)。
  2. 创建一个 HashSet,添加一些字符串,观察遍历顺序是否与插入顺序一致。然后改用 LinkedHashSet,再观察遍历顺序。
  3. 创建一个 TreeSet,添加几个字符串,观察它们是否按字典序排序。然后传入一个 Comparator 实现降序排序。
  4. 定义一个 Book 类,包含 titleauthor,重写 equals()hashCode() 使得两本书只要标题和作者相同就视为相等。然后用 HashSet 存储几本书,看看重复的书是否能被正确去重。
  5. 修改学生管理系统的 Student 类,把 enrolledCourses 改为 Set<Course>,并修改相关方法,确保编译通过且功能正常。

集合是 Java 程序员的“日常工具”,多用多练,你会越来越熟练。我们下一篇见。😃

📌 获取本系列示例代码请访问 GitCode

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值