免费Java教程里的集合框架:List、Set和Map入门

教程9小时前发布 jdkjadmin
0 00
免费Java教程里的集合框架:List、Set和Map入门

免费Java教程里的集合框架:List、Set和Map入门

Java 集合框架用于组织和操作一组对象。与数组相比,许多集合可以动态增删元素,并提供查找、遍历、排序等常用能力。初学者最先接触的通常是 List、Set 和 Map:List 保存有顺序的元素序列,Set 表示不重复元素,Map 保存键值映射。

理解它们不能只记住接口名称,还要看清几个实际问题:是否允许重复、是否保留顺序、怎样判断两个元素相同、是否允许空值,以及多个线程同时访问时会发生什么。本文使用 Java 8 及以上版本的语法,示例可保存为 CollectionIntro.java 后编译运行。

一、先认识接口与实现类

List、Set 和 Map 是接口,描述一类操作;程序通常通过具体实现类创建对象。例如,ArrayList 实现 List,HashSet 实现 Set,HashMap 实现 Map。声明变量时优先使用接口类型,方便以后更换实现:

List<String> names = new ArrayList<>();
Set<String> tags = new HashSet<>();
Map<String, Integer> scores = new HashMap<>();

集合通常保存对象,而不是基本类型。需要数字时使用包装类型,如 Integer、Double;Java 的自动装箱和拆箱会在基本类型与包装类型之间转换。泛型中的类型参数在编译期提供类型检查,例如 List<String> 不接受普通的整数值,避免了大量强制类型转换。

二、List:有位置的元素序列

List 适合需要保留元素排列、允许重复,或者要按位置访问元素的场景,例如购物车条目、搜索结果、待办事项。List 的位置从 0 开始,常见操作包括 add、get、set 和 remove。

List<String> tasks = new ArrayList<>();
tasks.add("检查邮件");
tasks.add("提交报告");
tasks.add("检查邮件"); // List 允许重复

System.out.println(tasks.get(0)); // 检查邮件
tasks.set(1, "提交周报");         // 替换指定位置的元素
tasks.remove(0);                 // 删除下标为 0 的元素

ArrayList 是常见的默认选择。它基于数组存储,按下标读取通常很快;在列表中间插入或删除元素时,后续元素可能需要移动。另一种实现 LinkedList 基于链表,同时实现了 List 和 Deque。它支持队列、双端队列相关操作,但并不意味着所有插入删除都更快:按下标访问仍需要沿链表查找,实际性能取决于访问方式和数据规模。没有明确测量结果时,不必仅凭“链表插入快”就改用 LinkedList。

遍历与删除

只读遍历可以使用增强型 for 循环,或者迭代器:

for (String task : tasks) {
    System.out.println(task);
}

不要在增强型 for 循环中直接对同一个普通 List 调用 remove 来删除正在遍历的元素。这可能抛出 ConcurrentModificationException。更稳妥的写法是用迭代器的 remove,或使用 Java 8 的 removeIf:

tasks.removeIf(task -> task.startsWith("检查"));

注意,ConcurrentModificationException 中的“Concurrent”不表示集合因此变成线程安全。它是对某些结构性修改的快速失败检测,不能作为并发同步机制,也不保证每种并发修改都能被检测到。

需要知道列表长度时使用 size(),有效下标范围是 0 到 size() - 1。对越界下标调用 get、set 或按位置删除,会抛出 IndexOutOfBoundsException。空列表的长度是 0,此时没有有效下标。不要把 List 的下标和元素值混为一谈。

三、Set:只保留不重复的元素

Set 适合记录唯一成员,例如已经处理过的订单编号、用户拥有的标签,或从输入数据中去重。常用实现各有不同:

  • HashSet:通常用于快速判断元素是否存在;不承诺迭代顺序。
  • LinkedHashSet:保留元素的插入顺序。
  • TreeSet:按自然顺序或指定比较器排序;元素必须能按相应规则比较。

Set 的“不重复”依赖元素相等规则。对普通对象,HashSet 依靠 equals 和 hashCode 判断相等:相等的对象必须有相同的哈希值;哈希值相同的对象仍可能不相等,集合会进一步判断。若自定义类只重写 equals 却没有按规则重写 hashCode,基于哈希的集合就可能无法按预期去重。

