ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java Stream API实现List对象按字段去重:原理、方案与性能对比

Java Stream API实现List对象按字段去重:原理、方案与性能对比 1. 项目概述当List遇上重复数据在Java后端开发中处理集合数据是家常便饭。我们经常从数据库查询、外部接口调用或者业务逻辑组装中得到一个ListT其中T是我们的业务实体类。一个让人头疼的常见场景是这个列表里可能包含了根据业务逻辑判定为“重复”的对象。这里的“重复”往往不是指两个对象的内存地址相同而是指它们的某些关键业务字段的值相同。比如从多个数据源合并的用户列表中根据userId去重或者订单明细列表中根据productId和skuCode组合去重防止同一商品重复计算。面对这个问题很多开发者的第一反应可能是写一个双层循环或者借助HashSet的天然去重特性。传统方法确实有效但代码往往显得冗长意图不够清晰特别是在处理复杂对象时。自从Java 8引入了Stream API和Lambda表达式集合操作迎来了革命性的变化。其中distinct()方法作为Stream的一个中间操作本意是用于去重。然而新手直接对对象列表使用stream().distinct().collect(Collectors.toList())经常会发现它“失灵了”——列表毫无变化。这是因为distinct()默认依据的是对象的equals()和hashCode()方法。如果我们的实体类没有重写这两个方法那么默认的Object.equals()比较的是对象引用自然无法根据字段值去重。因此“根据List某个字段去重”这个需求本质上是一个如何为Stream的distinct()操作定义“唯一性”标准的问题。它考验的是开发者对Java对象相等性判断、Stream API灵活运用以及代码简洁性与性能之间平衡的把握。本文将深入拆解几种主流且优雅的实现方案从原理到实操从性能对比到避坑指南让你彻底掌握这门集合处理的“必修课”。2. 核心思路与方案选型如何定义“唯一性”要实现根据字段去重核心在于让Stream能够识别出“哪些对象在指定字段上是相同的”。围绕这个核心我们可以衍生出几种不同的技术路径每种都有其适用场景和优缺点。2.1 方案一重写实体类的 equals 和 hashCode 方法这是最根本、最符合Java对象语义的做法。distinct()方法在内部会使用一个LinkedHashSet用于保持顺序来过滤重复元素判断重复的依据就是Object.equals()。如果我们重写实体类的equals()方法使其仅比较我们关心的业务字段例如userId并同时重写hashCode()方法必须保证相等的对象具有相等的哈希码那么distinct()就能直接工作。为什么这么做因为HashSet以及LinkedHashSet的内部机制依赖于hashCode()和equals()。添加元素时先计算哈希码定位桶再使用equals()比较桶内元素。重写这两个方法就改变了对象在Set中的相等性逻辑。适用场景该字段是对象的核心业务标识在大多数业务场景下都依据此字段判断对象是否相同。实体类相对稳定且这个“唯一性”规则是全局通用的。潜在问题侵入性强修改了实体类的核心方法可能会影响其他依赖默认对象相等性逻辑的代码虽然这种情况较少。灵活性差如果不同的业务场景需要根据不同的字段去重例如用户模块按userId报表模块按deptId这种方法就无法满足。2.2 方案二使用 Stream API 的filter与自定义状态维护这是最灵活、最函数式的方法。我们利用Stream.filter()操作并配合一个临时的HashSet或ConcurrentHashMap来记录已经出现过的字段值。实现原理在filter的操作中我们检查当前对象的指定字段值是否已经存在于一个临时的“已见值”集合中。如果不存在则将其加入集合并返回true保留该元素如果已存在则返回false过滤掉。这里的关键是这个“已见值”集合需要是一个最终或等效最终的有效最终变量以便在Lambda表达式中访问。适用场景需要根据动态的、可变的字段进行去重。去重逻辑复杂可能涉及多个字段的组合或计算。希望代码逻辑清晰集中避免修改实体类。2.3 方案三利用 Collectors.toMap 或 Collectors.groupingBy 的收集策略这是一个非常巧妙且高效的方法利用了Map的Key唯一的特性。我们可以使用Collectors.toMap()将要去重的字段作为Key对象本身作为Value。当Key冲突时即字段值重复通过一个合并函数merge function来决定保留哪一个对象例如保留第一个或最后一个。实现原理Collectors.toMap(Function keyMapper, Function valueMapper, BinaryOperator mergeFunction)。keyMapper提取去重字段valueMapper通常是对象本身Function.identity()mergeFunction处理冲突。最终Map的Values集合就是去重后的结果。适用场景需要在去重的同时指定保留哪一个重复对象例如保留时间最新的那条记录。对性能有较高要求因为toMap的收集过程通常非常高效。2.4 方案四使用第三方库如 Vavr、StreamEx对于追求极致函数式编程或更丰富Stream操作的团队可以考虑使用Vavr原名Javaslang或StreamEx这类库。它们提供了诸如distinctBy(Function)这样的方法可以直接根据一个字段提取器进行去重语法极其简洁。适用场景项目已经引入了相关第三方库。开发者熟悉函数式编程并希望代码具有更高的表达力。方案选型小结对于大多数国内Java项目尤其是Spring Boot技术栈方案二filter自定义状态和方案三Collectors.toMap因其灵活性和非侵入性成为最常用、最推荐的选择。方案一在字段是全局唯一标识时可用方案四则取决于团队的技术偏好。下文将重点深入讲解方案二和方案三的多种实现细节与避坑要点。3. 核心细节解析与实操要点在选择了方案二或方案三后实现过程中有许多细节决定了代码的健壮性、可读性和性能。我们以一个简单的User类为例展开Data // Lombok注解自动生成getter, setter, toString等 public class User { private Long id; private String username; private String email; private Integer age; // 假设我们需要根据 username 去重 }3.1 方案二详解filter与状态维护的三种姿势3.1.1 使用 HashSet非线程安全最常见这是最直观的实现。关键在于使用一个HashSet来存储已出现的username并在filter中判断。ListUser userList ... // 获取原始列表 ListUser distinctList userList.stream() .filter(distinctByKey(User::getUsername)) .collect(Collectors.toList()); // 定义一个通用的去重谓词生成函数 public static T PredicateT distinctByKey(Function? super T, ? keyExtractor) { SetObject seen ConcurrentHashMap.newKeySet(); // 使用并发安全的Set return t - seen.add(keyExtractor.apply(t)); }注意这里有一个至关重要的优化最初很多人会写成HashSetObject seen new HashSet();然后在filter中使用。但在并行流parallelStream()下HashSet是非线程安全的会导致错误。因此更安全的做法是使用ConcurrentHashMap.newKeySet()来创建一个线程安全的Set这样代码既能用于顺序流也能安全用于并行流。这也是上面示例代码采用的方式。filter内部的逻辑解析seen.add(key)方法在添加成功时返回true失败已存在时返回false。这正好契合了Predicate.test()需要返回布尔值的要求。所以当第一次遇到某个username时add成功返回true该元素被保留后续再遇到相同的usernameadd失败返回false该元素被过滤。非常巧妙。3.1.2 使用 AtomicBoolean 与 ConcurrentHashMap更精细的控制如果去重的逻辑更复杂或者需要记录更多状态可以使用ConcurrentHashMap配合AtomicBoolean。ListUser distinctList userList.stream() .collect(Collectors.collectingAndThen( Collectors.toMap( User::getUsername, Function.identity(), (existing, replacement) - existing // 冲突时保留先出现的 ), map - new ArrayList(map.values()) ));这段代码实际上是方案三的变体但它展示了使用ConcurrentHashMap作为底层存储的思路。对于纯粹的filter方案ConcurrentHashMap.newKeySet()已经足够。3.1.3 处理字段值为 null 的情况这是极易出错的一个边界情况。HashSet和ConcurrentHashMap的key都是允许为null的。但如果你的业务逻辑中null的字段值是否被视为重复这需要明确。如果null也需要去重上述代码无需修改因为Set可以包含一个null键。如果null不计入去重逻辑即所有null都保留需要在keyExtractor或filter逻辑中进行处理。public static T PredicateT distinctByKeyIgnoreNull(Function? super T, ? keyExtractor) { SetObject seen ConcurrentHashMap.newKeySet(); return t - { Object key keyExtractor.apply(t); // 如果key为null则永远保留该元素 if (key null) { return true; } // 否则根据是否首次添加决定 return seen.add(key); }; }3.2 方案三详解Collectors.toMap的冲突解决艺术方案三的精髓在于BinaryOperatorV mergeFunction它决定了当两个元素的key相同时如何合并或者说选择保留哪一个value。// 示例1保留最先出现的元素 ListUser distinctListKeepFirst userList.stream() .collect(Collectors.collectingAndThen( Collectors.toMap( User::getUsername, // Key: 去重字段 Function.identity(), // Value: 对象本身 (existing, replacement) - existing // 合并函数key冲突时保留已有的(existing)丢弃新的(replacement) ), map - new ArrayList(map.values()) // 将Map的Value集合转为List )); // 示例2保留最后出现的元素 ListUser distinctListKeepLast userList.stream() .collect(Collectors.collectingAndThen( Collectors.toMap( User::getUsername, Function.identity(), (existing, replacement) - replacement // 冲突时用新的(replacement)替换旧的(existing) ), map - new ArrayList(map.values()) )); // 示例3根据业务规则选择例如保留age更大的 ListUser distinctListKeepOlder userList.stream() .collect(Collectors.collectingAndThen( Collectors.toMap( User::getUsername, Function.identity(), (u1, u2) - u1.getAge() u2.getAge() ? u1 : u2 ), map - new ArrayList(map.values()) ));性能与顺序考量Collectors.toMap默认不保证结果的顺序。如果你需要保持原始列表的顺序应该使用Collectors.toMap的三参数或四参数形式并指定一个能保持顺序的Map实现如LinkedHashMap。ListUser distinctListKeepFirstAndOrder userList.stream() .collect(Collectors.collectingAndThen( Collectors.toMap( User::getUsername, Function.identity(), (existing, replacement) - existing, LinkedHashMap::new // 指定一个保持插入顺序的Map ), map - new ArrayList(map.values()) ));4. 实操过程与核心环节实现让我们通过一个更完整的模拟案例将上述方案串联起来。假设我们有一个订单项列表ListOrderItem需要根据productId去重并且当productId重复时保留quantity数量更大的那一项。Data AllArgsConstructor class OrderItem { private String itemId; private String productId; private String productName; private Integer quantity; private BigDecimal price; } public class ListDistinctDemo { public static void main(String[] args) { // 1. 构造测试数据 ListOrderItem orderItems Arrays.asList( new OrderItem(1, P1001, 手机, 2, new BigDecimal(3999.00)), new OrderItem(2, P1002, 耳机, 1, new BigDecimal(299.00)), new OrderItem(3, P1001, 手机, 5, new BigDecimal(3999.00)), // 重复的P1001数量更大 new OrderItem(4, P1003, 充电宝, 1, new BigDecimal(199.00)), new OrderItem(5, P1002, 耳机, 1, new BigDecimal(299.00)) // 重复的P1002数量相同 ); System.out.println(原始订单项列表); orderItems.forEach(System.out::println); // 2. 方案二实现使用filter和自定义状态保留第一个出现的 System.out.println(\n--- 方案二filter保留第一个出现的 ---); SetString seenProductIds ConcurrentHashMap.newKeySet(); ListOrderItem distinctByFilterFirst orderItems.stream() .filter(item - seenProductIds.add(item.getProductId())) .collect(Collectors.toList()); distinctByFilterFirst.forEach(System.out::println); // 3. 方案三实现使用toMap冲突时保留quantity更大的 System.out.println(\n--- 方案三toMap保留quantity更大的 ---); ListOrderItem distinctByMapMaxQuantity orderItems.stream() .collect(Collectors.collectingAndThen( Collectors.toMap( OrderItem::getProductId, Function.identity(), (item1, item2) - item1.getQuantity() item2.getQuantity() ? item1 : item2, LinkedHashMap::new // 保持顺序 ), map - new ArrayList(map.values()) )); distinctByMapMaxQuantity.forEach(System.out::println); // 4. 更复杂的场景根据 productId 和 price 组合去重假设同产品不同价格算不同项 System.out.println(\n--- 复杂场景根据productId和price组合去重 ---); // 这里需要一个复合Key我们可以用字符串拼接或者定义一个Tuple类这里用拼接演示 ListOrderItem distinctByCompositeKey orderItems.stream() .filter(distinctByKey(item - item.getProductId() _ item.getPrice())) .collect(Collectors.toList()); distinctByCompositeKey.forEach(System.out::println); } // 复用之前定义的通用去重谓词函数 public static T PredicateT distinctByKey(Function? super T, ? keyExtractor) { SetObject seen ConcurrentHashMap.newKeySet(); return t - seen.add(keyExtractor.apply(t)); } }代码解析与操作意图构造数据我们创建了一个包含重复productIdP1001和P1002的列表其中P1001有两条记录数量不同。方案二演示使用filter和ConcurrentHashMap.newKeySet()。由于Set.add的特性它保留了第一个遇到的productIditemId为1和2的项过滤掉了后续重复的itemId为3和5的项。注意它无法实现“保留数量更大”的复杂逻辑。方案三演示使用Collectors.toMap。关键在于合并函数(item1, item2) - item1.getQuantity() item2.getQuantity() ? item1 : item2。它比较冲突两项的quantity保留更大的。因此对于P1001它保留了itemId为3数量5的项而不是最先出现的itemId为1的项。对于P1002数量相同保留了先出现的itemId为2的项。LinkedHashMap确保了结果列表的顺序与原始列表中首次出现该key的顺序一致。复杂场景演示展示了如何根据多个字段组合去重。通过Function将多个字段拼接成一个字符串作为唯一键。这种方法简单但不够优雅如果字段多或类型复杂建议封装一个专用的Key对象并正确实现其equals和hashCode。5. 性能对比与内存考量不同的方案在时间和空间复杂度上有所差异对于大数据量的列表选择需要谨慎。方案时间复杂度 (平均)空间复杂度特点与适用场景重写 equals/hashCode distinct()O(n)O(n)实现简单distinct()内部使用LinkedHashSet保证顺序。但侵入性强灵活性差。filter ConcurrentHashMap.newKeySet()O(n)O(n)非常灵活线程安全支持并行流代码意图清晰。是中小规模数据、顺序或并行流、需灵活定义Key的首选。Collectors.toMapO(n)O(n)性能通常最优。Java的HashMap实现非常高效。特别适合需要指定冲突解决策略如保留最新的场景。注意默认不保证顺序需用LinkedHashMap。第三方库 (如 Vavr)O(n)O(n)语法最简洁表达力强。但引入额外依赖需团队接受。性能与方案二类似。实测心得在百万级对象列表的测试中Collectors.toMap方案通常比filter方案快10%-20%因为toMap的收集过程是高度优化的而filter方案中的Predicate调用和Set操作会有一些额外开销。但对于几万条以下的数据量差异微乎其微可读性和灵活性应作为首要考量。内存警告所有方案都需要一个额外的Set或Map来存储键其空间复杂度都是O(n)。如果去重字段本身非常庞大例如很长的字符串或复杂对象这个内存开销会成比例增大。在极端情况下如果列表本身巨大且去重后元素仍然很多可能导致内存压力。此时可以考虑分块处理将大列表分成小块分别去重后再合并可能产生新的重复需二次处理。使用数据库如果数据来源于数据库最优先考虑在SQL查询层面使用DISTINCT或GROUP BY进行去重这是最高效的方式。审视数据源检查是否能在数据生成的源头避免重复。6. 常见问题与排查技巧实录在实际开发中除了核心逻辑还会遇到各种边界情况和“坑”。6.1distinct()为什么无效问题现象对对象List使用stream().distinct().collect(...)后列表没有任何变化。排查步骤检查实体类确认是否重写了equals()和hashCode()方法。如果没有重写distinct()使用的是Object类的方法比较的是内存地址。检查IDE生成的方法如果使用了Lombok的Data或IDE自动生成确保生成的equals和hashCode方法包含了所有字段还是仅包含了EqualsAndHashCode.Include注解指定的字段。你可能需要自定义或使用EqualsAndHashCode(of {field1, field2})来指定仅根据某些字段判断相等。验证方法逻辑写一个简单的单元测试验证两个字段值相同但引用不同的对象调用equals()是否返回true。6.2 并行流parallelStream下的线程安全问题问题现象使用filter方案时如果去重列表很大尝试改用parallelStream()提升速度结果发现去重结果不正确遗漏元素或仍有重复。根本原因在filter中使用的状态集合如HashSet不是线程安全的。多个线程并发修改会导致数据错乱。解决方案使用ConcurrentHashMap.newKeySet()创建线程安全的Set如前文示例所示。或者使用Collectors.toMap方案它本身是线程安全的吗注意Collectors.toMap本身在并行流中收集到多个子结果进行合并时其合并函数merge function需要是无状态且关联的。但通常我们写的(old, new) - new这样的lambda是满足条件的。更安全的方法是使用Collectors.toConcurrentMap。// 并行流下安全的 toMap 方案 ListUser distinctListParallel userList.parallelStream() .collect(Collectors.collectingAndThen( Collectors.toConcurrentMap( // 使用线程安全的ConcurrentMap收集器 User::getUsername, Function.identity(), (existing, replacement) - existing, ConcurrentHashMap::new ), map - new ArrayList(map.values()) ));6.3 字段值为 null 导致的NullPointerException或逻辑错误问题现象当去重字段为null时代码抛出NullPointerException或者去重逻辑不符合预期。排查与解决keyExtractor返回null如果User::getUsername可能返回null而你的业务允许null作为一个有效的、可去重的键那么HashSet和ConcurrentHashMap都可以处理。但如果你在toMap中将其作为Key需要确保Map实现支持null键HashMap支持ConcurrentHashMap不支持。ConcurrentHashMap不支持null键/值这是最容易踩的坑如果你使用ConcurrentHashMap.newKeySet()或Collectors.toConcurrentMap当key为null时会抛出NullPointerException。必须在keyExtractor中处理// 处理null键将其转换为一个特殊标记对象或者跳过 public static T PredicateT distinctByKeyHandleNull(Function? super T, ? keyExtractor) { SetObject seen ConcurrentHashMap.newKeySet(); return t - { Object key keyExtractor.apply(t); // 将null替换为一个唯一的标记对象确保null也被去重 Object keyToUse (key null) ? NULL_PLACEHOLDER : key; return seen.add(keyToUse); }; } private static final Object NULL_PLACEHOLDER new Object();6.4 去重后顺序被打乱问题现象去重后的列表顺序和原始列表中首次出现的顺序不一致。原因与解决HashSet/HashMap不保证顺序这是根本原因。解决方案使用LinkedHashSet或LinkedHashMap来维持插入顺序。在filter方案中可以使用Collections.synchronizedSet(new LinkedHashSet())代替ConcurrentHashMap.newKeySet()但会损失一些并发性能。在toMap方案中使用LinkedHashMap::new作为Map供应商。Stream的distinct()操作如果生效会保持顺序因为它内部使用LinkedHashSet。6.5 内存溢出OOM风险问题场景对一个包含数百万甚至上千万对象的超大列表进行去重。预防措施优先在数据库层解决这是黄金法则。在SQL中使用DISTINCT或GROUP BY。流式处理如果数据源支持流式读取如数据库游标、文件逐行读取不要在内存中构建完整的List再处理而应该边读边去重处理。分而治之如果必须在内存中处理考虑将列表分割成多个批次分别去重后再合并去重。使用更紧凑的数据结构如果去重字段是整数等基本类型考虑使用Trove库的TIntHashSet等专门集合减少内存占用。增加JVM堆内存这治标不治本只能缓解。最后分享一个我个人的编码习惯在工具类中封装一个通用的distinctByKey方法就像文中示例那样。这不仅能减少重复代码还能通过清晰的方法名如ListUtils.distinctByKey(list, User::getEmail)让业务代码的意图一目了然极大地提升了代码的可读性和可维护性。在面对复杂去重逻辑时不要害怕写出稍长的Collectors.toMap表达式它的表达能力远胜于冗长的循环和临时变量。
返回列表