集合java怎么用?从List、Set到Map的基础示例

运营资讯17小时前发布 jdkjadmin
0 00
集合java怎么用?从List、Set到Map的基础示例

集合java怎么用?从List、Set到Map的基础示例

Java 集合用于在程序运行时保存和处理一组对象。和数组相比,集合通常能按需增删元素,并提供查找、排序、去重等操作。日常开发中最常用的三类是 List、Set 和 Map:需要有序保存一组元素时考虑 List,需要按相等规则去重时考虑 Set,需要通过键查找值时考虑 Map。

本文以 Java 8 及以上版本为例,说明它们的基本用法、选择依据和容易踩到的边界。示例中的代码可以复制到名为 CollectionBasics.java 的文件中编译运行。集合存放的是对象;使用泛型可以在编译时约束元素类型,减少强制类型转换和类型错误。

一、先认识集合框架

Java 集合框架主要由接口和实现类组成。List、Set 和 Queue 都是 Collection 接口体系中的常见类型;Map 则是独立的接口,不继承 Collection,因为它保存的是键值对,而不是单个元素。

  • List<E>:元素按位置排列,可以通过索引访问;通常允许重复。
  • Set<E>:不保存重复元素,但是否保留顺序取决于具体实现。
  • Map<K, V>:通过键关联值;键不能重复,值可以重复。

声明变量时,通常使用接口类型,创建对象时再选择具体实现。例如:

List<String> names = new ArrayList<>();

这样做让代码依赖于接口,后续可以在合适时换成其他实现。不过,不同实现的顺序、性能、可修改性和线程安全特征并不相同,不能只换类名就默认行为完全一样。

二、List:有顺序、可重复的一组元素

List 适用于有明确排列顺序,或需要根据下标访问元素的场景,例如购物车商品、页面结果、操作记录和待处理任务。最常用的实现是 ArrayList。它基于可扩容数组,按下标读取通常很快;在尾部添加元素通常也很高效,但在中间插入或删除可能需要移动后续元素。

1. 创建、添加和访问

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

public class ListExample {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();

        names.add("Lin");
        names.add("Maya");
        names.add("Lin"); // List 允许重复

        System.out.println(names);       // [Lin, Maya, Lin]
        System.out.println(names.get(1)); // Maya
        System.out.println(names.size()); // 3

        names.set(1, "Nora"); // 替换指定位置的元素
        names.remove(0);      // 删除下标为 0 的元素

        System.out.println(names); // [Nora, Lin]
    }
}

下标从 0 开始。对空 List 调用 get(0),或访问小于 0、等于或大于当前长度的下标,会抛出 IndexOutOfBoundsException。需要访问前先检查范围,或者使用遍历,而不是假定列表一定有元素。

remove 有一个容易混淆的重载:remove(int index) 按下标删除;remove(Object value) 按元素删除。对于 List<Integer>,下面的调用删除的是下标 1,而不是整数 1:

List<Integer> numbers = new ArrayList<>();
numbers.add(10);
numbers.add(20);
numbers.add(30);

numbers.remove(1); // 删除下标 1 的元素,即 20
numbers.remove(Integer.valueOf(30)); // 按值删除 30

另一个常见细节是 add 的返回值:List 的 add(E) 返回布尔值;按指定位置插入的 add(int, E) 则不返回布尔值。不要把集合方法的返回值一概理解成“是否添加成功”,具体应查阅对应接口的签名和实现约定。

2. ArrayList 和 LinkedList 怎么选

ArrayList 通常是通用 List 的默认选择,尤其是读取多、尾部追加多、需要下标访问的情况。LinkedList 是链表实现,也实现了 Deque,可以作为双端队列使用。它并不意味着任意位置的插入删除都更快:如果先要通过下标找到节点,查找本身仍需遍历;同时链表节点也有额外内存开销。

如果需求是队列或双端队列,优先考虑 Deque 接口和相应实现,例如 ArrayDeque;如果需求是普通的列表,通常先选 ArrayList,再根据测量结果和具体访问模式调整,而不是因为“插入很多”就直接认定链表更合适。

三、Set:按相等规则去重