Set<String> cities = new HashSet<>();
cities.add("杭州");
cities.add("北京");
cities.add("杭州");

System.out.println(cities.size()); // 2
System.out.println(cities.contains("北京")); // true

HashSet 不承诺元素打印或遍历的先后顺序;即使某次运行看起来有固定顺序,也不要依赖它。要按添加顺序遍历,可以使用 LinkedHashSet;要排序,可以使用 TreeSet。TreeSet 的比较规则应当一致、稳定。按比较器判断为相同的元素不会同时保留,即使它们的 equals 结果不相等,也可能与其他基于 equals 的判断产生不同结果。

另一个容易忽略的边界是:放入 HashSet 后,如果对象参与 equals 或 hashCode 的字段发生改变,集合之后可能无法按预期找到或删除它。把对象作为集合元素时,最好让用于相等判断的字段保持不变;或者使用不可变对象、稳定的标识值。

四、Map:通过键查找值

Map 保存键和值的对应关系,适合按标识查数据,例如商品编号到商品信息、用户名到用户资料、单词到出现次数。键必须唯一;同一个键再次放入新值会替换旧值,而不是增加第二条同键记录。值可以重复。

Map<String, Integer> inventory = new HashMap<>();
inventory.put("笔记本", 12);
inventory.put("钢笔", 30);
inventory.put("笔记本", 15); // 替换原来的 12

System.out.println(inventory.get("笔记本")); // 15

使用 get(key) 查询时,如果键不存在会得到 null。但 HashMap 允许一个空键,也允许空值,因此仅凭 get 返回 null,有时不能区分“键不存在”和“键存在但值就是 null”。可以调用 containsKey 判断键是否存在,或者在业务设计中约定不存储 null 值。

if (inventory.containsKey("钢笔")) {
    Integer count = inventory.get("钢笔");
    System.out.println(count);
}

常用遍历方式是遍历 entrySet(),一次取得键和值:

for (Map.Entry<String, Integer> entry : inventory.entrySet()) {
    System.out.println(entry.getKey() + ": " + entry.getValue());
}

HashMap 不保证遍历顺序。若需要保留插入顺序,可用 LinkedHashMap;若需要按键排序,可用 TreeMap。TreeMap 的键需要能按自然顺序比较,或在创建时提供比较器。不同实现对空键、空值和顺序的限制可能不同,不能把某个实现的行为当成所有 Map 的统一承诺。

键的相等规则

HashMap 判断键是否是同一个键,依赖哈希值和 equals。自定义键类应正确重写 equals 与 hashCode,并尽量保持作为键期间相关字段不变,否则可能出现已经放入、却无法按原键查出的情况。用字符串、整数等常见不可变类型作键,通常较省心。

Map 的键唯一指的是按其实现采用的相等规则唯一。HashMap 通常依据 equals;TreeMap 则依据比较器或自然顺序。若比较结果为 0,TreeMap 会把新键视为已有键并替换对应值。因此,比较规则应与业务上的键身份定义一致。

五、用 Map 统计词频

下面是一个完整的小例子:把文本按空白字符拆分,并统计每个单词出现次数。它展示了 List、Set 和 Map 的常见协作方式,也能直接运行。这里的拆分规则只是演示;真实文本若需要处理标点、大小写或中文分词,应另行定义规范。

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class CollectionIntro {
    public static void main(String[] args) {
        String text = "java list map java set list";

        List<String> words = new ArrayList<>();
        for (String word : text.split("\\\\s+")) {
            words.add(word);
        }

        Set<String> uniqueWords = new HashSet<>();
        Map<String, Integer> counts = new HashMap<>();

        for (String word : words) {
            uniqueWords.add(word);
            counts.put(word, counts.getOrDefault(word, 0) + 1);
        }

        System.out.println("原始词数: " + words.size());
        System.out.println("不同单词数: " + uniqueWords.size());
        System.out.println("词频: " + counts);
    }
}

将代码保存为 CollectionIntro.java,在安装了 JDK 的环境中运行:

javac CollectionIntro.java
java CollectionIntro

