尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

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

Java Stream distinct() 失效?对象字段去重实战方案对比

Java Stream distinct() 失效?对象字段去重实战方案对比 1. 从一次数据导出异常说起为什么简单的去重会这么复杂前几天在做一个物料明细导出的功能代码跑起来看着挺正常但业务同事反馈说导出的Excel里同一种物料竟然重复出现了好几行。我第一反应是数据库查询写错了加了DISTINCT或者GROUP BY结果发现SQL没问题数据就是从ListMaterialDetailDto里来的。问题出在Java层我从多个数据源组装了这个List虽然每个Dto的materialCode物料编码应该唯一但不同数据源带来的附加信息比如批次、供应商略有不同导致整个对象不相等HashSet去重直接失效。这让我重新审视了List去重特别是基于对象某个字段去重这个看似简单、实则暗藏玄机的问题。对于Java开发者尤其是用上了Java 8及之后版本的我们处理集合去重是家常便饭。但“去重”二字背后根据场景不同解决方案的复杂度和性能影响天差地别。简单的ListString去重一个Stream.distinct()或者扔进HashSet就能搞定但面对ListCustomObject要根据对象的id、name或是其他任意字段去重时事情就变得有趣了。网上搜一下各种方案五花八门有直接用distinct()的往往达不到预期有手写循环对比的也有用TreeSet或Collectors.collectingAndThen的。今天我就结合这次踩坑和后续的几种解决方案深挖一下Java 8 Stream distinct()的机制并给出几种根据对象字段去重的实战方案帮你避开我遇到的坑。2. 理解根基Stream.distinct()到底在比较什么在急着写代码之前我们必须搞清楚Stream.distinct()这个内置方法的行为逻辑。很多人误以为它可以像SQL的DISTINCT关键字一样指定根据某个字段去重这是一个非常普遍的误解。Stream.distinct()方法的行为依赖于流中元素的equals()和hashCode()方法。它的去重逻辑是基于整个对象的相等性而非对象的某个属性。底层实现通常使用一个LinkedHashSet用于保持遇到顺序或类似的内部结构来记录已经出现过的元素。当新元素到来时会调用其equals()方法与集合中现有元素比较。关键点在于如果你没有在自定义的实体类比如MaterialDetailDto中重写equals()和hashCode()方法那么默认继承自Object类的方法会比较对象的内存地址即引用是否指向同一对象。这意味着即使两个MaterialDetailDto对象的所有字段值都一模一样只要它们是new出来的两个不同实例distinct()就会认为它们是不同的元素不会去重。// 一个简单的DTO类未重写 equals 和 hashCode Data // Lombok注解默认不会生成基于字段的equals/hashCode public class MaterialDetailDto { private String materialCode; private String materialName; private String batchNo; } public class TestDistinct { public static void main(String[] args) { ListMaterialDetailDto list new ArrayList(); list.add(new MaterialDetailDto(M001, 螺丝, BATCH-01)); list.add(new MaterialDetailDto(M001, 螺丝, BATCH-01)); // 字段值完全相同但是new的新对象 list.add(new MaterialDetailDto(M002, 螺母, BATCH-01)); // 尝试使用 distinct 去重 ListMaterialDetailDto distinctList list.stream() .distinct() .collect(Collectors.toList()); System.out.println(原始列表大小: list.size()); // 输出: 3 System.out.println(去重后列表大小: distinctList.size()); // 输出: 3 (去重失败) } }这个例子清晰地展示了问题。两个materialCode为M001的对象没有被去重因为它们被认为是不同的对象。注意即使你使用了Lombok的Data注解它默认生成的equals()和hashCode()方法是基于所有非静态、非瞬态字段的。如果你希望根据所有字段进行整体去重那么Data是合适的distinct()也能正常工作。但我们的需求往往是“根据某个特定字段去重”而忽略其他字段的差异这就矛盾了。你不能为了一个场景的去重需求而改变整个类的全局相等性逻辑这可能会破坏其他依赖对象相等性的代码比如放入HashMap作为Key。所以Stream.distinct()不适合直接用于“根据对象某个字段去重”的场景除非你能接受并确保类的equals/hashCode就是按那个字段来定义的。在大多数业务实体类中这通常不是个好主意因为“业务主键”如物料编码和“对象相等性”并不总是等同。3. 实战方案一使用TreeSet与自定义比较器既然distinct()不行我们就要寻找其他途径。第一种经典且高效的方法是借助TreeSet。TreeSet是一个有序集合它不允许重复元素其判断重复的依据不是equals()而是我们提供的Comparator比较器。我们可以创建一个只比较目标字段如materialCode的Comparator。// 假设我们有一个包含重复 materialCode 的列表 ListMaterialDetailDto listWithDuplicates ...; // 从某处获取的列表 // 方案1: 使用 TreeSet 和自定义 Comparator SetMaterialDetailDto uniqueSet new TreeSet(Comparator.comparing(MaterialDetailDto::getMaterialCode)); uniqueSet.addAll(listWithDuplicates); // 将 Set 转换回 List (此时顺序可能变为按 materialCode 排序) ListMaterialDetailDto uniqueList1 new ArrayList(uniqueSet);优点逻辑清晰意图明确就是根据指定字段去重。时间复杂度尚可TreeSet的添加操作是O(log n)总体复杂度约为O(n log n)对于数据量不是特别巨大的场景比如几万条完全可以接受。缺点与坑点丢失原始顺序TreeSet会按照比较器的规则对元素进行排序。如果你需要保留元素在原始List中的首次出现顺序这个方法就不适用了。结果列表会是按materialCode排序的。“覆盖”问题当两个对象的materialCode相同时TreeSet只会保留其中一个。它依据比较器认为这两个元素“相等”因此后添加的不会成功。那么保留哪一个呢这取决于TreeSet的内部实现和添加顺序通常是不确定的。如果你需要指定保留策略比如保留第一个出现的或者保留某个字段值更大的单纯的TreeSet无法满足。内存与性能需要额外创建一个TreeSet数据结构。实操心得这个方法适用于去重后不关心顺序且任意保留一个重复项即可的场景。在早期的Java版本中这是很常见的做法。但在Java 8之后我们有更优雅的Stream方案。4. 实战方案二StreamCollectors.toMap模拟“保留首次出现”这是目前社区里比较推崇的一种方法它巧妙地利用了Collectors.toMap收集器。其核心思想是把List的每个元素映射为一个Map其中键Key是我们用于去重的字段如materialCode值Value是对象本身。在Map的合并函数merge function中我们指定当键冲突时即遇到重复字段保留先出现的值。import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; public ListMaterialDetailDto distinctByField(ListMaterialDetailDto list) { // 使用 toMapkey为去重字段value为对象本身当key冲突时保留第一个put的值 (v1) MapString, MaterialDetailDto map list.stream() .collect(Collectors.toMap( MaterialDetailDto::getMaterialCode, // Key Mapper: 提取作为key的字段 Function.identity(), // Value Mapper: 对象本身作为value (existingValue, newValue) - existingValue // Merge Function: 键冲突时保留已存在的(existing) )); // 将Map的values转换回List return new ArrayList(map.values()); }代码拆解与原理Collectors.toMap需要三个参数第一个参数KeyMapperMaterialDetailDto::getMaterialCode。这是一个方法引用告诉收集器用每个对象的materialCode字段值作为Map的键。第二个参数ValueMapperFunction.identity()。表示Map的值就是流中的元素对象本身。第三个参数MergeFunction(oldValue, newValue) - oldValue。这是一个BinaryOperator当两个元素的materialCode相同时即Map的键冲突这个函数会被调用。我们这里直接返回oldValue即先被处理的那个对象从而实现了“保留首次出现”的策略。如果你想保留最后一次出现的可以返回newValue。最终我们得到了一个MapString, MaterialDetailDto其中每个materialCode只对应一个MaterialDetailDto对象我们指定的那个。再将这个Map的values()转换为List就得到了去重后的列表。优点保留顺序标准的HashMaptoMap默认使用不保证顺序但Java 8的Collectors.toMap默认产生的Map是HashMap其values()的顺序是不确定的。但是我们可以通过使用LinkedHashMap来完美解决这个问题从而保留元素首次出现的原始顺序。MapString, MaterialDetailDto map list.stream() .collect(Collectors.toMap( MaterialDetailDto::getMaterialCode, Function.identity(), (v1, v2) - v1, LinkedHashMap::new // 第四个参数指定Map工厂使用LinkedHashMap )); // 此时 new ArrayList(map.values()) 的顺序就是原List中首次出现的顺序策略灵活合并函数让你可以自由决定冲突时保留哪一个例如比较两个对象的日期字段保留日期更晚的那个。一次流操作完成非常符合函数式编程的风格代码紧凑。缺点与注意事项空值Null处理Collectors.toMap的键Key不能为null。如果materialCode字段有可能为null直接使用会抛出NullPointerException。需要在KeyMapper中进行过滤或提供默认值。.collect(Collectors.toMap( dto - Objects.requireNonNullElse(dto.getMaterialCode(), DEFAULT_NULL), // 处理null key Function.identity(), (v1, v2) - v1, LinkedHashMap::new ))需要理解toMap的机制对于初学者这段代码的理解成本比TreeSet方案略高。这个方案是我目前最常用的尤其是在需要保留顺序的场景下。它平衡了简洁性、功能性和性能。5. 实战方案三使用filter与中间状态Set进行去重另一种直观的思路是我们遍历List并用一个HashSet来记录已经出现过的“键”。如果当前元素的键已经在Set中就过滤掉否则将其加入结果集并记录这个键。用Stream可以这样实现public ListMaterialDetailDto distinctByFieldWithFilter(ListMaterialDetailDto list) { SetString seen new HashSet(); return list.stream() .filter(dto - seen.add(dto.getMaterialCode())) // filter的妙用add成功返回true .collect(Collectors.toList()); }原理剖析 这行代码的精髓在于filter(dto - seen.add(dto.getMaterialCode()))。HashSet.add(key)方法在添加成功即集合中原本不包含此key时返回true添加失败即已存在时返回false。Stream.filter(Predicate)会保留谓词Predicate结果为true的元素。因此当处理一个新materialCode时seen.add(...)返回true该元素被保留当处理一个重复的materialCode时add失败返回false该元素被过滤掉。优点代码极其简洁一行filter搞定意图明确。保留首次出现顺序因为Stream是按顺序处理的filter会保留第一个使得谓词为true的元素。易于理解逻辑直白——“如果这个编码我没见过就留下并标记为已见”。缺点与重大坑点状态流Stateful Stream与并行流Parallel Stream的灾难这是这个方案最大的问题。HashSetseen是一个在流外部创建的可变状态并且在filter操作中被修改。这违反了Stream操作的无状态stateless原则。在串行流默认下它可能工作正常但一旦你将流改为并行.parallelStream()由于多个线程并发访问和修改同一个非线程安全的HashSet会导致不可预测的结果包括数据丢失、重复甚至抛出异常。// 危险并行流下行为未定义 list.parallelStream().filter(dto - seen.add(dto.getMaterialCode()))...空值处理同样如果materialCode为nullHashSet可以接受但你需要考虑业务上是否允许null也作为去重键。如果允许那么多个null值的对象只会保留第一个。实操心得与建议强烈不推荐在生产代码中使用此方案除非你能百分百保证该流永远不会被并行化并且团队所有成员都清楚这个隐患。它的简洁是带有巨大风险的。在代码审查中看到这种用法应该亮起红灯。相比之下方案二toMap是线程安全的并且能更清晰地表达“保留首次出现”的语义。6. 方案对比与选型指南什么场景用什么方法我们把上述几种方案连同最基础的distinct()放在一起做个对比方便你根据实际场景做选择。方案核心方法是否保留顺序去重依据线程安全/并行友好性能特点适用场景原生distinctstream().distinct()是按遇到顺序对象的equals()和hashCode()是O(n)需要根据整个对象的完全相等性去重且已正确重写equals/hashCodeTreeSetnew TreeSet(Comparator)否按比较器排序自定义Comparator是但注意Comparator的线程安全O(n log n)去重后不关心顺序或需要按去重字段排序且任意保留一个重复项Stream toMapCollectors.toMap(...)是配合LinkedHashMap自定义Key提取器是O(n)推荐。需要根据指定字段去重且需保留首次出现顺序策略灵活Filter HashSetfilter(e - set.add(key))是自定义Key提取器否并行流危险O(n)不推荐用于生产。仅用于理解概念或绝对串行且简单的临时脚本选型决策流程建议明确去重标准是基于整个对象还是基于一个或多个字段如果是字段这些字段能否为null明确顺序要求去重后的结果需要保持元素在原列表中的首次出现顺序吗明确保留策略当字段重复时你想保留第一个出现的还是最后一个或是需要根据其他字段如时间戳进行选择考虑数据规模与性能对于百万级以上的数据O(n log n)的TreeSet可能成为瓶颈O(n)的toMap或distinct更优。同时要考虑内存toMap会额外存储一份Map。考虑代码环境这段代码是否可能被并行流调用团队是否熟悉该模式的隐患对于绝大多数“根据对象某个字段去重并保留首次出现顺序”的需求方案二StreamCollectors.toMapLinkedHashMap是最佳选择。它清晰、安全、功能完整是现代Java代码的典范写法。7. 进阶与边界情况处理掌握了核心方案我们来看看一些更复杂或常见的边界情况如何处理。7.1 根据多个字段组合去重有时候去重键不是单个字段而是多个字段的组合。比如对于物料明细可能要根据materialCode和warehouseId仓库ID组合起来判断是否重复。这时KeyMapper就需要生成一个复合键。方法一使用字符串拼接简单但需注意分隔符MapString, MaterialDetailDto map list.stream() .collect(Collectors.toMap( dto - dto.getMaterialCode() _ dto.getWarehouseId(), // 复合键 Function.identity(), (v1, v2) - v1, LinkedHashMap::new ));注意要确保分隔符如_不会出现在字段值中否则可能产生错误的键。更稳妥的方法是使用String.join或String.format。方法二使用List或自定义对象作为键更规范// 使用List作为键 MapListObject, MaterialDetailDto map list.stream() .collect(Collectors.toMap( dto - Arrays.asList(dto.getMaterialCode(), dto.getWarehouseId()), Function.identity(), (v1, v2) - v1 // 注意LinkedHashMap的key可以是List )); // 或者定义一个简单的RecordJava 14或Pair类作为键 record MaterialKey(String code, String warehouseId) {} MapMaterialKey, MaterialDetailDto map list.stream() .collect(Collectors.toMap( dto - new MaterialKey(dto.getMaterialCode(), dto.getWarehouseId()), Function.identity(), (v1, v2) - v1 ));使用List或自定义键对象更清晰且避免了分隔符冲突问题但需要确保作为键的类正确实现了equals()和hashCode()List和Record已经实现。7.2 处理去重字段为null的情况如前所述toMap的键不能为null。如果字段可能为null必须在KeyMapper中处理。MapString, MaterialDetailDto map list.stream() .filter(dto - dto.getMaterialCode() ! null) // 方案A直接过滤掉key为null的项 .collect(Collectors.toMap( MaterialDetailDto::getMaterialCode, Function.identity(), (v1, v2) - v1, LinkedHashMap::new )); // 或者如果希望保留key为null的项只保留一个 MapString, MaterialDetailDto map list.stream() .collect(Collectors.toMap( dto - dto.getMaterialCode() null ? NULL_PLACEHOLDER : dto.getMaterialCode(), // 方案B替换null Function.identity(), (v1, v2) - v1, LinkedHashMap::new )); // 后续如果需要可以将NULL_PLACEHOLDER再转换回null但要注意Map中键的唯一性。选择方案A还是B取决于你的业务逻辑是否需要保留那些关键字段为null的记录。7.3 更复杂的保留策略不是保留第一个而是保留“最优”的那个合并函数(v1, v2) - v1让我们可以自由定义冲突解决策略。假设我们的MaterialDetailDto有一个updateTime字段我们想保留更新时间最新的那条记录。MapString, MaterialDetailDto map list.stream() .collect(Collectors.toMap( MaterialDetailDto::getMaterialCode, Function.identity(), (oldOne, newOne) - { // 比较两个重复项的更新时间返回更晚的那个 return oldOne.getUpdateTime().isAfter(newOne.getUpdateTime()) ? oldOne : newOne; }, LinkedHashMap::new ));这个合并函数会在每次键冲突时被调用比较已存在的值oldOne和新来的值newOne然后返回我们希望保留的那个。这提供了极大的灵活性。8. 回顾开头的坑我的问题最终是如何解决的回到文章开头我遇到的那个物料导出重复的问题。我的ListMaterialDetailDto数据来自多个服务调用虽然materialCode相同但其他辅助字段如supplierInfo可能不同导致对象不相等。我采用了方案二toMap因为我需要根据materialCode去重。我需要保留每个物料第一次出现在列表中的记录那通常是最主要或默认的数据源记录。数据量在几千条性能不是问题。代码需要清晰易懂便于后续维护。最终的修复代码类似这样// 获取原始数据列表 ListPMPrepareMaterialDetailExportDto rawData getPartFields(dto); // 根据物料编码去重保留首次出现记录 ListPMPrepareMaterialDetailExportDto distinctData rawData.stream() .collect(Collectors.toMap( PMPrepareMaterialDetailExportDto::getMaterialCode, Function.identity(), (first, later) - first, // 保留先来的 LinkedHashMap::new )) .values().stream().collect(Collectors.toList()); // 使用去重后的数据生成Excel // ... 后续的Excel生成逻辑插入这段去重逻辑后导出的Excel中每个物料编码就只对应一行数据了问题得以解决。这个经历让我深刻体会到即使是最基础的集合操作对细节的理解深度也直接决定了代码的健壮性和可维护性。Stream API给了我们强大的表达能力但同时也要求我们对它的行为有精准的把握。
返回列表