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 保持插入顺序。
二、不可变集合:把“约定”变成“约束”
工程中经常出现“这个集合初始化后不应再被修改”的需求。如果只靠注释约定,迟早会被某处 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,这是编译期约束,不是运行时检查。
四、性能对比的观察方法
性能对比不能靠感觉,要有可复现的测量方式。一个简单做法是用 System.nanoTime 包裹目标操作,先做若干轮预热让 JIT 编译生效,再取多次运行的中位数。例如对比 ArrayList 与 LinkedList 在 10 万元素下的随机访问耗时,ArrayList 通常快一到两个数量级;而在头部插入场景,LinkedList 反而占优。需要强调的是,这类微基准受 JVM 预热、GC、数据分布影响很大,结论应结合具体场景判断,不能直接照搬。工程中更可靠的做法是先用 JMH 建立基准,再在真实业务链路上用 APM 或火焰图确认瓶颈位置。
集合选型的最终判断标准,是访问模式、数据规模、并发要求和内存约束四者的交集。把选型理由写进代码注释或设计文档,比记住“哪个更快”更有长期价值。