Java核心中的集合框架:常用接口与实现类详解

最新新闻13小时前发布 jdkjadmin
0 00
Java核心中的集合框架:常用接口与实现类详解

Java核心中的集合框架:常用接口与实现类详解

Java 集合框架提供了一组用于存放、查找和处理对象的接口与实现类。选型时,不能只记住类名,还要考虑是否需要保持顺序、是否允许重复、是否需要排序、是否存在并发访问,以及集合是否可以被修改。

本文以 Java 8 及以上版本为背景介绍常用类型;涉及 List.of、Map.of 等工厂方法时,要求 Java 9 或更高版本。集合框架中没有一个实现能同时满足所有需求,合适的选择取决于数据规模、访问方式和并发模型。

一、先理解集合框架的主要接口

集合类大体分为两支:Collection 表示一组元素,Map 表示键值映射。Map 不继承 Collection,因为它存储的是键和值的对应关系,而不是单纯的元素序列。

  • List<E>:有序序列,允许重复元素,可通过下标访问。
  • Set<E>:不允许重复元素;不同实现对遍历顺序的保证不同。
  • Queue<E>:通常用于排队处理元素,提供插入、查看和移除队首等操作。
  • Deque<E>:双端队列,可以在两端插入和移除,也可以用作栈。
  • Map<K,V>:通过键查询值,键不能重复;值是否允许重复由具体实现决定。

这些接口定义操作能力,不承诺某个固定的性能。例如,List 既可以由支持快速随机访问的数组实现,也可以由链表实现。写代码时,变量类型通常声明为接口,具体创建哪种实现再根据需求决定。

二、List:有序、可重复的元素序列

1. ArrayList:大多数场景的默认选择

ArrayList 基于动态数组实现,适合按下标读取、遍历以及在尾部追加元素。按下标读取通常为 O(1);在中间插入或删除时,后续元素需要移动,成本与元素数量相关。尾部追加通常很快,但扩容时可能需要分配新数组并复制数据。

容量和元素个数不是一回事。构造函数中的初始容量用于减少扩容,并不代表集合已经包含对应数量的元素。

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

public class ArrayListExample {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>(16);
        names.add("安");
        names.add("博");
        names.add("安");

        System.out.println(names.get(0)); // 安
        System.out.println(names.size()); // 3,初始容量不是元素个数

        names.remove("安"); // 删除第一个匹配元素
        System.out.println(names); // [博, 安]
    }
}

如果程序已知数据量的大致范围,可以传入合适的初始容量,减少扩容;不要把一个未经验证的外部输入直接当作巨大容量。集合的容量会受 JVM 可用内存、数组大小限制等因素影响,不能把容量参数理解成可靠的资源保障。

2. LinkedList:双端队列用途通常比随机访问更合适

LinkedList 是双向链表,同时实现了 List 和 Deque。从首尾插入、删除可以很方便,但按下标访问需要沿链表查找,通常为 O(n)。与数组列表相比,链表还需要为节点保存额外引用,内存开销往往更高。

因此,不能简单认为“频繁增删就选 LinkedList”。如果操作位置需要先通过下标或查找确定,定位成本仍然存在;许多实际场景下,数组列表的缓存局部性也更有利。需要双端操作时,优先从 Deque 接口的使用方式出发,再根据测量结果选择实现。

3. 固定大小视图与不可修改列表不是一回事

Arrays.asList(array) 返回由数组支撑的定长列表。可以替换已有位置的值,但不能添加或删除元素;修改列表元素也会反映到原数组中。List.of(Java 9 起)返回不可修改列表,不能增删改,并且不接受 null 元素。

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

public class ListFactoriesExample {
    public static void main(String[] args) {
        String[] array = {"a", "b"};
        List<String> fixed = Arrays.asList(array);
        fixed.set(0, "x");
        System.out.println(array[0]); // x
        // fixed.add("c"); // UnsupportedOperationException

        List<String> immutable = List.of("a", "b");
        // immutable.set(0, "x"); // UnsupportedOperationException

        List<String> mutableCopy = new ArrayList<>(fixed);
        mutableCopy.add("c");
        System.out.println(mutableCopy);
    }
}

三、Set:唯一性与顺序

1. HashSet:只关心去重时的常见选择

HashSet 基于哈希表实现,通常适合快速判断元素是否存在、去重等场景。它不保证遍历顺序。对象是否被视为重复,取决于 equals 和 hashCode:如果重写了其中一个,通常必须同时按一致规则重写另一个。

元素加入集合后,如果修改了影响 equals 或 hashCode 的字段,集合可能无法按预期找到或删除该元素。因此,应避免把可变对象作为哈希集合中的元素,或者至少保证其判等相关状态在集合中保持不变。

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

public class SetExample {
    public static void main(String[] args) {
        Set<String> tags = new HashSet<>();
        tags.add("java");
        tags.add("java");
        tags.add("collections");

        System.out.println(tags.size()); // 2
        System.out.println(tags.contains("java")); // true
        // 不要依赖遍历输出顺序
    }
}

