3. 集合框架在工程中的深度应用

集合框架是 Java 工程代码中出现频率最高的基础设施之一。很多性能问题、并发问题和内存问题,追根溯源并不是算法写错,而是集合选型不当。本节从“选型依据—性能对比—不可变集合—Stream 数据处理”四个层面展开,重点讲清楚在真实工程中如何做出可解释、可验证的选择。

一、选型的第一依据:访问模式而非习惯

选择 List、Map 还是 Set,本质上取决于三个问题:是否需要按索引随机访问、是否需要按 key 快速查找、是否要求元素唯一。ArrayList 基于数组,随机访问是 O(1),尾部追加均摊 O(1),但中间插入删除需要搬移元素,为 O(n)。LinkedList 基于双向链表,头尾插入删除 O(1),但随机访问是 O(n),且每个节点额外维护前后指针,内存开销明显更大。工程实践中,除非确实需要频繁在两端操作或作为队列使用,否则优先 ArrayList。

Map 的选型更依赖 key 的特征。HashMap 平均查找 O(1),依赖 hashCode 与 equals 的正确实现;当哈希冲突严重时,JDK 8 之后链表长度超过 8 且容量达到 64 会转为红黑树,退化查找从 O(n) 改善为 O(log n)。TreeMap 基于红黑树,查找 O(log n),但支持按 key 有序遍历和范围查询,适合需要排序或 subMap、headMap 的场景。LinkedHashMap 在 HashMap 基础上维护插入顺序或访问顺序,后者可用于实现简单的 LRU 缓存。Set 通常由对应 Map 实现,HashSet 无序、TreeSet 有序、LinkedHashSet 保持插入顺序。

集合选型决策与性能特征对照 核心问题 按什么方式访问数据? 按索引 / 顺序访问 List 家族 ArrayList 随机访问 O(1) 中间插入/删除 O(n) 内存紧凑,缓存友好 默认首选 LinkedList 头尾操作 O(1) 随机访问 O(n) 节点额外指针开销 仅适合双端队列场景 按 key 查找 Map 家族 HashMap 平均查找 O(1) 冲突严重转红黑树 无序,允许 null 通用首选 TreeMap 查找 O(log n) 按 key 有序遍历 支持范围查询 需要排序时使用 元素唯一性 Set 家族 HashSet 去重,无序 基于 HashMap add/contains O(1) 去重首选 LinkedHashSet 去重 + 插入顺序 遍历顺序可预期 略高于 HashSet 开销 需稳定顺序时使用

二、不可变集合:把“约定”变成“约束”

工程中经常出现“这个集合初始化后不应再被修改”的需求。如果只靠注释约定,迟早会被某处 add 破坏。JDK 9 之后提供了 List.of、Set.of、Map.of 等工厂方法,创建的是真正不可变集合:任何修改操作都会抛出 UnsupportedOperationException。需要注意两个坑:一是 List.of 不允许 null 元素,传入 null 会抛 NullPointerException;二是 Map.of 的键值对参数最多 10 组,超过需用 Map.ofEntries。

// 不可变集合:初始化后禁止修改
List<String> regions = List.of("cn-north", "cn-east", "cn-south");
Set<Integer> retryCodes = Set.of(500, 502, 503, 504);
Map<String, Integer> quota = Map.of(
    "read", 1000,
    "write", 200
);

// 需要超过 10 组键值对时
Map<String, Integer> big = Map.ofEntries(
    Map.entry("a", 1),
    Map.entry("b", 2)
    // ...
);

// 防御性拷贝:对外返回不可变视图
public List<String> getRegions() {
    return List.copyOf(regions); // 内部可变,对外不可变
}

List.copyOf 与 List.of 的区别在于:copyOf 会先判断传入集合是否已经是不可变实现,若是则直接返回,否则做一次拷贝。这在“内部维护可变集合、对外暴露只读视图”的场景中非常实用,既保证封装性,又避免每次调用都产生新对象。

三、Stream API 与 Lambda:把循环逻辑声明化

Stream 的价值不在于“更短”,而在于把“做什么”与“怎么做”分离。一个典型的数据处理链路是:过滤 → 映射 → 排序 → 收集。以订单数据为例,筛选金额大于 100 且状态为已支付的订单,提取订单号并按金额降序排列,最后收集为列表:

record Order(String id, String status, double amount) {}

List<Order> orders = List.of(
    new Order("O001", "PAID", 320.0),
    new Order("O002", "UNPAID", 150.0),
    new Order("O003", "PAID", 88.0),
    new Order("O004", "PAID", 560.0)
);

List<String> result = orders.stream()
    .filter(o -> "PAID".equals(o.status()))
    .filter(o -> o.amount() > 100)
    .sorted(Comparator.comparingDouble(Order::amount).reversed())
    .map(Order::id)
    .collect(Collectors.toList());
// 结果:[O004, O001]

分组统计是 Stream 在工程中最常见的用法之一。Collectors.groupingBy 配合 counting、summingDouble、averagingDouble 可以一行完成原本需要多轮循环的聚合:

Map<String, Double> amountByStatus = orders.stream()
    .collect(Collectors.groupingBy(
        Order::status,
        Collectors.summingDouble(Order::amount)
    ));
// {PAID=968.0, UNPAID=150.0}

Map<String, Long> countByStatus = orders.stream()
    .collect(Collectors.groupingBy(Order::status, Collectors.counting()));
// {PAID=3, UNPAID=1}

使用 Stream 时有几个工程要点。第一,Stream 是惰性的,中间操作不会立即执行,只有遇到终端操作(collect、forEach、reduce 等)才会触发整条流水线。第二,Stream 只能消费一次,重复使用会抛 IllegalStateException。第三,parallelStream 默认使用 ForkJoinPool.commonPool,在容器环境中线程数可能不符合预期,且并行并不总是更快——数据量小、操作轻量时,线程调度开销反而拖慢整体。第四,Lambda 中引用的外部局部变量必须是 effectively final,这是编译期约束,不是运行时检查。

选型速查:需要索引访问选 ArrayList;需要 key 查找选 HashMap;需要去重选 HashSet;需要排序遍历选 TreeMap/TreeSet;需要保持插入顺序选 LinkedHashMap/LinkedHashSet;初始化后不再修改用 List.of/Set.of/Map.of;对外暴露只读视图用 List.copyOf。

四、性能对比的观察方法

性能对比不能靠感觉,要有可复现的测量方式。一个简单做法是用 System.nanoTime 包裹目标操作,先做若干轮预热让 JIT 编译生效,再取多次运行的中位数。例如对比 ArrayList 与 LinkedList 在 10 万元素下的随机访问耗时,ArrayList 通常快一到两个数量级;而在头部插入场景,LinkedList 反而占优。需要强调的是,这类微基准受 JVM 预热、GC、数据分布影响很大,结论应结合具体场景判断,不能直接照搬。工程中更可靠的做法是先用 JMH 建立基准,再在真实业务链路上用 APM 或火焰图确认瓶颈位置。

集合选型的最终判断标准,是访问模式、数据规模、并发要求和内存约束四者的交集。把选型理由写进代码注释或设计文档,比记住“哪个更快”更有长期价值。