Java 8 Stream 三种对象属性去重方案详解与实战对比

1. 项目概述与核心价值

在日常的Java开发中,尤其是处理从数据库查询或外部接口获取的数据集合时,我们经常会遇到一个看似简单却容易踩坑的需求:如何对一个List<Object>根据对象的一个或多个属性进行去重。比如,你有一个用户列表,每个用户有userIduserName,现在需要根据userId来去重,保留唯一用户。在Java 8之前,你可能需要手动遍历、使用HashSet辅助或者借助第三方库,代码写起来既啰嗦又容易出错。自从Java 8引入了Stream API和Lambda表达式,这类数据操作变得前所未有的优雅和强大。

今天,我们就来深入探讨三种基于Java 8 Stream流,根据对象属性进行列表去重的实现方法。这三种方法各有千秋,分别适用于不同的场景和性能要求。我会结合我多年在后台数据处理、微服务接口开发中的实际经验,不仅告诉你“怎么做”,更会详细拆解“为什么这么做”,以及每种方法背后的性能考量、使用陷阱和最佳实践。无论你是刚接触Stream的新手,还是想优化现有代码的老手,这篇文章都能给你带来直接的、可落地的参考。

2. 核心思路与方案选型背后的考量

在动手写代码之前,我们先要理清思路。根据对象属性去重,本质上是一个“筛选”过程,其核心在于如何定义“重复”。对于基本类型列表,Stream.distinct()方法可以直接使用,因为它依赖Object.equals()方法。但对于自定义对象,默认的equals比较的是对象引用(内存地址),除非你重写了equalshashCode方法,使其基于业务属性进行比较。

然而,在实际项目中,我们往往不希望或者不能仅仅为了去重就去修改实体类的equals/hashCode方法,因为这可能会影响该对象在其他场景(如放入HashMapHashSet)下的行为,带来意想不到的副作用。因此,我们需要寻找一种不依赖对象自身equals方法,而是基于指定属性进行去重的方式。

Stream API提供了强大的中间操作和终端操作,让我们可以灵活组合。围绕这个需求,业界和社区沉淀出了几种主流方案,我们主要讨论三种最常用、最具代表性的:

  1. 使用Collectors.toMap模拟去重:利用Map的Key唯一性,将对象属性作为Key,对象本身作为Value进行收集,最后取Map的Values。
  2. 使用TreeSet自定义比较器:利用TreeSet的有序性和唯一性,通过传入自定义的Comparator来定义何为“重复”。
  3. 使用Streamfilter与自定义状态维护:通过一个临时的HashSet来记录已出现过的属性值,在filter操作中进行过滤。

选择哪种方案?这取决于你的数据量、对原列表顺序的要求、以及对代码可读性和性能的权衡。接下来,我们将逐一拆解,你会看到每种方法如何实现,以及它们各自的“脾气秉性”。

3. 方法一:使用Collectors.toMap实现去重

这是我个人在数据量不大(例如万级以下)、且需要保留去重后元素任意一种顺序时最常用的一种方法。它的思路非常巧妙,利用了Map的键(Key)不能重复的特性。

3.1 实现原理与代码示例

假设我们有一个User类,现在需要根据userId去重。

@Data // 使用Lombok简化代码 @AllArgsConstructor class User { private Long userId; private String userName; private Integer age; }

去重代码如下:

