Java Stream distinct() 方法详解:高效实现 List 对象字段去重
1. 项目概述:从“去重”这个高频痛点说起
在Java开发中,处理List集合几乎是每天都要面对的日常。无论是从数据库查询出的结果集,还是从外部接口接收到的数据,我们常常会遇到一个恼人的问题:数据重复。想象一下,你从多个数据源聚合了一个用户列表,准备批量发送通知,结果因为重复数据导致同一个用户收到了好几条一模一样的消息;或者你在做数据统计时,重复的条目让最终的计数和金额完全失真。这种时候,“去重”就成了一个必须立刻解决的技术动作。
传统的去重方法,比如借助HashSet的无序唯一性,或者遍历List进行手动比对,虽然能解决问题,但代码往往显得冗长,且在处理对象集合时不够优雅。特别是当需求变为“根据对象中的某个特定字段进行去重”时,比如一个List<User>,要求根据用户的id或者name来去重,传统方法就需要我们手动实现equals()和hashCode(),或者使用TreeSet并传入自定义比较器,步骤繁琐。
而Java 8引入的Stream API,尤其是其中的distinct()方法,配合filter、map等操作,为我们提供了一种声明式、函数式的解决方案。它让代码的意图更加清晰,将“怎么做”的过程式思维,转变为了“做什么”的声明式思维。今天,我们就来深入聊聊,如何利用Java 8 Stream,特别是围绕distinct()方法,高效、优雅地解决List集合,尤其是对象集合根据字段去重的各种场景。你会发现,掌握了这些技巧,之前那些需要写十几行代码的逻辑,现在可能一行就能搞定,而且可读性和可维护性都大大提升。
2. 核心思路与方案选型背后的考量
面对一个List去重的需求,我们的大脑里通常会闪过几个方案。选择哪一个,不仅仅取决于能否实现,更取决于代码的简洁性、性能、可读性以及后续的维护成本。我们来逐一拆解这些方案背后的逻辑。
2.1 传统方案回顾与优劣分析
在Java 8之前,我们主要有以下几种武器:
使用
HashSet进行整体去重:这是最经典的方法。HashSet基于哈希表实现,其add方法会利用元素的equals()和hashCode()来判断唯一性。将List直接放入HashSet再转回List,即可去重。- 优点:时间复杂度接近O(n),性能通常很好。
- 缺点:要求集合中的元素正确重写了
equals()和hashCode()方法。对于根据对象某个字段去重,如果该字段不是对象判断相等的依据,此方法无效。除非你为了这次去重临时修改这两个方法,但这会破坏对象原有的相等语义,是危险的做法。
使用
TreeSet配合自定义Comparator:TreeSet是基于红黑树实现的有序集合,它依赖元素的自然排序或者传入的Comparator来保证唯一性。- 优点:可以非常灵活地根据自定义的比较逻辑(比如对象的某个字段)来定义“唯一性”,完美解决根据字段去重的问题。
- 缺点:
TreeSet的插入和查询时间复杂度是O(log n),在数据量较大时可能略慢于HashSet。同时,它要求元素要么实现Comparable接口,要么提供Comparator,并且这个比较逻辑必须与equals()保持一致(虽然不强制,但最佳实践如此),否则可能产生违反直觉的行为。
双重循环遍历比对:最原始的方法,外层循环遍历每个元素,内层循环检查后续是否有重复,发现重复则移除。
- 优点:无需任何额外条件,逻辑完全可控。
- 缺点:时间复杂度是O(n²),性能极差,仅适用于极小数据量。代码也最为冗长。
2.2 Java 8 Streamdistinct()的方案解析
Java 8的Stream API带来了全新的编程范式。distinct()是Stream的一个中间操作,它返回一个由原流中不重复元素组成的新流。它的去重依据,同样是元素的equals()和hashCode()方法。
那么,它如何解决“根据字段去重”这个核心痛点呢?
关键在于,Stream的操作是链式的、可组合的。我们不能直接让distinct()去比较字段,但我们可以通过其他操作,转换流的元素,让distinct()作用于我们“希望它比较”的那个部分。核心思路有两种:
- 映射去重法:使用
map操作,将对象流映射为某个字段的流(比如id的流),对这个字段流进行distinct()去重,然后再通过某种方式关联回原对象。这通常需要后续的收集操作配合。 - 过滤收集法:利用
Collectors.collectingAndThen或自定义收集器,在收集过程中维持一个“已见字段”的集合(如HashSet),通过filter操作筛选出首次出现的记录。
为什么选择Stream方案?
- 声明式编程:代码清晰表达了“我要根据id去重”的意图,而不是“我要如何循环、如何比较”。
- 链式调用:多个操作(过滤、映射、去重、收集)可以流畅地连接在一起,形成一条清晰的数据处理流水线。
- 易于并行化:Stream API天然支持并行流(
parallelStream()),对于大数据集,可以轻松利用多核优势提升去重性能。 - 更强的表达能力:可以轻松组合其他操作,例如先去重再排序,或者先过滤再根据字段去重。
注意:
distinct()是一个有状态的中断操作,在处理无限流或并行流时需要留意其开销。对于并行流,distinct()需要维护一个共享的ConcurrentHashMap来跟踪已见元素,这会带来一定的性能开销。在数据量不大或顺序流中,这个开销通常可以忽略。
3. 核心细节解析与多种场景实操
理论说再多,不如一行代码。下面我们通过一个具体的实体类User,来演示各种去重场景。假设我们有如下List<User>:
@Data // 使用Lombok简化代码 @AllArgsConstructor class User { private Long id; private String name; private String city; // 假设没有重写 equals 和 hashCode } List<User> users = Arrays.asList( new User(1L, "张三", "北京"), new User(2L, "李四", "上海"), new User(1L, "张三", "广州"), // id重复 new User(3L, "王五", "深圳"), new User(2L, "李四", "杭州") // id重复 );我们的目标是:根据id字段去重,保留第一次出现的记录。
3.1 场景一:使用TreeSet与Collectors.collectingAndThen
这是最经典、可读性较高的一种方法。思路是:利用Collectors.toCollection创建一个TreeSet,并传入一个根据id比较的Comparator。TreeSet在添加元素时会自动去重。最后,再将这个Set转换回ArrayList。
List<User> distinctUsers = users.stream() .collect(Collectors.collectingAndThen( Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(User::getId))), ArrayList::new ));代码拆解:
Comparator.comparing(User::getId):创建了一个比较器,它通过User::getId这个方法引用来提取比较键(即id字段)。new TreeSet<>(...):实例化一个TreeSet,并传入上面的比较器。这样,TreeSet判断两个User对象是否“相等”(即是否重复)的标准,就变成了比较它们的id字段是否相等。Collectors.toCollection(() -> new TreeSet<>(...)):这是一个收集器,它告诉Stream将元素收集到我们指定的那个TreeSet中。Collectors.collectingAndThen():这是一个包装收集器。它先执行第一个收集器(将元素收集到TreeSet),然后对其结果(即TreeSet)应用一个finisher函数(ArrayList::new),将其转换为ArrayList。
优点:逻辑清晰,一行代码完成去重和收集。缺点:由于使用了TreeSet,结果列表会按照id字段排序。如果你需要保留原始顺序,这个方法就不适用了。
3.2 场景二:使用filter与临时HashSet维持状态
如果我们想保留原始的插入顺序(即首次出现的顺序),可以使用filter操作配合一个临时的HashSet来记录已经出现过的键。
List<User> distinctUsers = users.stream() .filter(distinctByKey(User::getId)) .collect(Collectors.toList()); // 需要定义一个静态工具方法 public static <T> Predicate<T> distinctByKey(Function<? super T, ?> keyExtractor) { Map<Object, Boolean> seen = new ConcurrentHashMap<>(); // 使用ConcurrentHashMap以支持并行流 return t -> seen.putIfAbsent(keyExtractor.apply(t), Boolean.TRUE) == null; }代码拆解:
distinctByKey(User::getId):这是一个高阶函数,它接收一个函数User::getId作为参数,并返回一个Predicate(断言)。- 这个
Predicate内部维护了一个ConcurrentHashMap(seen)。Map的键是我们提取的字段值(如id),值在这里不重要(我们用了Boolean.TRUE作为占位符)。 putIfAbsent(key, value)方法是关键:如果key不存在,则插入键值对并返回null;如果key已存在,则返回当前key对应的值(不会是null)。- 在
filter中,对于流中的每个元素t,我们应用这个Predicate:提取其id,尝试放入seenMap。如果返回null,说明这个id是第一次出现,Predicate结果为true,该元素被保留;否则结果为false,元素被过滤掉。
优点:完美保留了元素在原始流中的首次出现顺序。方法通用性强,可以轻松应用于任何字段。缺点:需要额外定义一个静态工具方法,代码稍显分散。内部使用了ConcurrentHashMap,在单线程顺序流中略有性能开销,但保证了并行流的安全性。
3.3 场景三:复杂场景——根据多个字段组合去重
有时候,去重的逻辑可能更复杂,比如需要根据“姓名+城市”两个字段的组合来判定是否重复。我们只需稍微调整上面方法中的“键”即可。
使用TreeSet方法:
List<User> distinctUsers = users.stream() .collect(Collectors.collectingAndThen( Collectors.toCollection(() -> new TreeSet<>( Comparator.comparing((User u) -> u.getName()) .thenComparing(u -> u.getCity()) )), ArrayList::new ));这里我们使用了Comparator.comparing(...).thenComparing(...)来构建一个组合比较器。
使用filter与临时Map方法: 只需要修改keyExtractor函数,使其返回一个能代表组合唯一性的对象,比如一个字符串拼接,或者更优雅地,使用一个List或自定义的键对象。
// 方法1:拼接字符串(简单,但可能效率低或产生歧义) List<User> distinctUsers = users.stream() .filter(distinctByKey(u -> u.getName() + "#" + u.getCity())) // 用特殊字符分隔 .collect(Collectors.toList()); // 方法2:使用List作为复合键(更规范) List<User> distinctUsers = users.stream() .filter(distinctByKey(u -> Arrays.asList(u.getName(), u.getCity()))) .collect(Collectors.toList());注意:使用
List作为Map的键是可行的,因为ArrayList重写了equals和hashCode,会递归比较列表内的元素。但要注意,用于构建键的字段值本身应该是不可变或至少在此次去重操作中不会改变的。
4. 性能考量与边界情况处理
选择了合适的方案,我们还需要关注它在不同场景下的表现,以及可能遇到的“坑”。
4.1 不同数据量下的性能对比
为了有个直观感受,我们可以简单分析一下:
- 小数据量(<1000):几种方法性能差异微乎其微。选择代码最清晰、最易维护的方案即可,通常推荐场景二(filter+Map),因为它保留了顺序且逻辑清晰。
- 中大数据量(千级到百万级):
HashSet/TreeSet方案:HashSet的O(1)插入查询通常最快,TreeSet的O(log n)次之。但它们都需要在内存中完整存储另一个集合(Set),内存开销是O(n)。filter+ConcurrentHashMap方案:同样有O(n)的内存开销(存储键)。在顺序流中,ConcurrentHashMap的锁开销几乎可以忽略;在并行流中,它的表现会很好。- 如果内存紧张,且数据源是数据库,最优先考虑的是在SQL层面使用
DISTINCT或GROUP BY进行去重,将计算压力转移到数据库,这是最高效的方式。
- 超大数据量/流式数据:如果数据源是文件或网络流,无法全部加载到内存,上述基于内存集合的方法都会失效。此时可能需要考虑使用外部排序去重,或者使用支持分布式去重的数据处理框架(如Flink/Spark)。这超出了本文讨论范围,但心里要有这根弦。
4.2 保留哪一条重复数据的策略
我们之前的例子都是保留首次出现的记录。但业务上可能需要保留最后一次出现的记录,或者保留某个字段最大/最小的记录。
保留最后一次出现:最简单的办法是将原始
List反转,然后应用“保留首次”的逻辑,最后再将结果反转回来。或者,在遍历时用一个Map来不断覆盖,最终Map里的值就是最后一次出现的记录。// 使用Map覆盖,保留最后一次出现 Map<Long, User> map = new HashMap<>(); users.forEach(user -> map.put(user.getId(), user)); // 后面的覆盖前面的 List<User> distinctUsers = new ArrayList<>(map.values()); // 注意:HashMap不保证顺序,如需顺序,可用LinkedHashMap保留某个字段最大/小的记录:这需要使用
Collectors.toMap并指定一个合并函数(merge function)。// 假设根据id去重,保留name字母序最大的User List<User> distinctUsers = new ArrayList<>(users.stream() .collect(Collectors.toMap( User::getId, // 键:id Function.identity(), // 值:User对象本身 (u1, u2) -> u1.getName().compareTo(u2.getName()) > 0 ? u1 : u2 // 合并规则:保留name大的 )) .values());
4.3 处理null值
如果作为去重依据的字段可能为null,需要特别注意。TreeSet和作为Map键的ConcurrentHashMap通常可以处理null键(TreeSet要看比较器是否支持,ConcurrentHashMap不允许null键)。在自定义distinctByKey方法中,如果键提取器可能返回null,需要确保你的逻辑是预期的。一个简单的处理方式是,将null转换为一个特殊的标记对象。
public static <T> Predicate<T> distinctByKey(Function<? super T, ?> keyExtractor) { Map<Object, Boolean> seen = new ConcurrentHashMap<>(); return t -> { Object key = keyExtractor.apply(t); // 处理null,将其转换为一个特殊的单例对象,避免NPE Object keyToUse = (key == null) ? NULL_PLACEHOLDER : key; return seen.putIfAbsent(keyToUse, Boolean.TRUE) == null; }; } private static final Object NULL_PLACEHOLDER = new Object();5. 常见问题排查与实战技巧
在实际编码中,你可能会遇到一些意想不到的情况。下面是我踩过的一些坑和总结的技巧。
5.1distinct()不生效?检查你的equals和hashCode!
如果你直接对对象流调用distinct(),但去重效果不符合预期,99%的原因是你的实体类没有正确重写equals()和hashCode()方法。默认情况下(从Object类继承),这两个方法比较的是对象的内存地址。这意味着new User(1L, “张三”)和new User(1L, “张三”)会被认为是两个不同的对象。
解决方案:使用IDE(如IntelliJ IDEA或Eclipse)自动生成基于所有字段或关键字段的equals()和hashCode()方法。如果使用Lombok,用@Data或@EqualsAndHashCode注解即可。
@Data // 这会生成基于所有非静态、非瞬态字段的equals和hashCode class User { private Long id; private String name; }5.2 并行流(parallelStream)下的去重陷阱
使用parallelStream()可以提升性能,但distinct()操作在并行流中是有状态的,它内部使用一个共享的ConcurrentHashMap来跟踪元素。这通常是线程安全的,但如果你在去重过程中,用于判断相等的字段被其他线程修改了,就会导致不可预知的行为。更常见的问题是,并行流会打乱元素顺序,即使你用了distinct(),最终结果的顺序也是不确定的。
技巧:如果需要保留顺序,请不要使用parallelStream()进行去重操作,或者使用forEachOrdered等终端操作来保证顺序,但这可能会抵消并行的性能优势。通常,去重操作本身不是计算密集型,顺序流足矣。
5.3 内存溢出(OOM)风险
无论是HashSet、TreeSet还是ConcurrentHashMap方案,都需要在内存中维护一个与去重键数量相当的集合。如果去重键的数量极大(例如上亿),就可能导致OutOfMemoryError。
排查与解决:
- 评估数据量:首先确认是否真的需要在内存中处理如此大量的数据。
- 考虑数据库去重:如果数据来自数据库,务必在SQL查询时使用
DISTINCT或GROUP BY,这是最有效率的。 - 分批处理:如果必须在Java中处理,可以考虑将大
List分成多个批次,每批次单独去重,然后再合并去重。但这需要设计好批次间的去重逻辑。 - 使用磁盘或分布式缓存:对于极端情况,可以考虑使用像Ehcache、Redis这样的外部缓存来存储已见键,但这会引入IO开销和系统复杂性。
5.4 去重后类型丢失问题
在使用map操作先提取字段去重后,你得到的是一个字段值(如Long)的流。如何找回完整的对象?这通常需要后续的步骤,比如用去重后的id列表再去查询数据库,或者在一开始就使用filter方案而非map方案。
示例:错误做法
// 错误:这样得到的是去重后的id列表,不是User列表 List<Long> distinctIds = users.stream() .map(User::getId) .distinct() .collect(Collectors.toList()); // 如何根据id列表获取User?可能需要再次查询数据库。正确做法:使用前面介绍的filter+distinctByKey或TreeSet方案,它们能在去重的同时保留完整对象。
5.5 实用工具类封装
为了避免重复编写distinctByKey这样的方法,我习惯在项目里创建一个StreamUtils工具类:
public class StreamUtils { private StreamUtils() {} /** * 根据指定的键提取器对流进行去重,保留首次出现的元素。 * @param keyExtractor 键提取函数 * @param <T> 流元素类型 * @return 一个去重的Predicate */ public static <T> Predicate<T> distinctByKey(Function<? super T, ?> keyExtractor) { Set<Object> seen = ConcurrentHashMap.newKeySet(); // Java 8+ 可以使用newKeySet return t -> seen.add(keyExtractor.apply(t)); // add方法返回true表示首次添加 } /** * 根据多个键提取器进行去重。 * @param keyExtractors 键提取函数数组 * @param <T> 流元素类型 * @return 一个去重的Predicate */ @SafeVarargs public static <T> Predicate<T> distinctByKeys(Function<? super T, ?>... keyExtractors) { return t -> { List<Object> keys = Arrays.stream(keyExtractors) .map(extractor -> extractor.apply(t)) .collect(Collectors.toList()); // 这里需要一个支持复合键的Set,我们可以用拼接字符串或List本身作为键。 // 注意:List作为键要求其内容在去重过程中不变。 return seenComposite.add(keys); }; } // 注意:distinctByKeys 的实现需要更复杂的seenComposite结构,此处仅为示意。 }这样,在业务代码中就可以非常简洁地调用:
List<User> result = users.stream() .filter(StreamUtils.distinctByKey(User::getId)) .collect(Collectors.toList());我个人在实际项目中,更偏爱filter+distinctByKey的方案。它足够灵活,能保留顺序,并且通过一个清晰的方法名(distinctByKey)明确表达了意图,代码的可读性非常高。当团队新成员看到这行代码时,几乎不需要注释就能理解它在做什么。对于简单的、不关心顺序且字段可比较的场景,TreeSet方案也非常简洁。关键是,理解每种方法背后的原理和代价,才能在做技术选型时游刃有余。