2. LinkedHashSet 与 TreeSet

LinkedHashSet 在保留元素唯一性的同时,按插入顺序遍历,适合需要稳定展示顺序的去重场景。它会为顺序维护额外结构,因此不要因为“顺序更好看”就无条件替换 HashSet。

TreeSet 按自然顺序或指定比较器排序,常见操作如查找、插入和删除通常为 O(log n)。元素必须能够比较,或在创建集合时提供兼容的 Comparator。比较器若把两个不同对象比较为 0,TreeSet 会将它们视为同一个排序键,后加入的元素不会作为独立元素保留。比较规则最好与 equals 一致,否则集合的行为可能令人意外。

四、Map:键值映射的常用实现

1. HashMap:通用键值存储

HashMap 适合不要求遍历顺序的键值查询。通常情况下,get、put 等操作的平均成本接近 O(1),但性能取决于哈希分布、容量和负载等因素,不能把平均复杂度当作对所有输入的保证。

HashMap 允许一个 null 键和多个 null 值。键的判等依赖 equals 与 hashCode。与哈希集合一样,不应在键加入后修改影响哈希值或相等性判断的状态。

2. LinkedHashMap 与 TreeMap

LinkedHashMap 默认按插入顺序遍历,也可以配置为按访问顺序维护条目,常用于构建简单的有界缓存策略。它本身不会自动限制大小,也不是线程安全缓存;移除最旧条目等行为需要通过合适的构造方式和 removeEldestEntry 等机制实现,并处理好并发与缓存失效问题。

TreeMap 按键的自然顺序或比较器排序,适合需要有序遍历、范围查询、最近键查询等场景。键需要可比较,或提供适用的比较器。排序比较为 0 的两个键会被视为同一个映射位置,即使它们的 equals 返回 false 也如此。TreeMap 不允许 null 键;在自然排序下,插入空键会失败。是否允许空值与此不同:TreeMap 通常允许空值。

import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;

public class MapExample {
    public static void main(String[] args) {
        Map<String, Integer> counts = new HashMap<>();
        counts.put("java", 2);
        counts.merge("java", 1, Integer::sum);
        System.out.println(counts.get("java")); // 3

        Map<String, Integer> ordered = new LinkedHashMap<>();
        ordered.put("second", 2);
        ordered.put("first", 1);
        System.out.println(ordered.keySet()); // [second, first]

        Map<String, Integer> sorted = new TreeMap<>();
        sorted.put("b", 2);
        sorted.put("a", 1);
        System.out.println(sorted.keySet()); // [a, b]
    }
}

Map.get(key) 返回 null 时,可能表示键不存在,也可能表示键存在但对应的值为 null。在允许空值的映射中,需要用 containsKey 区分;如果业务不需要空值,约定不存储 null 可以减少歧义。

Java 8 起,getOrDefault、putIfAbsent 和 merge 等方法可以简化常见逻辑,但它们不等于任意实现上的通用并发事务。并发需求应使用明确的并发集合与适当的原子操作。

五、Queue 与 Deque:排队、栈和优先级处理

1. ArrayDeque:单线程队列或栈的常见选择

ArrayDeque 是基于数组的双端队列,通常适合在两端进行插入和删除。它不允许存入 null。与旧式 Stack 类相比,若只是需要栈操作,使用 Deque 接口通常更合适;使用 push、pop 可表达栈语义,使用 addLast、removeFirst 可表达队列语义。ArrayDeque 不是线程安全实现。

2. PriorityQueue:按优先级取出,而不是保持整体排序

PriorityQueue 会根据自然顺序或比较器优先取出最小(或按自定义规则优先级最高)的元素,但迭代器遍历不保证按优先级排序。若需要排序后的完整列表,应复制到列表并排序,而不是依赖队列的迭代顺序。优先级相同的元素,取出次序没有额外稳定性保证。它也不允许 null。

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.PriorityQueue;
import java.util.Queue;

public class QueueExample {
    public static void main(String[] args) {
        Deque<String> stack = new ArrayDeque<>();
        stack.push("first");
        stack.push("second");
        System.out.println(stack.pop()); // second

        Queue<Integer> priorities = new PriorityQueue<>();
        priorities.add(8);
        priorities.add(3);
        priorities.add(5);
        System.out.println(priorities.poll()); // 3
    }
}

队列接口中有两组相近操作:add/offer 用于插入,remove/poll 用于移除队首,element/peek 用于查看队首。差别在于队列无法接受操作时的处理方式:前一组通常抛出异常,后一组返回特殊值(插入失败时为 false,获取或移除空队列时为 null)。具体实现是否会遇到容量限制,要看该实现和构造方式。

六、不可修改集合、视图与防御性复制

Collections.unmodifiableList(list) 这类方法返回的是不可通过该视图修改的包装对象,不是底层数据的独立快照。如果其他代码仍持有原列表并修改它,包装视图观察到的内容也会变化。它防止的是经由该包装引用进行修改,不是访问控制或安全边界。

