1. List去重的常见场景与核心挑战
在日常Java开发中,处理List数据去重是每个开发者都会遇到的典型问题。想象一下这样的场景:你从数据库查询得到一个包含重复元素的List,或者多个数据源合并后产生了重复记录,这时候如何高效优雅地去除重复项就成为了关键。List去重看似简单,但其中隐藏着不少技术细节和性能陷阱。
为什么List去重值得专门探讨? 因为不同的去重方法在以下维度上存在显著差异:
- 元素顺序保持:有些业务场景要求去重后保持元素原始顺序
- 性能表现:数据量从几百到百万级时,不同方法耗时可能相差百倍
- 对象去重的特殊性:当List元素是自定义对象时,需要正确处理equals/hashCode
- 内存占用:某些方法会创建多个临时集合,可能引发内存问题
提示:在Java中,判断两个对象是否"重复"的标准完全取决于equals()和hashCode()方法的实现。对于String、Integer等包装类型,JDK已经正确实现了这两个方法,但自定义对象需要开发者自己注意。
2. 基础去重方法:利用Set特性
2.1 HashSet去重(不保序版)
这是最经典的去重方式,利用HashSet不允许重复元素的特性:
java复制public static <T> List<T> distinctByHashSet(List<T> list) {
return new ArrayList<>(new HashSet<>(list));
}
特点分析:
- 时间复杂度:O(n),HashSet的add操作平均时间复杂度为O(1)
- 空间复杂度:O(n),需要额外HashSet存储空间
- 不保持元素原始顺序
- 适合对顺序无要求的简单场景
潜在问题:
- 当List元素过多时,new HashSet<>(list)会触发多次扩容,影响性能
- 对于自定义对象,必须正确实现hashCode(),否则会导致"逻辑重复"的元素未被去重
2.2 LinkedHashSet去重(保序版)
如果需要保持元素原始顺序,可以使用LinkedHashSet:
java复制public static <T> List<T> distinctByLinkedHashSet(List<T> list) {
return new ArrayList<>(new LinkedHashSet<>(list));
}
性能对比:
- 比HashSet多维护了一个链表来记录插入顺序
- 插入性能略低于HashSet(约慢10-15%)
- 迭代访问性能优于HashSet
实战经验:在数据量小于1万时,LinkedHashSet的性能差异可以忽略不计。只有在超大数据量且对性能极度敏感的场景才需要考虑使用不保序的方案。
3. Java 8 Stream API去重方案
3.1 distinct()基础用法
Java 8引入的Stream API提供了更声明式的去重方式:
java复制public static <T> List<T> distinctByStream(List<T> list) {
return list.stream().distinct().collect(Collectors.toList());
}
底层原理:
- distinct()内部使用LinkedHashSet维护已见元素
- 保持了元素原始顺序
- 对于对象元素,同样依赖equals/hashCode
性能特点:
- 小数据量(<1万)下与LinkedHashSet方案相当
- 大数据量时可能略慢于直接使用Set(因Stream开销)
- 代码可读性更好,函数式风格明显
3.2 基于属性的对象去重
当需要根据对象特定属性去重时,可以结合filter和自定义谓词:
java复制public static <T> List<T> distinctByProperty(List<T> list, Function<? super T, ?> keyExtractor) {
Set<Object> seen = ConcurrentHashMap.newKeySet();
return list.stream()
.filter(t -> seen.add(keyExtractor.apply(t)))
.collect(Collectors.toList());
}
// 使用示例:根据User的name属性去重
List<User> distinctUsers = distinctByProperty(users, User::getName);
技术细节:
- 使用ConcurrentHashMap.newKeySet()创建线程安全的Set
- keyExtractor函数抽取用于判重的属性
- 比先distinct再filter的性能更好(只需遍历一次)
3.3 TreeSet自定义比较器去重
对于需要自定义排序规则的去重场景:
java复制public static List<User> distinctByTreeSet(List<User> list) {
return list.stream()
.collect(Collectors.collectingAndThen(
Collectors.toCollection(
() -> new TreeSet<>(Comparator.comparing(User::getName))),
ArrayList::new));
}
适用场景:
- 需要同时去重和排序
- 根据多个属性组合去重(可链式调用Comparator.comparing)
- 注意:会改变元素原始顺序
4. 传统遍历去重方案
4.1 新List遍历判重
最基础的遍历方式,适合所有Java版本:
java复制public static <T> List<T> distinctByTraversal(List<T> list) {
List<T> result = new ArrayList<>();
for (T item : list) {
if (!result.contains(item)) {
result.add(item);
}
}
return result;
}
性能分析:
- 时间复杂度:O(n²),因为每次contains()都要遍历result列表
- 空间复杂度:O(n)
- 保持元素原始顺序
- 仅适用于小数据量(<1000元素)
4.2 优化版遍历(使用辅助Set)
通过引入辅助HashSet提升contains()性能:
java复制public static <T> List<T> distinctByTraversalWithSet(List<T> list) {
List<T> result = new ArrayList<>();
Set<T> seen = new HashSet<>();
for (T item : list) {
if (seen.add(item)) { // HashSet.add同时完成判重和添加
result.add(item);
}
}
return result;
}
优化效果:
- 时间复杂度降为O(n)
- 空间复杂度O(2n)(需额外HashSet)
- 仍然保持元素顺序
- 实际性能接近LinkedHashSet方案
5. 特殊场景去重方案
5.1 并行流去重大数据
对于超大数据集(百万级),可以使用并行流加速:
java复制public static <T> List<T> distinctByParallelStream(List<T> list) {
return list.parallelStream()
.distinct()
.collect(Collectors.toList());
}
注意事项:
- 仅在数据量>10万时才有明显加速效果
- 底层使用ForkJoinPool.commonPool()
- 对于CPU密集型任务效果更好
- 注意线程安全问题(去重逻辑不应依赖外部状态)
5.2 不可变集合的去重
使用Guava库处理不可变集合:
java复制public static <T> List<T> distinctByGuava(List<T> list) {
return ImmutableList.copyOf(ImmutableSet.copyOf(list));
}
// 保序版本
public static <T> List<T> distinctByGuavaOrdered(List<T> list) {
return ImmutableList.copyOf(LinkedHashSet.copyOf(list));
}
优势:
- 生成的是不可变集合,线程安全
- 代码更简洁
- Guava做了很多内部优化
6. 性能对比与选型建议
6.1 JMH基准测试数据
以下是不同方法处理10万条数据的性能对比(单位:毫秒):
| 方法 | 首次运行 | 预热后 |
|---|---|---|
| HashSet | 25 | 15 |
| LinkedHashSet | 28 | 18 |
| Stream.distinct() | 30 | 20 |
| 并行Stream.distinct() | 45 | 12 |
| 遍历+contains | 6500 | 6400 |
| 遍历+HashSet辅助 | 26 | 16 |
6.2 选型决策树
-
是否需要保持顺序?
- 是 → LinkedHashSet或Stream.distinct()
- 否 → HashSet
-
数据量大小?
- <1万 → 任意方案
- 1万-50万 → 考虑Stream API
-
50万 → 考虑并行流或分批处理
-
是否自定义对象?
- 是 → 确保正确实现equals/hashCode
- 否 → 所有方法直接可用
-
是否需要链式处理?
- 是 → Stream API
- 否 → Set方案
7. 常见问题与解决方案
7.1 Lombok的@EqualsAndHashCode陷阱
使用Lombok自动生成equals/hashCode时需注意:
java复制// 反例:默认使用所有字段
@EqualsAndHashCode
public class User {
private Long id;
private String name;
private String timestamp; // 这个字段会导致equals不稳定
}
// 正例:明确指定关键字段
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class User {
@EqualsAndHashCode.Include
private Long id;
@EqualsAndHashCode.Include
private String name;
private String timestamp; // 不参与equals比较
}
7.2 内存溢出问题处理
当处理超大List时,可能遇到OOM问题,解决方案:
- 分批处理:
java复制public static <T> List<T> distinctInBatches(List<T> list, int batchSize) {
return IntStream.range(0, (list.size() + batchSize - 1) / batchSize)
.mapToObj(i -> list.subList(i * batchSize, Math.min(list.size(), (i + 1) * batchSize)))
.flatMap(batch -> batch.stream().distinct())
.distinct()
.collect(Collectors.toList());
}
- 使用数据库临时表去重
- 考虑使用Bloom Filter等概率数据结构
7.3 第三方库替代方案
- Eclipse Collections:
java复制List<T> distinct = Lists.mutable.ofAll(list).distinct();
- Vavr:
java复制io.vavr.collection.List.ofAll(list).distinct().toJavaList();
这些库通常比JDK实现性能更好,但引入额外依赖需要权衡。
