Java Stream列表去重:三种高效方法详解与实战避坑指南
1. 从一次线上数据重复引发的思考那天下午监控告警突然响了提示某个核心接口的响应时间飙升。排查下来问题出在一个看似简单的列表处理上。一个返回用户订单列表的接口因为上游数据源的偶发性问题导致同一个订单ID的数据被重复推送到了处理队列。我们的服务在组装最终返回给前端的列表时没有做去重处理结果一个用户可能看到十几条一模一样的订单记录。前端渲染卡顿用户投诉DBA那边也反馈数据库的临时表空间因为大量重复数据的JOIN操作而暴涨。问题的根源很清晰我们需要对一个ListOrder根据订单ID进行去重。团队里一位刚毕业的同事下意识地写了一个双层for循环遍历对比虽然功能实现了但在数据量稍大比如上万条时那个O(n²)的时间复杂度立刻让接口性能雪上加霜。我review代码时指着那段循环问他“咱们现在用的是Java 8你想想有没有更优雅、更高效的方式” 他愣了一下然后恍然大悟“可以用Stream”是的Java 8引入的Stream API配合Lambda表达式为我们处理集合操作开辟了一条“声明式”编程的道路。对于列表去重这个高频操作Stream提供了不止一种而是多种清晰且高效的实现路径。今天我就结合那次事故的复盘和日常开发中的实践详细拆解根据对象属性对列表进行去重的三种主流Stream实现方法并深入探讨它们背后的原理、适用场景以及那些容易踩进去的坑。2. 场景还原为什么需要根据对象属性去重在深入代码之前我们得先明确“根据对象属性去重”到底意味着什么。这和我们直接调用list.stream().distinct()有本质区别。distinct()方法是依据对象的equals()和hashCode()来判断是否重复。对于String、Integer这种标准库类型它工作得很好。但对于我们自定义的Order、User、Product等业务对象情况就复杂了。假设我们的Order类定义如下Data // 使用Lombok注解自动生成getter, setter, equals, hashCode等方法 public class Order { private Long orderId; // 订单ID唯一标识 private String orderSn; // 订单号 private BigDecimal amount; // 订单金额 private Long userId; // 用户ID // 省略其他字段... }现在有一个ListOrder里面可能包含多个orderId相同但其他字段如amount因优惠券变动可能不同的对象。业务上我们通常认为orderId相同的对象是同一笔订单的重复记录我们需要保留其中一条可能是第一条也可能是金额最大的一条而过滤掉其他。这时直接distinct()是无效的因为即使orderId相同如果amount不同默认的equals()方法如果没被正确重写会认为这是两个不同的对象。因此我们必须基于orderId这个特定属性来定义“重复”的标准。理解了这一点我们就可以开始探索三种不同的实现策略了。3. 方法一使用Collectors.toMap的“收集转映射”法这是我最常用也是我认为在需要保留特定元素的场景下最灵活、最直观的一种方法。其核心思路是利用Map的Key唯一性来达到去重的目的。3.1 核心实现与代码解析ListOrder uniqueOrders orderList.stream() .collect(Collectors.toMap( Order::getOrderId, // Key映射器以orderId作为Map的Key order - order, // Value映射器对象本身作为Value (existing, replacement) - existing // 合并函数当Key冲突时保留已存在的第一条 )) .values() // 获取Map中所有的Value即去重后的Order对象 .stream() // 将CollectionOrder重新转为Stream .collect(Collectors.toList()); // 最终收集为List这段代码虽然看起来有点绕但拆解后逻辑非常清晰Collectors.toMap这个收集器的目标是将Stream中的元素转换成一个MapK, V。第一个参数Order::getOrderId这是一个Function它决定了Map的Key是什么。这里我们指定用orderId作为Key。Map的Key是唯一的这就天然奠定了去重的基础。第二个参数order - order这也是一个Function决定Map的Value是什么。通常我们直接放入对象本身。第三个参数(existing, replacement) - existing这是最关键的合并函数BinaryOperator。当两个元素的orderId即Key相同时toMap会调用这个函数来处理冲突。这里我们简单地返回existing先遇到的那个对象这意味着我们“保留第一条丢弃后来者”。经过toMap后我们得到了一个MapLong, Order其中每个orderId只对应一个Order对象。调用map.values()获取去重后的对象集合再将其转为List。3.2 方法一的优势与选择逻辑我之所以偏爱这个方法是因为它的选择策略非常明确。合并函数(existing, replacement) - existing就是我们的“去重裁决器”。你可以根据业务需求轻松修改它保留最新的一条如果列表是按时间倒序的你可以用(old, new) - new。保留某个属性最大/小的那条例如保留金额最大的订单(o1, o2) - o1.getAmount().compareTo(o2.getAmount()) 0 ? o1 : o2。合并两条记录理论上你甚至可以创建一个新的对象合并两个冲突对象的某些属性。这种灵活性是其他方法难以比拟的。它把“根据什么去重”Key和“去重时保留哪一个”合并函数这两个问题解耦得非常清楚。3.3 实战中的坑与避雷指南注意Collectors.toMap对Key为null的容忍度。如果orderId有可能为null上面的代码在运行时可能会抛出NullPointerException。因为HashMaptoMap默认使用的Map实现不允许null作为Key。如果你的业务中存在Key为null的有效数据这通常意味着数据有问题但有时不得不处理你需要使用Collectors.toMap的重载版本并指定一个允许null键的Map比如HashMap实际上HashMap允许null键但toMap的默认实现可能因版本而异最安全的是手动处理ListOrder uniqueOrders orderList.stream() .collect(Collectors.toMap( Order::getOrderId, Function.identity(), (o1, o2) - o1, HashMap::new // 显式指定Map工厂使用HashMap )) .values().stream().collect(Collectors.toList());更健壮的做法是在数据源头或处理前就过滤掉Key为null的记录。另一个性能上的小细节是这种方法会创建中间Map。如果原始列表非常大例如百万级这个Map会占用额外的内存。但在绝大多数业务场景下千级到万级数据这点开销是可以接受的换来的是代码的清晰和逻辑的强控制力。4. 方法二利用TreeSet或自定义容器的“过滤”法这种方法的核心思想是在遍历过程中用一个辅助的Set来记录已经出现过的属性值从而实现过滤。它通常通过filter操作来实现。4.1 使用ConcurrentHashMap实现线程安全的去重这是利用Set特性的一种变体利用了ConcurrentHashMap的newKeySet方法创建一个线程安全的Set在并行流中也能正确工作。SetLong seenOrderIds ConcurrentHashMap.newKeySet(); ListOrder uniqueOrders orderList.stream() .filter(order - seenOrderIds.add(order.getOrderId())) // 关键add成功返回true意味着第一次见到此id .collect(Collectors.toList());工作原理创建一个并发的SetLong用于存放已经遇到的orderId。在filter中我们尝试将当前订单的orderId添加到这个Set中。Set.add()方法有一个重要特性如果Set中不包含该元素则添加并返回true如果已包含则返回false。filter会保留那些使谓语lambda表达式返回true的元素。因此只有第一个成功将orderId加入seenOrderIds的订单会被保留下来后续相同orderId的订单因为add返回false而被过滤掉。这种方法代码非常简洁意图明确“过滤掉那些orderId已经出现过的元素”。它天然保留了第一条数据。4.2 使用TreeSet实现排序去重如果你不仅想去重还希望结果列表按照orderId或其他属性进行排序那么可以结合TreeSet。但这里有个转折我们不能直接过滤因为我们需要保留整个对象。一个常见的“坑”是试图这样写// 错误示例这会丢失对象只留下orderId SetLong treeSet new TreeSet(); ListOrder uniqueOrders orderList.stream() .filter(order - treeSet.add(order.getOrderId())) .collect(Collectors.toList()); // 结果列表是乱序的原顺序TreeSet只起到了去重判断作用没起到排序作用上面的代码虽然能去重但最终列表的顺序仍然是原始流中的顺序TreeSet只被用作一个判断是否重复的容器其排序特性没有应用到结果列表上。真正的“排序去重”需要换一种思路通常是在去重后单独排序或者使用toMap法并指定TreeMap作为容器但这样更复杂。更直接的方法是先排序再用上面ConcurrentHashMap的方法去重但去重会保留排序后的第一条// 先按orderId排序再去重保留排序后每组的第一条 ListOrder sortedAndUniqueOrders orderList.stream() .sorted(Comparator.comparing(Order::getOrderId)) .filter(distinctByKey(Order::getOrderId)) .collect(Collectors.toList()); // 定义一个通用的去重Key方法 public static T PredicateT distinctByKey(Function? super T, ? keyExtractor) { SetObject seen ConcurrentHashMap.newKeySet(); return t - seen.add(keyExtractor.apply(t)); }4.3 方法二的适用场景与局限优点代码极其简洁尤其是ConcurrentHashMap版本一行filter逻辑清晰。对于并行流parallelStream()处理ConcurrentHashMap.newKeySet()是线程安全的而toMap方法在并行流中合并函数可能会被多次调用需要确保合并函数是幂等的。缺点与注意事项选择策略固定它严格保留第一次使add返回true的元素也就是原始流中第一个出现的元素。你无法像toMap那样自由指定保留最新或某个极值。状态性filter操作本应是“无状态”的但这里我们引入了一个外部的、可变的状态seenOrderIdsSet。这在函数式编程范式中被视为一种“副作用”虽然在实际工程中经常使用但需要明确知晓。在并行流中必须使用线程安全的容器。内存占用和toMap法一样需要额外的空间来存储已见的Key。提示关于“无状态”的探讨。纯函数式编程强调“无状态”和“无副作用”。这里的filter依赖于外部可变状态打破了这一原则。但在Java的Stream API实践中只要谨慎处理如用于顺序流或为并行流提供线程安全容器这种模式是被广泛接受和使用的。它更像是一种“命令式”思维嵌入到了“声明式”的框架里是实用主义的一种体现。5. 方法三通过Collectors.collectingAndThen与TreeSet的“终极”去重法这是一种相对小众但非常巧妙的方法它结合了TreeSet的排序去重特性和Collectors.collectingAndThen的后续转换能力。它适用于需要根据某个属性去重同时还要根据另一个属性排序的复杂场景。5.1 实现代码与步骤拆解假设我们有一个更复杂的需求需要根据userId去重但对于同一个userId的多个订单我们想保留amount最大的那一个并且最终列表按照orderId排序。ListOrder uniqueOrders orderList.stream() .collect(Collectors.collectingAndThen( Collectors.toCollection( () - new TreeSet(Comparator .comparing(Order::getUserId) .thenComparing(Order::getAmount).reversed() // 先按userId比相同则按amount降序 ) ), set - new ArrayList(set) // 将TreeSet转换回ArrayList ));拆解分析Collectors.toCollection(...)这个收集器告诉Stream将元素收集到一个我们指定的Collection中。这里我们指定为一个TreeSet。new TreeSet(Comparator ...)我们创建了一个带自定义比较器Comparator的TreeSet。这个比较器是关键Comparator.comparing(Order::getUserId)首先根据userId进行比较。TreeSet依靠Comparator或元素的Comparable接口来判断元素是否相等。在TreeSet中如果Comparator比较两个元素返回0就认为它们相等后一个不会被加入。因此这里我们用userId作为去重的依据。.thenComparing(Order::getAmount).reversed()当userId相同时再按照amount进行比较并且是降序reversed()。这意味着对于同一个userIdamount更大的订单会被TreeSet认为“更小”因为降序在构造Set时它会排在前面。由于Set的唯一性第一个被加入的即amount最大的会保留后续userId相同但amount不同的订单会被视为重复而丢弃。Collectors.collectingAndThen()这个收集器的作用是“先收集然后对结果进行一个后续转换”。它接受两个参数第一个参数是另一个收集器这里是我们定制的toCollection收集器用于执行最初的收集动作得到一个TreeSetOrder。第二个参数是一个Function用于对第一个收集器的结果进行转换。这里我们将TreeSet转换成一个ArrayList。最终我们得到了一个ArrayList它已经根据我们定义的Comparator先按userId去重同userId按amount降序取最大完成了去重和排序。5.2 方法三的威力与苛刻前提这种方法非常强大它将去重的标准、排序的规则以及冲突时的选择策略全部封装在了一个Comparator里。你可以通过精心设计这个比较器实现非常复杂的去重逻辑。但是它的使用前提非常苛刻去重依据必须与排序依据兼容你用于去重的属性如userId必须被包含在Comparator的排序逻辑中并且是首要排序键。因为TreeSet是靠比较结果是否为0来判断相等的。可能违背直觉对于不熟悉TreeSet和Comparator机制的开发者来说这段代码的可读性较差。看到TreeSet第一反应是排序很难直接联想到它被用来做“按属性去重且保留极值”的操作。性能考量TreeSet的插入时间复杂度是O(log n)对于大规模数据构建这个Set的成本会比HashMapO(1)平均稍高。5.3 一个更清晰的变体先分组再归约对于上面那个“按userId去重保留amount最大”的需求其实有一个更主流、更易读的实现方式它揭示了这类问题的本质分组取极值。ListOrder uniqueOrders orderList.stream() .collect(Collectors.collectingAndThen( Collectors.toMap( Order::getUserId, // 按userId分组 Function.identity(), // 值就是订单本身 (o1, o2) - o1.getAmount().compareTo(o2.getAmount()) 0 ? o1 : o2 // 合并取amount大的 ), map - new ArrayList(map.values()) ));或者使用groupingBy收集器ListOrder uniqueOrders new ArrayList( orderList.stream() .collect(Collectors.toMap( Order::getUserId, Function.identity(), BinaryOperator.maxBy(Comparator.comparing(Order::getAmount)) // 使用内置比较器 )) .values() );这两种写法都明确地表达了“分组”和“在组内选择”两个步骤比用TreeSet的Comparator技巧更直观也更容易被其他同事理解和维护。所以方法三的TreeSet技巧更像是一种炫技在大多数情况下toMap配合明确的合并函数是更优的选择。6. 性能对比与选型决策指南纸上得来终觉浅绝知此事要测一测。我们来对这三种方法做一个简单的性能分析和场景对号入座。我构建了一个包含10万个Order对象的列表其中约有30%的重复orderId在相同的测试环境下JDK 1.8 默认JVM参数进行去重操作粗略统计耗时单位毫秒多次测试取平均。请注意这只是一个定性参考实际性能受数据分布、JVM状态、硬件等因素影响很大。方法描述平均耗时 (ms)特点toMap法使用HashMap保留第一条~15-25灵活性最高。可自由定义冲突解决策略保留第一条、最后一条、合并等。内存占用为O(n)性能优异。是大多数情况下的首选。filter(Set::add)法使用ConcurrentHashMap.newKeySet()~20-30代码最简洁意图清晰“过滤掉已见的”。保留第一条。并行流友好。性能与toMap法接近但灵活性稍差。TreeSet法在collectingAndThen中使用带比较器的TreeSet~40-60能够将去重和排序结合在一次操作中实现复杂规则。但代码晦涩性能通常低于基于Hash的容器。仅当需要复杂排序去重且规则能用单个Comparator表达时才考虑。选型决策树是否需要自定义保留规则是- 选择toMap法。在合并函数中实现你的规则保留第一条、最后一条、最大值、最小值或自定义合并逻辑。否只需保留第一条 - 进入第2步。是否使用并行流(parallelStream)是- 选择filter法配合ConcurrentHashMap.newKeySet()简洁且线程安全。否-toMap法和filter法均可。如果追求代码极简用filter法如果觉得toMap的表达更符合“收集为映射”的直觉用toMap法。是否需要同时完成复杂排序是且排序规则与去重属性强相关- 可以考虑TreeSet法但务必评估代码可读性。通常更推荐先排序再去重或者使用toMap后对结果列表再排序逻辑更清晰。否- 回到第1、2步。在我的日常开发中Collectors.toMap是绝对的主力。因为它完美地解决了“根据什么分组去重”和“组内冲突怎么办”这两个核心问题语义明确功能强大。那个导致线上问题的订单去重最终就是用这个方法修复的合并函数选择了(o1, o2) - o1表示保留最先处理的那条订单记录。7. 举一反三处理复合键与空值防御现实业务中去重条件往往不是单个属性。例如可能需要根据“用户ID 商品ID”来判断唯一性。这也很容易扩展到上述方法中。对于toMap法Key可以是一个复合对象// 定义一个简单的复合键类通常用record或内部类 record OrderItemKey(Long userId, Long productId) {} ListOrder uniqueOrders orderList.stream() .collect(Collectors.toMap( order - new OrderItemKey(order.getUserId(), order.getProductId()), // 使用复合键 Function.identity(), (o1, o2) - o1 // 保留第一条 )) .values().stream().collect(Collectors.toList());确保你的键类正确重写了equals()和hashCode()方法Javarecord会自动生成。对于filter法需要将多个属性组合成一个唯一标识放入SetSetString seenKeys ConcurrentHashMap.newKeySet(); ListOrder uniqueOrders orderList.stream() .filter(order - seenKeys.add(order.getUserId() _ order.getProductId())) // 简单拼接注意分隔符唯一性 .collect(Collectors.toList());注意拼接字符串作为Key的风险。使用字符串拼接如userId _ productId简单快捷但存在风险如果userId或productId本身包含分隔符_可能导致不同的组合产生相同的Key如user_1和product_2拼接成user_1_product_2与user和1_product_2拼接结果相同。更安全的方式是使用分隔符连接各部分的字符串表示或者直接使用ListObject作为Set的元素需注意List的equals和hashCode或者使用专门的复合键对象。空值防御是生产代码必须考虑的。如果去重依据的属性可能为null在toMap法中如前所述需处理nullKey问题或提前过滤。在filter法中Set.add(null)是允许的但你需要明确业务上是否将null视为一个有效的去重键。通常建议在流操作开始前使用filter(Objects::nonNull)或filter(order - order.getOrderId() ! null)将无效数据排除使核心去重逻辑更干净。最后无论选择哪种方法单元测试都至关重要。你需要覆盖空列表、无重复列表、全部重复列表、部分重复列表、属性为null的边界情况等场景确保你的去重逻辑在任何情况下都表现正确。毕竟这些优雅的Stream代码最终是要为线上稳定运行负责的。