示例中的 getOrDefault(key, defaultValue) 在键不存在时返回默认值,不会因此自动把默认值写入 Map;随后 put 才保存更新后的计数。输出 Map 时,键值对的顺序未必固定,因此不要用一次控制台输出的顺序来判断统计结果是否正确。

六、集合参数、容量与常见误区

集合通常可以增长,但仍受运行环境可用内存、JVM 实现和数组索引等实际限制,不存在“无限容量”。构造函数中的初始容量只是预分配或扩容策略相关的参数,不是集合允许存放元素的精确上限。对 HashMap 等哈希集合,负的初始容量属于非法参数,会导致 IllegalArgumentException;具体容量参数应依据目标类的 API 文档使用。

如果事先知道大致数据量,可以考虑合适的初始容量以减少扩容,但不必为小型集合过度优化。也不要把用户提供的数量未经验证就直接作为巨大预分配容量,否则可能带来内存浪费甚至内存分配失败。需要处理大量输入时,应设置合理的业务边界,并考虑分批处理,而不是只依赖集合自动增长。

还有一种常见混淆来自 Arrays.asList:

List<String> fixedSize = Arrays.asList("A", "B");
fixedSize.set(0, "X"); // 可以替换元素
// fixedSize.add("C"); // 不支持增加元素,会抛出 UnsupportedOperationException

List<String> resizable = new ArrayList<>(fixedSize);
resizable.add("C");

Arrays.asList 返回由给定数组支持的定长列表,不等于可自由增删的 ArrayList;改动元素会与底层数组相互影响。若需要普通可变列表,可将结果复制到 new ArrayList<>(...) 中。某些工厂方法返回的不可变集合也不支持增删,调用修改操作会抛出 UnsupportedOperationException,使用前应了解返回对象的契约。

删除整数列表中的元素也容易踩坑。List<Integer> 同时有按下标删除和按对象删除的重载:

List<Integer> numbers = new ArrayList<>(
        Arrays.asList(10, 20, 30));
numbers.remove(1);                   // 删除下标 1 的元素,即 20
numbers.remove(Integer.valueOf(30)); // 按值删除 30

remove(1) 选择的是按下标删除,不是删除值为 1 的元素。写代码时应确认参数类型和方法重载,必要时用 Integer.valueOf 明确表达按值删除。

七、线程安全与数据安全

ArrayList、HashSet 和 HashMap 等常见实现不是为多个线程无保护地并发修改而设计的。若多个线程共享集合并进行读写,需要根据场景使用同步、并发集合(如 ConcurrentHashMap)或其他并发控制方式。仅把引用声明为 final,只表示引用不能重新指向另一个对象,不会使集合内容自动线程安全。

Collections.synchronizedList 等包装方法可以为集合操作提供同步访问,但多步复合操作仍需谨慎;遍历同步包装集合时,通常也要按文档要求在同步锁内迭代。并发集合提供的保证也各不相同,不能笼统地理解为“所有操作组成一个不可分割事务”。应根据具体 API 文档确认其并发语义。

集合本身不是安全边界。不能因为数据存进 Set 就认为输入可信,也不能把 List、Map 的访问控制、参数校验或权限判断省略。若集合被用于缓存会话、权限映射或请求数据,仍要检查输入来源、生命周期、并发访问和敏感数据暴露风险;对外输出时也应按具体上下文进行编码或过滤。

八、如何快速选择

  • 需要保留元素序列、允许重复,并按位置访问:优先考虑 ArrayList。
  • 只关心元素是否存在或需要去重,不依赖顺序:考虑 HashSet。
  • 需要去重且保留添加顺序:考虑 LinkedHashSet。
  • 需要唯一键到值的映射,且不依赖遍历顺序:考虑 HashMap。
  • 需要按键或元素排序:考虑 TreeMap 或 TreeSet,并明确比较规则。
  • 多个线程会并发访问:先确定读写模式,再选择并发集合或同步策略,而不是直接沿用普通集合。

初学阶段可以先掌握三个核心判断:List 关注位置和重复,Set 关注成员唯一性,Map 关注键到值的对应关系。随后再检查所选实现的顺序、空值、相等规则和并发约定。把这些边界弄清楚,比背住更多类名更重要;实际项目中也应结合数据规模、访问模式和 API 文档做选择。

© 版权声明

相关文章