import java.util.*; import java.util.stream.Collectors; public class DeduplicationDemo1 { public static void main(String[] args) { // 构造一个包含重复userId的列表 List<User> userList = Arrays.asList( new User(1L, “张三”, 25), new User(2L, “李四”, 30), new User(1L, “张三(重复)”, 28), // userId重复 new User(3L, “王五”, 35), new User(2L, “李四(重复)”, 32) // userId重复 ); // 方法一:使用Collectors.toMap去重 List<User> distinctUsers = userList.stream() .collect(Collectors.toMap( User::getUserId, // Key Mapper: 以userId作为去重依据 user -> user, // Value Mapper: 对象本身作为值 (existing, replacement) -> existing // Merge Function: 遇到重复Key时,保留已存在的(第一个) )) .values() // 获取Map中的所有值(即去重后的User对象) .stream() // 将Collection<User>重新转为Stream .collect(Collectors.toList()); // 收集为List // 打印结果 distinctUsers.forEach(System.out::println); } }

输出结果:

User(userId=1, userName=张三, age=25) User(userId=2, userName=李四, age=30) User(userId=3, userName=王五, age=35)

可以看到,根据userId去重后,只保留了每组重复项中第一次出现的那个(张三和李四)。

3.2 关键参数深度解析

Collectors.toMap有三个参数,理解它们是用好这个方法的关键:

  1. Key Mapper (Function<? super T, ? extends K> keyMapper): 这是一个函数,用于从流元素中提取作为Map键的值。在我们的场景里,它就是去重的依据,比如User::getUserId关键点:这个函数返回的值(例如userId)的hashCodeequals方法将被用于判断是否重复。因此,如果你的去重依据是一个自定义对象,必须确保该对象正确实现了hashCodeequals

  2. Value Mapper (Function<? super T, ? extends U> valueMapper): 这个函数决定当键唯一时,放入Map的值是什么。通常我们直接使用对象本身(user -> user)。有时你也可以选择只保留某个属性,例如User::getUserName,但那就不是去重对象,而是去重后得到一个属性列表了。

  3. Merge Function (BinaryOperator<U> mergeFunction):这是实现去重逻辑的核心!keyMapper提取出的键发生冲突(即重复)时,这个函数被调用来决定保留哪一个值。它接收两个参数:existing(Map中已存在的值)和replacement(新来的值)。

    • (old, new) -> old: 保留先出现的(首次出现原则)。这是我们例子中的用法,也是最常见的。
    • (old, new) -> new: 保留后出现的(末次出现原则)。
    • 更复杂的逻辑:例如,比较两个对象的age,保留年龄大的:(u1, u2) -> u1.getAge() > u2.getAge() ? u1 : u2

3.3 实操心得与避坑指南

注意:Collectors.toMap的默认行为与空值(null)Collectors.toMap默认不允许keyMappervalueMapper返回null值,否则会抛出NullPointerException。如果你的去重依据属性可能为null,必须使用Collectors.toMap的重载版本,并指定一个具体的Map实现(如HashMap),或者考虑使用方法二或三。

优点:

  • 逻辑清晰:代码直观地表达了“以某个属性为键收集为Map,冲突时保留前者,最后取值的集合”这一过程。
  • 灵活控制保留策略:通过mergeFunction,可以轻松实现保留第一个、保留最后一个或按自定义规则保留,非常灵活。
  • 一次流转完成:只需要一次stream()操作,理论上效率较高。

缺点与坑点:

  1. 顺序丢失:通过Map.values()得到的Collection(通常是HashSet的视图)是不保证顺序的。虽然例子中最后又转成了List,但此时的顺序已不是原列表的顺序。如果你需要保留元素原始的插入顺序,这个方法默认不满足。解决方案是使用LinkedHashMap来保持顺序:
    List<User> distinctUsersByInsertOrder = userList.stream() .collect(Collectors.toMap( User::getUserId, user -> user, (old, new) -> old, LinkedHashMap::new // 指定Map工厂,使用LinkedHashMap )) .values() .stream() .collect(Collectors.toList());
  2. 并行流(Parallel Stream)风险toMapmergeFunction在并行流下可能被并发调用,如果函数不是纯函数(有副作用或依赖外部状态),或者mergeFunction的逻辑不是结合性的(associative),可能会导致不确定的结果或错误。在去重场景下,简单的(old, new) -> old是结合性的,相对安全,但仍需谨慎。
  3. 多属性去重:如果需要根据多个属性(如userIduserName同时相同才算重复)去重,keyMapper需要返回一个能代表组合键的对象。通常有两种做法:
    • 拼接字符串user -> user.getUserId() + “_” + user.getUserName()。简单但丑陋,且存在分隔符冲突的理论风险。
    • 使用List或自定义对象user -> Arrays.asList(user.getUserId(), user.getUserName())。需要确保Listequals/hashCode是有效的(Arrays.asList返回的List实现是满足的)。

4. 方法二:使用TreeSet与自定义比较器去重

这种方法利用了TreeSet是一个有序且元素唯一的集合特性。通过向TreeSet提供一个自定义的Comparator,我们可以定义在TreeSet看来,什么样的两个对象是“相等”的(即比较器返回0)。

4.1 实现原理与代码示例

import java.util.*; import java.util.stream.Collectors; public class DeduplicationDemo2 { public static void main(String[] args) { List<User> userList = Arrays.asList( new User(1L, “张三”, 25), new User(2L, “李四”, 30), new User(1L, “张三(重复)”, 28), new User(3L, “王五”, 35), new User(2L, “李四(重复)”, 32) ); // 方法二:使用TreeSet与自定义比较器去重 List<User> distinctUsers = userList.stream() .collect(Collectors.collectingAndThen( Collectors.toCollection( // 创建一个TreeSet,其排序(去重)规则由Comparator定义 () -> new TreeSet<>(Comparator.comparing(User::getUserId)) ), ArrayList::new // 将TreeSet转换为ArrayList )); distinctUsers.forEach(System.out::println); } }

输出结果与第一种方法一致。

4.2 核心组件:Collectors.collectingAndThenComparator

  • Collectors.toCollection(Supplier<C> collectionFactory): 这是一个收集器,允许你指定最终结果容器的具体类型。这里我们传入一个Supplier,它返回一个新的TreeSet
  • Comparator.comparing(Function keyExtractor): 这是Java 8引入的非常方便的API,用于快速创建比较器。Comparator.comparing(User::getUserId)创建了一个根据userId进行自然序(Long实现了Comparable)比较的比较器。对于TreeSet,如果比较器认为两个对象相等(compare返回0),则后一个对象不会被加入。
  • Collectors.collectingAndThen(Collector<T,A,R> downstream, Function<R,RR> finisher): 这是一个“装饰器”收集器。它先使用下游收集器(downstream,这里是将元素收集到TreeSet)得到一个中间结果R(即TreeSet<User>),然后应用finisher函数(ArrayList::new)将其转换为最终结果RR(即ArrayList<User>)。

4.3 注意事项与性能权衡

优点:

  • 天然排序TreeSet会根据你提供的Comparator对元素进行排序。如果你恰好需要去重后按某个属性排序,这个方法一举两得。
  • 逻辑相对直观:代码表达了“用一个特定的规则(比较器)把元素放到一个不允许重复的集合里”的概念。
  • 支持多属性去重Comparator可以链式调用。例如,先按userId,再按userName去重:Comparator.comparing(User::getUserId).thenComparing(User::getUserName)

缺点与坑点:

  1. 强制排序带来的开销TreeSet基于红黑树实现,每次插入的平均时间复杂度是O(log n)。对于纯粹为了去重且不关心顺序的场景,这比HashSet的O(1)插入要慢。数据量越大,性能差距越明显
  2. 改变了元素顺序:即使你最后转成了ArrayList,顺序也是TreeSet排序后的顺序,不是原列表的顺序。如果你需要保留原序,此方法不适用。
  3. 依赖比较器的正确性:你必须确保Comparator实现的compare方法与你期望的“相等”逻辑严格对应。即,当且仅当两个对象在你业务上认为“重复”时,compare方法才返回0。如果compare返回0但业务上并非重复(极罕见),会导致错误去重;反之,则无法去重。
  4. 空值(null)处理TreeSet默认不允许插入null元素。如果你的列表可能包含null,且你希望保留一个null(或在去重时考虑null),需要特别小心。Comparator需要能处理null,或者提前过滤掉null

5. 方法三:使用filter与状态维护去重(自定义去重逻辑)

这是最基础、最灵活,也是最能体现“手动控制”思想的方法。它不依赖于任何特殊的收集器或集合,而是直接在流的filter操作中,通过一个外部状态(通常是一个HashSet)来记录已经遇到过的键。

5.1 实现原理与代码示例

import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.stream.Collectors; public class DeduplicationDemo3 { public static void main(String[] args) { List<User> userList = Arrays.asList( new User(1L, “张三”, 25), new User(2L, “李四”, 30), new User(1L, “张三(重复)”, 28), new User(3L, “王五”, 35), new User(2L, “李四(重复)”, 32) ); // 方法三-1:使用HashSet维护状态(非线程安全,适用于顺序流) Set<Long> seenUserId = new HashSet<>(); List<User> distinctUsers1 = userList.stream() .filter(user -> seenUserId.add(user.getUserId())) // 如果add成功(即未见过),则过滤通过 .collect(Collectors.toList()); System.out.println(“方法三-1结果:”); distinctUsers1.forEach(System.out::println); // 方法三-2:使用ConcurrentHashMap维护状态(线程安全,可用于并行流) // 这里利用ConcurrentHashMap的原子性操作putIfAbsent来模拟Set Set<Long> seenUserIdForParallel = ConcurrentHashMap.newKeySet(); List<User> distinctUsers2 = userList.parallelStream() // 使用并行流 .filter(user -> seenUserIdForParallel.add(user.getUserId())) .collect(Collectors.toList()); System.out.println(“\n方法三-2(并行流)结果:”); distinctUsers2.forEach(System.out::println); } }

5.2 状态维护的两种模式解析

顺序流模式(seenUserId.add

  • HashSet.add(key)方法在键不存在时添加并返回true,键已存在时返回false
  • filter中,只有add返回true(即第一次见到这个userId)的元素才会被保留下来。这巧妙地实现了“保留首次出现”的去重逻辑。
  • 重要:这种方法不是线程安全的。HashSet不能在多线程环境下修改。因此,它只能用于顺序流(stream()),绝对不能用于并行流(parallelStream()),否则会导致数据竞争和不确定的结果。

并行流模式(ConcurrentHashMap.newKeySet().add

  • 为了在并行流中使用这种模式,我们需要一个线程安全的集合来维护状态。ConcurrentHashMap.newKeySet()返回一个由ConcurrentHashMap支持的线程安全Set
  • 它的add方法内部使用了并发安全的逻辑,可以安全地在多个线程中调用。
  • 这样,我们就可以利用并行流来加速大规模数据的去重处理。但要注意,并行流本身有开销(线程创建、结果合并),对于小数据量可能得不偿失。

5.3 灵活性与复杂场景应对

这种方法的强大之处在于其灵活性。你可以在filter函数中实现任何复杂的去重逻辑。

场景一:根据多个属性去重

Set<String> seenKeys = new HashSet<>(); List<User> distinctUsers = userList.stream() .filter(user -> { String key = user.getUserId() + “|” + user.getUserName(); // 组合键 return seenKeys.add(key); }) .collect(Collectors.toList());

场景二:条件去重(例如,保留年龄最大的)这需要更复杂的状态维护,通常filter就不够用了,可能需要使用Collectors.toMap并自定义mergeFunction,或者先分组再处理。

场景三:保留最后一次出现的记录只需将状态维护的逻辑稍作改动,但需要更多代码,通常不如toMap(old, new) -> new来得简洁。

优点:

  • 极致灵活:你可以完全控制去重的逻辑,包括基于复杂条件、动态键等。
  • 保留原始顺序:因为是顺序过滤,所以天然保留了元素在原始流中首次出现的顺序。
  • 概念简单:易于理解和调试,就是简单的“记录-检查”模式。

缺点:

  • 有状态操作,违反函数式原则:Stream操作理想上是无状态的。这种方法引入了外部可变状态(Set),使得操作不再是纯函数,这在复杂的函数式流水线中可能带来困惑。
  • 并行流需要额外处理:必须使用线程安全的集合,增加了复杂性和轻微的性能开销。
  • 代码稍显冗长:对于简单的根据单个属性去重,代码量比前两种方法要多一些。

6. 三种方法对比与选型建议

为了更直观地对比,我将三种方法的核心特性总结如下表:

特性维度方法一:Collectors.toMap方法二:TreeSet+Comparator方法三:filter+ 状态Set
核心原理利用Map键的唯一性利用TreeSet元素唯一性与自定义比较器外部记录已出现键,在filter中拦截
是否保留原列表顺序默认不保留,需用LinkedHashMap不保留,按比较器排序保留(顺序流下)
去重策略控制非常灵活,通过mergeFunction控制灵活,通过Comparator定义“相等”非常灵活,可自定义任意逻辑
多属性去重支持支持,需构造组合键对象支持,使用Comparator.thenComparing链式调用支持,需在filter内构造组合键
并行流支持支持,但需确保mergeFunction是结合性的且线程安全不支持TreeSet非线程安全)支持,需使用并发集合(如ConcurrentHashMap.KeySet
性能特点一次流转,O(n)复杂度,性能较好插入复杂度O(log n),数据量大时较慢,且排序有开销O(n)复杂度,但需维护外部集合,有轻微开销
代码简洁度较简洁较简洁相对冗长
函数式纯度高(终端操作)高(终端操作)低(有状态中间操作)
典型适用场景需要灵活控制保留策略、不严格要求原序或可用LinkedHashMap保序去重后需要按某属性排序的场景需要严格保留原列表顺序、或去重逻辑极其复杂的场景

选型建议:

  • 如果你的需求是“保留首次出现,且不去关心顺序”方法一(toMap通常是首选,代码简洁,性能良好。
  • 如果你的需求是“保留首次出现,且必须严格保留原列表顺序”方法三(filter+HashSet是最直接的选择。或者使用方法一,但指定LinkedHashMap::new作为Map工厂。
  • 如果你的需求是“去重后需要按某个属性排序”方法二(TreeSet可以同时完成去重和排序,一举两得。
  • 如果你的去重逻辑非常复杂(例如,需要根据动态条件或外部状态判断)方法三的灵活性是无可替代的。
  • 如果处理的数据量非常大(百万级以上)且需要并行处理:可以考虑方法三配合ConcurrentHashMap和并行流,或者确保线程安全的前提下使用方法一的并行流版本。务必进行性能测试。
  • 简单起见,作为通用工具方法:我通常会封装一个使用方法一(配合LinkedHashMap保序)的通用工具类,因为它平衡了简洁性、灵活性和性能。

7. 常见问题排查与实战技巧实录

在实际使用中,你可能会遇到一些意想不到的问题。这里记录了几个我踩过的坑和对应的解决方案。

7.1 问题一:去重后列表顺序乱了

现象:使用Collectors.toMap去重后,元素的顺序和原列表不一样了。根因Collectors.toMap默认使用HashMap,而HashMap不保证遍历顺序。解决方案

  1. 如果需要保留插入顺序,使用LinkedHashMap
    .collect(Collectors.toMap(key, value, mergeFunc, LinkedHashMap::new))
  2. 如果需要保留首次出现的顺序,且不介意额外开销,直接使用方法三(filter+HashSet)。

7.2 问题二:并行流(parallelStream)下去重结果不正确或报错

现象:使用了parallelStream(),结果去重后数量不对,或者直接抛出并发修改异常。根因:你使用的去重方法不是线程安全的。例如,方法二中的TreeSet,方法三中普通的HashSet,都不是线程安全的容器。解决方案

  1. 避免在非线程安全的去重方法中使用并行流
  2. 如果必须用并行流,选择线程安全的方案:
    • 方法三,但将HashSet替换为ConcurrentHashMap.newKeySet()
    • 方法一(toMap)本身在并行流下是安全的,但其默认的合并器(mergeFunction)必须是结合性的。对于简单的(old, new) -> old,它是安全的。
  3. 评估是否真需要并行流:数据量不是特别大(例如少于1万条)时,顺序流往往更快,因为并行化有线程管理和上下文切换的开销。

7.3 问题三:根据多个属性去重时,拼接字符串存在隐患

现象:使用user.getProp1() + “_” + user.getProp2()作为组合键,理论上存在极低概率的键冲突(例如“a_bc”“ab_c”拼接后都是“a_bc”)。解决方案

  1. 使用更安全的连接符:选择一个在属性值中几乎不可能出现的字符或字符串,如“|||”“\u0001”(Unicode控制字符)。
  2. 使用对象作为键:推荐做法。可以创建一个专用的PairTuple类,或者直接使用List
    // 使用List .collect(Collectors.toMap( user -> Arrays.asList(user.getProp1(), user.getProp2()), Function.identity(), (old, new) -> old ))
    确保作为键的对象正确实现了equalshashCodeArrays.asListList.of返回的列表是满足的)。
  3. 使用方法二的ComparatorComparator.comparing(Obj::getProp1).thenComparing(Obj::getProp2),既清晰又安全。

7.4 问题四:去重依据的属性值为null导致空指针异常

现象:当对象的去重属性为null时,抛出NullPointerException根因Collectors.toMapkeyMapper函数返回null,或者TreeSetComparator无法比较null解决方案

  1. 提前过滤:如果业务上null值无意义,可以在流开始时过滤掉:.filter(user -> user.getUserId() != null)
  2. 处理null值:在keyMapperComparator中处理null。例如,对于toMap,可以返回一个包装对象或特定标记:
    .collect(Collectors.toMap( user -> user.getUserId() != null ? user.getUserId() : “NULL_MARKER”, Function.identity(), (old, new) -> old ))
    对于TreeSet,可以使用Comparator.nullsFirstComparator.nullsLast
    Comparator.comparing(User::getUserId, Comparator.nullsFirst(Comparator.naturalOrder()))
  3. 考虑使用Collectors.toMap的重载版本(接受Map工厂),但通常不如前两种方法直接。

7.5 一个实用的通用工具方法封装

最后,分享一个我项目中常用的工具方法,它使用方法一(保序版),并处理了常见的空集合情况:

import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; public class StreamUtils { /** * 根据指定键提取器对列表进行去重,保留首次出现的元素及其原始顺序。 * * @param list 待去重的列表 * @param keyExtractor 用于提取去重依据键的函数 * @param <T> 列表元素类型 * @param <K> 键的类型 * @return 去重后的新列表 */ public static <T, K> List<T> distinctByKey(List<T> list, Function<T, K> keyExtractor) { if (list == null || list.isEmpty()) { return new ArrayList<>(); } return list.stream() .collect(Collectors.collectingAndThen( Collectors.toMap( keyExtractor, Function.identity(), (oldValue, newValue) -> oldValue, LinkedHashMap::new ), map -> new ArrayList<>(map.values()) )); } // 使用示例 public static void main(String[] args) { List<User> users = // ... 获取列表 List<User> distinctUsers = StreamUtils.distinctByKey(users, User::getUserId); } }

这个方法足够应对大部分“根据属性去重并保序”的场景,代码简洁且意图明确。当然,你可以根据团队的需要,在此基础上扩展更多功能,比如支持自定义保留策略、并行流处理等。