Set 表示不包含重复元素的集合。它适用于去重、判断某个元素是否已出现,以及维护一批唯一标识等场景。常见实现包括:

  • HashSet:通常提供较快的添加、删除和查找;不保证迭代顺序。
  • LinkedHashSet:按插入顺序迭代。
  • TreeSet:按自然顺序或提供的比较器排序;元素需要能够比较,或创建时提供比较器。

HashSet 的去重依据不是“看起来相同”,而是对象的 equals 和 hashCode 合同。对于自定义类型,如果希望按业务字段判断重复,就需要正确实现这两个方法。

1. 基本去重示例

import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

public class SetExample {
    public static void main(String[] args) {
        List<String> input =
                Arrays.asList("red", "blue", "red", "green");

        Set<String> unique = new HashSet<>(input);
        System.out.println(unique); // 去重,但输出顺序不保证

        Set<String> inInputOrder = new LinkedHashSet<>(input);
        System.out.println(inInputOrder); // [red, blue, green]

        System.out.println(unique.contains("blue")); // true
        System.out.println(unique.add("blue"));      // false,已存在
    }
}

如果既要去重又要保留首次出现的顺序,LinkedHashSet 比 HashSet 更合适。若需要排序,选择 TreeSet,并确认排序规则符合业务要求。排序和插入顺序是不同语义,不应混为一谈。

2. 自定义元素与 equals/hashCode

下面的 Person 将姓名作为身份字段:两个姓名相同的对象会被 Set 视为重复。实际业务中,“相等”的字段应该由业务规则决定,不要机械地把所有字段都纳入判断。

import java.util.Objects;

final class Person {
    private final String name;

    Person(String name) {
        this.name = name;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }
        if (!(other instanceof Person)) {
            return false;
        }
        Person person = (Person) other;
        return Objects.equals(name, person.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }
}

如果两个对象的 equals 返回 true,它们的 hashCode 必须相同;反过来,哈希值相同不代表对象一定相等。只重写 equals 而不重写 hashCode,会导致哈希集合的查找或去重行为不符合预期。

还有一个重要边界:对象放入 HashSet 后,不要修改参与 equals 或 hashCode 计算的字段。字段变化后,对象所在的哈希位置可能与新哈希值不匹配,导致 contains 或 remove 看起来失效。常见做法是使用不可变的键字段,或者在修改相关字段前先移除对象,修改后再重新加入。

四、Map:用键查找对应的值

Map 适用于需要建立“标识到数据”关系的场景,例如用户 ID 到用户资料、单词到出现次数、配置项名称到配置值。常见实现有:

  • HashMap:通用哈希映射,不保证迭代顺序。
  • LinkedHashMap:按插入顺序迭代;也可以配置为访问顺序。
  • TreeMap:按键的自然顺序或比较器顺序排列。

Map 的键唯一。对已存在的键再次调用 put 会替换该键原来的值;值则可以重复。以下是基本示例:

import java.util.HashMap;
import java.util.Map;

public class MapExample {
    public static void main(String[] args) {
        Map<String, Integer> scores = new HashMap<>();

        scores.put("A-01", 86);
        scores.put("A-02", 92);
        scores.put("A-01", 90); // 覆盖 A-01 原来的值

        System.out.println(scores.get("A-01")); // 90
        System.out.println(scores.containsKey("A-02")); // true
        System.out.println(scores.get("missing")); // null

        Integer oldValue = scores.remove("A-02");
        System.out.println(oldValue); // 92
    }
}

get 返回 null 时,可能表示键不存在,也可能表示键存在但映射到 null(具体实现是否允许空值有所不同)。如果业务需要区分这两种情况,应使用 containsKey 判断键是否存在。对 HashMap 来说,它允许一个 null 键和多个 null 值;但 TreeMap 的键比较方式、以及其他 Map 实现的限制不同,不能把某一实现的空值规则推广到所有 Map。

1. 遍历 Map

需要同时处理键和值时,通常遍历 entrySet:

Map<String, Integer> scores = new HashMap<>();
scores.put("A-01", 90);
scores.put("A-02", 92);

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

