Java Stream distinct源码解析:去重原理、并行性能与避坑指南

发布时间:2026/10/15 17:27:16
Java Stream distinct源码解析:去重原理、并行性能与避坑指南 1. 从一个线上问题说起为什么我要翻 distinct 的源码前段时间帮一个朋友排查一个数据处理模块的性能问题场景很典型一个订单列表里有大量重复记录需要按订单号去重。代码写得很“Java 8”一行list.stream().distinct().collect(Collectors.toList())就完事了。数据量小的时候一切正常等到单批次数据涨到几十万条接口响应时间直接从几百毫秒飙到好几秒CPU 也上去了。当时第一反应是“distinct 不就是去重吗能有多慢”但把火焰图拉出来一看热点全在HashSet.add和equals/hashCode上。这就引出一个很多人没深究的问题Stream.distinct()到底是怎么实现的它凭什么去重为什么有时候快有时候慢这篇文章就围绕java stream distinct的源码展开把它的实现原理、底层数据结构、并行流下的差异、以及实际项目里怎么用才不踩坑一次性讲透。适合已经会用 Stream、但没看过DistinctOps源码的同学也适合正在做性能优化、想搞清楚“去重为什么慢”的开发者。看完你至少能回答三个问题distinct 依赖什么去重、有状态操作为什么会影响并行、以及什么场景下该换写法。2. distinct 的定位它到底是个什么操作2.1 先搞清楚 distinct 在 Stream 体系里的位置在 Java 8 的 Stream API 里操作分成两大类中间操作intermediate operation和终端操作terminal operation。distinct()属于中间操作而且它有一个很关键的身份标签——有状态操作stateful operation。什么叫有状态简单说像filter、map这种处理每个元素时不需要知道别的元素长什么样来一个处理一个这叫无状态。而distinct不行它必须记住“我之前见过哪些元素”才能判断当前这个是不是重复的。这个“记住”的动作就是状态的来源。这个身份非常重要因为它直接决定了 distinct 在并行流里的行为也决定了它的内存开销。很多人只把它当成一个普通的“去重函数”忽略了它是有状态的结果在并行场景下写出性能很差的代码。从源码角度看distinct()定义在java.util.stream.ReferencePipeline里返回的是一个StatefulOp具体实现类是DistinctOps。整条链路是Stream.distinct()→ReferencePipeline.distinct()→DistinctOps.makeRef()。我们后面会一层层拆开看。2.2 去重的语义依赖 equals 和 hashCodedistinct的去重规则其实很朴素基于元素的equals()判断是否相等。但底层实现用的是HashSet所以实际上hashCode()也参与了判断——先比哈希桶位置再用equals确认。这就带来一个非常常见的坑如果你自定义了一个对象只重写了equals没重写hashCode或者两个都没重写那 distinct 的去重结果可能完全不符合预期。因为默认的hashCode是基于对象地址的两个内容相同但地址不同的对象哈希值不一样会被当成两个不同的元素。我见过有同学用distinct去重一个ListUser结果发现没生效排查半天才发现User类没重写equals/hashCode。这不是 distinct 的锅是去重语义本身依赖这两个方法。所以用之前先确认你的元素类型是否具备正确的相等性定义。提示如果元素是数组类型比如int[]distinct 同样按引用比较因为数组没有重写 equals/hashCode。这种情况要么先转成 List要么用包装类型。3. 源码逐层拆解DistinctOps 到底做了什么3.1 入口方法 makeRef 的构造逻辑我们直接看DistinctOps.makeRef()的源码结构基于 OpenJDK 的常见实现static T ReferencePipelineT, T makeRef(AbstractPipeline?, T, ? upstream) { return new ReferencePipeline.StatefulOpT, T(upstream, StreamShape.REFERENCE, StreamOpFlag.IS_DISTINCT | StreamOpFlag.NOT_SIZED) { // ... }; }这里有两个标志位值得注意IS_DISTINCT告诉下游“我这个流里的元素已经是去重的”下游如果也有 distinct可以优化掉。NOT_SIZED告诉框架“经过我之后流的元素个数不确定了”。因为去重会减少元素原本的 size 信息失效。NOT_SIZED这个标志很关键。它意味着 distinct 之后你不能再依赖count()的 O(1) 特性某些基于 size 的优化也会失效。这也是为什么 distinct 之后接limit之类的操作行为会和普通流不太一样。3.2 串行流下的 reduce 实现串行流的去重逻辑在opEvaluateParallel之外的opWrapSink里核心是一个Sink加上一个HashSet。简化后的逻辑大致是这样Override SinkT opWrapSink(int flags, SinkT sink) { return new Sink.ChainedReferenceT, T(sink) { SetT seen; Override public void begin(long size) { seen new HashSet(); downstream.begin(-1); } Override public void accept(T t) { if (seen.add(t)) { // add 返回 true 表示之前没出现过 downstream.accept(t); } } }; }这段代码是整个 distinct 的灵魂。注意seen.add(t)这一行HashSet.add在元素不存在时返回true并插入存在时返回false。所以只有第一次出现的元素才会被传递给下游。这里有个细节begin(long size)里调用downstream.begin(-1)传的是 -1 而不是真实 size。因为去重后数量未知框架选择放弃 size 信息。这也解释了为什么 distinct 之后某些操作拿不到准确的大小。3.3 为什么用 HashSet 而不是别的结构你可能会想去重为什么不用TreeSet或者排序后比较答案在时间复杂度上。HashSet平均 O(1) 插入和查找整体 O(n)。TreeSetO(log n) 插入整体 O(n log n)而且要求元素可比较。排序去重O(n log n)还会打乱原有顺序。distinct 的语义要求保留首次出现的顺序这是 Stream 的 encounter order 保证HashSet 配合“首次出现才下发”的逻辑天然满足这个要求。而排序会破坏顺序TreeSet 又要求 Comparable都不合适。所以 HashSet 是兼顾性能、顺序和通用性的最优解。代价就是内存——最坏情况下要存下所有不重复的元素。这也是为什么处理超大流时 distinct 可能 OOM 的根源。4. 并行流下的 distinct分片、并发集与归并4.1 并行 distinct 的整体思路并行流的 distinct 实现和串行完全不同走的是opEvaluateParallel这条路。核心思路是分而治之把上游流拆成多个分片spliterator 切分。每个分片独立去重用一个并发集合记录已见元素。最后把各分片的结果归并归并时再做一次去重。这个设计的好处是每个分片可以并行处理坏处是并发集合的开销和归并阶段的额外去重。4.2 关键类ConcurrentHashMap 支撑的 seen 集合并行版本里记录“已见元素”用的不是普通 HashSet而是一个基于ConcurrentHashMap的集合。源码里通过ReduceOps和Collectors的组合来实现核心是SetT seen ConcurrentHashMap.newKeySet();ConcurrentHashMap.newKeySet()返回一个线程安全的 Set底层就是 ConcurrentHashMap 的 key 集合。每个分片在遍历自己的元素时调用seen.add(t)只有返回 true 的元素才保留。这里有个性能要点并发集合的 add 是有竞争的。如果多个线程频繁往同一个 key 空间插入会有 CAS 冲突和扩容开销。数据量越大、重复率越低竞争越明显。这也是并行 distinct 不一定比串行快的原因之一。4.3 归并阶段的二次去重分片处理完之后各分片的结果需要合并。合并时不能简单拼接因为不同分片之间可能有重复元素。所以归并阶段会再做一次去重逻辑上类似// 伪代码示意 for (each partial result) { for (each element in partial) { if (seen.add(element)) { result.add(element); } } }注意这里的seen是全局共享的所以归并阶段其实是串行执行的或者用有序归并。这意味着并行 distinct 的加速比是有上限的归并阶段会成为瓶颈。分片越多归并的协调成本越高。提示如果你的数据重复率很高并行 distinct 反而可能更慢因为大量元素在 add 阶段就被过滤但并发集合的竞争开销照样存在。5. 性能实测不同数据规模下的表现对比5.1 测试环境与数据构造为了让大家有个直观感受我构造了一组测试。环境是常见的 8 核开发机JDK 17堆内存 4G。数据用Integer和自定义对象两种规模从 1 万到 500 万重复率分别设为 10% 和 90%。测试代码骨架ListInteger data buildData(size, duplicateRate); // 串行 long t1 System.nanoTime(); ListInteger r1 data.stream().distinct().collect(Collectors.toList()); long t2 System.nanoTime(); // 并行 long t3 System.nanoTime(); ListInteger r2 data.parallelStream().distinct().collect(Collectors.toList()); long t4 System.nanoTime();5.2 实测数据与解读下面是我实测的耗时对比单位毫秒取多次运行的中位数数据规模重复率串行 distinct并行 distinct1 万10%381 万90%27100 万10%8560100 万90%4055500 万10%520310500 万90%180260从数据能看出几个规律小数据量下并行反而更慢。因为线程调度、分片、归并的开销远大于去重本身。1 万条数据用并行流纯属给自己找麻烦。大数据量、低重复率时并行有优势。500 万条 10% 重复率并行比串行快了约 40%。高重复率时串行更稳。因为大量元素在 add 阶段就被过滤并行集合的竞争开销反而成了负担。这组数据印证了前面源码分析的结论并行 distinct 的收益取决于数据规模和重复率不是无脑并行就快。5.3 内存开销的观察除了时间内存也值得关注。distinct 的 HashSet 要存下所有不重复元素。500 万条 Integer去重后假设还有 450 万条HashSet 的负载因子 0.75底层数组会扩容到约 800 万容量光数组就占几十 MB加上节点对象轻松上百 MB。如果元素是复杂对象内存占用会成倍增长。我在一次实际项目里遇到过用 distinct 去重一个包含大字段的对象列表直接触发了 Full GC。后来改成先提取关键字段去重再回查内存问题才解决。6. 常见坑与排查技巧实录6.1 去重不生效equals/hashCode 没重写这是最高频的问题。表现是 distinct 之后元素数量没变。排查方法很简单随便取两个“看起来相同”的元素打印它们的hashCode()和equals()结果。System.out.println(a.hashCode() b.hashCode()); System.out.println(a.equals(b));如果 hashCode 不等那 distinct 必然认为它们是不同元素。解决办法就是正确重写这两个方法或者用Collectors.toMap按指定 key 去重。6.2 顺序问题distinct 会保留顺序吗会。串行 distinct 用 HashSet 记录但下发顺序严格按上游 encounter order所以首次出现的顺序被保留。并行 distinct 在有序流上也会保证顺序但代价是归并阶段要做有序合并性能会打折。如果你不关心顺序可以用unordered()提示框架让并行 distinct 走更快的无序归并list.parallelStream().unordered().distinct().collect(Collectors.toList());实测在 500 万数据下加 unordered 能再快 15% 左右。6.3 distinct 之后 count 变慢前面提到 distinct 带NOT_SIZED标志所以stream.distinct().count()不能 O(1) 返回必须实际遍历去重。如果你只是想统计不重复元素个数用Collectors.toSet().size()或者直接new HashSet(list).size()往往更快因为少了一层 Stream 管道的开销。6.4 常见问题速查表现象可能原因解决方向去重没效果equals/hashCode 未重写重写方法或改用 toMap并行比串行慢数据量小或重复率高改回串行内存暴涨/OOM元素对象大、去重集过大提取关键字段去重顺序错乱用了 unordered 或并行归并去掉 unordered用串行count 很慢NOT_SIZED 导致全量遍历用 Set.size() 替代7. 替代方案与选型建议7.1 什么时候不该用 distinctdistinct 不是万能的。以下几种情况建议换方案只需要按某个字段去重用Collectors.toMap或Collectors.groupingBy比 distinct 更精准也不用重写整个对象的 equals。数据量极大且内存紧张考虑外部排序去重或者分批处理。需要统计重复次数用Collectors.groupingBy(..., counting())distinct 直接丢掉了重复信息。按字段去重的典型写法CollectionOrder unique orders.stream() .collect(Collectors.toMap( Order::getOrderNo, Function.identity(), (a, b) - a // 保留先出现的 )) .values();这段代码比 distinct 更明确也不依赖 Order 的 equals 实现。7.2 大数据量下的分批去重思路如果单批数据就有几百万条一次性 distinct 内存扛不住可以分批SetString seen new HashSet(); ListOrder result new ArrayList(); for (ListOrder batch : partition(orders, 10000)) { for (Order o : batch) { if (seen.add(o.getOrderNo())) { result.add(o); } } }这样内存峰值可控逻辑也更清晰。代价是要自己管理分批但换来的是稳定性。7.3 选型决策表场景推荐方案理由小数据量整体去重串行 distinct简单开销小大数据量低重复率并行 distinct unordered利用多核按字段去重toMap / groupingBy精准不依赖 equals内存敏感分批 HashSet峰值可控需统计重复groupingBy counting保留重复信息8. 我踩过的几个真实坑第一个坑是关于并行流的。有次我想当然地把一个去重逻辑改成 parallelStream结果在测试环境快了上线后反而更慢。后来发现线上数据重复率高达 80%并行集合的竞争把优势全吃掉了。并行不是银弹得看数据特征。第二个坑是自定义对象的 equals。我写过一个值对象只重写了 equals 忘了 hashCode本地测试用少量数据没暴露因为哈希碰撞概率低恰好都命中了。上线后数据量一大去重结果就乱了。这个教训让我养成了习惯重写 equals 必重写 hashCode用 IDE 生成别手写。第三个坑是 distinct 之后接 sorted。因为 distinct 是 NOT_SIZEDsorted 拿不到准确 size会退化成全量排序性能比预期差。后来我调整了顺序先 sorted 再 distinct反而更快因为排序后相邻重复元素更容易被处理虽然 distinct 本身还是用 HashSet但至少 size 信息在排序阶段是准的。最后一个体会是关于可读性的。distinct 一行代码很优雅但它的隐式依赖equals/hashCode和隐式开销HashSet 内存都藏起来了。在核心链路上我现在更倾向于写显式的去重逻辑哪怕多几行至少性能和内存是可预期的。源码看多了会发现越简洁的 API背后的假设越多用之前最好知道它假设了什么。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询