List.of、Set.of、Map.of(Java 9 起)创建的集合不可修改,且不接受 null。Set.of 中有重复元素会抛出异常;Map.of 中有重复键也会抛出异常。Map.of 最多接受 10 对键值;需要创建更多条目时,可使用 Map.ofEntries 或构造可变映射。不可修改集合只限制集合结构,不会自动让其中的可变对象变成不可变对象。

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

public class DefensiveCopyExample {
    public static void main(String[] args) {
        List<String> source = new ArrayList<>();
        source.add("original");

        List<String> view = Collections.unmodifiableList(source);
        List<String> copy = Collections.unmodifiableList(
                new ArrayList<>(source));

        source.add("later");
        System.out.println(view); // [original, later]
        System.out.println(copy); // [original]

        // view.add("x"); // UnsupportedOperationException
    }
}

如果类要保存调用方传入的集合,且不希望调用方事后影响内部状态,可以复制输入集合;若还要防止调用方通过 getter 修改内部集合,应返回不可修改视图或再次返回副本。复制只是浅复制:集合中的对象引用仍然指向同一对象。若元素本身可变,需要结合对象设计决定是否也要复制元素。

七、迭代与并发:不要把“能运行”误认为“安全”

许多普通集合在迭代期间发生结构性修改时,可能抛出 ConcurrentModificationException。这类快速失败行为主要用于尽早发现错误,不是线程同步机制,也不保证所有并发修改都一定能被检测到。需要边遍历边删除时,可使用迭代器自己的 remove(若该迭代器支持),或先收集要删除的元素,再统一处理。

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

public class IteratorRemoveExample {
    public static void main(String[] args) {
        List<Integer> values =
                new ArrayList<>(List.of(1, 2, 3, 4));

        Iterator<Integer> iterator = values.iterator();
        while (iterator.hasNext()) {
            if (iterator.next() % 2 == 0) {
                iterator.remove();
            }
        }
        System.out.println(values); // [1, 3]
    }
}

常见并发选项包括:

  • ConcurrentHashMap:支持并发访问,不能存储 null 键或值。复合业务操作仍要选择合适的原子方法,不能把多次独立调用当成一个事务。
  • CopyOnWriteArrayList:写操作会复制底层数组,适合读多写少且迭代期间希望看到稳定快照的场景;写入成本和内存开销较高。
  • Collections.synchronizedList 等包装:对单个方法调用提供同步访问,但迭代时仍应按 API 要求在包装对象上同步整个遍历过程。

并发集合解决的是特定访问模式下的线程安全问题,不代表业务层面的复合操作自动原子化,也不提供身份验证、授权、输入校验或持久化一致性。

八、常见错误与边界检查

  1. 把集合大小和索引上限混淆。下标从 0 开始,有效索引范围是 0 到 size() - 1;空列表没有有效下标。
  2. 用容量代替初始化元素。new ArrayList<>(100) 创建的是空列表,不是 100 个元素的列表;此时 size() 仍为 0。
  3. 以为数组转换一定得到可增删列表。Arrays.asList 是定长视图,需要可增删列表时,复制到 ArrayList。
  4. 只重写 equals,不重写 hashCode。哈希集合与映射可能因此出现违反预期的查找和去重结果。
  5. 依赖未承诺的遍历顺序。HashMap 和 HashSet 不保证遍历顺序;PriorityQueue 的迭代也不是排序输出。
  6. 在集合中修改关键字段。哈希键、哈希集合元素的判等状态,以及有序集合元素的比较状态,都不宜在存入后改变。
  7. 直接接受外部数据规模。对来自请求、文件或消息的集合长度、分配容量和元素数量设置业务上限,并验证值是否合法;否则可能造成过量内存消耗或计算开销。限值应按应用资源与业务要求确定,没有适用于所有程序的固定安全数字。
  8. 把集合当安全边界。不可修改包装、私有引用或并发集合都不能替代权限校验和输入验证。对外暴露可变内部集合,可能让调用方绕过对象的不变量。

九、选型速查

需求优先考虑注意事项
通用序列、按下标读取、尾部追加ArrayList中间插入删除会移动元素
元素去重,不要求顺序HashSet保证正确实现 equals 与 hashCode
去重且按插入顺序遍历LinkedHashSet维护顺序会增加开销
键值查询,不要求顺序HashMap键的哈希与相等性规则必须稳定
按键排序或范围查询TreeMap键必须可比较,比较规则要合理
单线程双端队列或栈ArrayDeque不接受 null,不是线程安全实现
并发键值访问ConcurrentHashMap不接受 null,复合操作仍需正确设计

实际项目中,先把需求说清楚:是否需要顺序、是否允许重复、是否需要范围查询、是否存在并发写入,再选择接口与实现。对性能敏感的场景,应使用真实数据和代表性操作进行测量;不要仅凭“链表增删快”或某个复杂度标签做决定。与此同时,明确集合的可变性、输入规模和对象判等规则,通常比换一个实现类更能避免隐蔽错误。

© 版权声明

相关文章