如果只需要键,可以遍历 keySet;只需要值,可以遍历 values。不要在循环中对每个键重复调用 get 来取值,因为 entrySet 已经提供了键和值。

2. 统计词频:Map 的常见用途

Map 常用于计数。Java 8 的 getOrDefault 可为不存在的键提供默认值:

import java.util.HashMap;
import java.util.Map;

public class WordCountExample {
    public static void main(String[] args) {
        String[] words = {"java", "list", "java", "map"};

        Map<String, Integer> counts = new HashMap<>();
        for (String word : words) {
            counts.put(word, counts.getOrDefault(word, 0) + 1);
        }

        System.out.println(counts);
    }
}

也可以使用 merge 表达计数更新:

counts.merge(word, 1, Integer::sum);

这些写法适合单线程或由外部正确同步的情形。普通 HashMap 并不是线程安全容器;多个线程同时更新时,不能仅凭一行看起来简短的代码就认为操作具备原子性。需要并发计数时,应根据需求使用 ConcurrentHashMap 的并发操作(例如 merge),或采用其他正确的同步方案。

五、遍历集合和删除元素的边界

增强 for 循环适合读取集合,但通常不适合在遍历过程中直接对同一个集合执行结构性修改,例如添加或删除元素。对许多常见集合,这样做可能抛出 ConcurrentModificationException。这个异常属于快速失败检测,不能当作线程安全机制,也不保证所有并发修改都会以相同方式被发现。

需要在遍历时删除元素,可以使用迭代器的 remove:

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

List<String> names = new ArrayList<>();
names.add("Lin");
names.add("Maya");
names.add("Leo");

Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
    String name = iterator.next();
    if (name.startsWith("L")) {
        iterator.remove();
    }
}

不要在迭代器遍历过程中直接调用 names.remove(...)。若只是按条件移除元素,Java 8 也可以使用 removeIf:

names.removeIf(name -> name.startsWith("L"));

结构性修改通常指会改变集合元素数量或组织结构的操作。替换 List 中某个位置的值通常不属于结构性修改,但细节依实现而异;不要依赖某个实现未抛异常的偶然行为来设计并发逻辑。

六、可运行的综合示例

下面的完整程序把 List 去重、Set 去重、Map 计数放在一起。代码兼容 Java 8,可保存为 CollectionBasics.java,使用 javac CollectionBasics.java 编译,再使用 java CollectionBasics 运行。

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

public class CollectionBasics {
    public static void main(String[] args) {
        List<String> words = new ArrayList<>();
        words.add("java");
        words.add("list");
        words.add("java");
        words.add("map");

        System.out.println("原始列表: " + words);

        // LinkedHashSet 去重,同时保留首次出现的顺序
        Set<String> uniqueWords = new LinkedHashSet<>(words);
        System.out.println("去重结果: " + uniqueWords);

        // Map 统计每个单词出现的次数
        Map<String, Integer> counts = new HashMap<>();
        for (String word : words) {
            counts.put(word, counts.getOrDefault(word, 0) + 1);
        }

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

输出中的 HashMap 遍历顺序不保证固定,因此词频部分的行顺序可能不同。若输出必须按插入顺序排列,可换成 LinkedHashMap;若需要按键排序,可使用 TreeMap。应根据需求选择顺序语义,而不是依赖一次运行观察到的顺序。

七、初始化、不可修改集合与视图

Java 8 常见的快速初始化方式包括 Arrays.asList:

import java.util.Arrays;
import java.util.List;

List<String> fixedSize = Arrays.asList("a", "b");

这个 List 与数组关联,长度固定:可以通过 set 替换已有位置的元素,但不能正常增删元素;调用 add 或 remove 会抛出 UnsupportedOperationException。如果需要可增删的副本,可以这样创建:

List<String> editable =
        new ArrayList<>(Arrays.asList("a", "b"));
editable.add("c");

Collections.unmodifiableList(list) 返回的是不可通过该包装对象修改的视图,不等同于深度不可变。如果原始 List 仍被其他代码持有并修改,视图中也能观察到变化。Java 9 起可使用 List.of、Set.of、Map.of 创建不可修改集合;这些工厂方法不允许 null 元素、键或值,Set 和 Map 的重复元素或重复键也会导致创建失败。它们不适用于 Java 8,使用前要确认项目的 Java 版本。

八、线程安全:集合默认不等于并发容器

ArrayList、HashSet 和 HashMap 都不是面向多线程并发读写的同步容器。若多个线程访问同一集合且至少有线程修改它,需要设计正确的同步策略。简单地把一个集合声明为 final,只代表引用不能重新赋值,不代表集合内容线程安全。

可以根据场景考虑:

  • Collections.synchronizedList、Collections.synchronizedMap 等同步包装。遍历时仍需按文档要求对返回的包装对象加锁,避免遍历期间的并发修改。
  • ConcurrentHashMap 等并发集合,适合相应的并发访问需求;它不允许 null 键或 null 值。
  • 不可变或不再修改的集合,适合初始化完成后安全共享的设计,但要确认其中元素本身也不会被不安全地修改。

“线程安全”并不自动意味着一组多步操作是原子的。例如先判断键是否存在,再添加键值,两个独立步骤之间仍可能被其他线程插入操作。优先使用集合提供的原子复合方法,或者在整个业务操作范围内使用一致的同步策略。

九、如何按场景选择

需求通常考虑需要留意
保留输入顺序、允许重复、经常按下标读取ArrayList下标越界;中间插删可能需要移动元素
去重,不要求顺序HashSet迭代顺序不保证;自定义类型要正确实现 equals/hashCode
去重并保留插入顺序LinkedHashSet顺序是插入顺序,不是排序顺序
通过键快速关联值HashMap键唯一;重复 put 会覆盖旧值;迭代顺序不保证
按键排序或范围查找TreeMap、TreeSet比较规则必须稳定并符合业务含义
多线程并发更新映射ConcurrentHashMap 等确认空值限制及操作的原子性边界

性能比较应以操作和数据规模为依据。哈希集合的常见操作通常具有较好的平均性能,但这不是所有情况下的绝对保证;树结构会按比较顺序组织数据;链表、数组各有不同的访问和更新成本。对性能敏感的代码,应在代表性数据和真实操作模式下测量,而不是只凭类名推断。

十、常见错误与安全注意事项

  1. 混淆集合与实现类:变量声明可以使用 List、Set、Map 接口,但具体行为要看创建时使用的实现类。不要依赖接口未承诺的迭代顺序。
  2. 忘记泛型:原始类型如 List list 会绕过编译期类型检查,可能在运行时产生 ClassCastException。新代码应使用明确的泛型类型。
  3. 忽略 null 和空集合:不同集合允许的 null 不同。调用 get(0) 前确认列表非空,处理 Map 查找结果时考虑键不存在的情况。
  4. 把重复判断当成引用判断:Set 和 Map 的行为依赖相等性规则;用 == 比较对象引用与用 equals 判断逻辑相等不是一回事。
  5. 遍历时直接修改集合:优先用迭代器删除或 removeIf。快速失败异常不是同步方案。
  6. 把集合视图当成副本:Arrays.asList、subList、不可修改包装等对象可能与原集合或数组共享数据。需要独立可修改副本时,显式复制到新的实现中。

集合本身只是内存中的数据结构,不会自动验证业务权限、过滤恶意输入,也不会提供持久化、加密或访问控制。涉及用户输入时,应在适当的业务边界校验数据;涉及权限判断时,不要把客户端传来的 Map 或 Set 当成可信授权依据;涉及敏感信息时,也不要因为数据放进集合就认为它已得到保护。普通集合通常适合进程内数据组织,不应被误用为安全边界或并发控制机制。

结语

掌握 Java 集合,先根据数据关系选类型:按位置保存用 List,按唯一性组织用 Set,按键查值用 Map。然后确认具体实现带来的顺序、可修改性、空值和并发语义。遇到自定义对象去重或作为 Map 键时,重点检查 equals 与 hashCode;遍历中要删除元素时,使用迭代器或 removeIf;并发访问则选用合适的并发容器或同步方案。把这些边界弄清楚,集合代码就不只是“能运行”,也更容易维护和验证。

© 版权声明

相关文章