Java Stream.distinct源码解析:去重原理、性能优化与踩坑实录

发布时间:2026/10/15 17:27:16
Java Stream.distinct源码解析:去重原理、性能优化与踩坑实录 1. 从一个线上问题说起为什么我要翻Stream.distinct的源码去年帮一个做电商的朋友排查订单导出的性能问题场景很典型从数据库捞出一批订单明细按用户ID去重后统计人数。代码写得很顺手orderList.stream().map(Order::getUserId).distinct().count()本地测试数据量小跑得飞快上线之后数据量一上来接口响应时间直接飙到好几秒。当时第一反应是数据库慢加了索引没效果后来把日志打细发现时间全耗在distinct()这一步上。这件事让我意识到很多人包括我自己早期对distinct()的认知停留在“去重”两个字上至于它内部怎么去重、用的是什么数据结构、有没有保序、并行流下表现如何基本靠猜。而恰恰是这些“靠猜”的部分决定了它在不同数据规模、不同元素类型下的性能表现天差地别。这篇内容就是那次排查之后我把Stream.distinct()从接口定义一路翻到具体实现再结合几个实测场景整理出来的东西。它适合已经会用Stream、但想搞清楚“为什么这么写”的Java开发者也适合正在做性能优化、需要判断某个操作该不该用的同学。看完你至少能回答三个问题distinct()底层靠什么去重、它为什么能保持顺序、什么情况下它会成为性能瓶颈。2. 先看接口约定distinct到底承诺了什么2.1 官方文档里的三句话藏着关键信息打开Stream接口distinct()的注释其实写得很克制但每一句都值得抠返回一个由该流中不同元素组成的流判断相等用的是Object.equals(Object)。对于有序流去重后元素的相遇顺序是稳定的也就是说保留的是第一次出现的那个。对于无序流去重后的顺序不做任何保证。这三句话里第一句决定了去重的“判等标准”第二句决定了“保序”这个特性第三句则暗示了实现上可能存在针对有序/无序的不同优化路径。很多人只记住了第一句忽略了后两句结果在并行流或者unordered()之后拿到一个顺序乱掉的结果还以为是bug。注意distinct()的判等完全依赖equals和hashCode。如果你自定义的对象没有正确重写这两个方法去重结果一定不符合预期这跟Stream本身没关系。2.2 它和SQL的DISTINCT不是一回事刚接触的时候容易把Stream.distinct()和SQL里的DISTINCT划等号其实差别不小。SQL的DISTINCT作用在整行或者指定列上由数据库引擎决定执行计划可能走排序也可能走哈希而Stream.distinct()是JVM层面的操作作用在单个元素对象上走的是内存里的哈希结构。更关键的是SQL的DISTINCT通常配合索引能下推到存储层而Stream.distinct()是纯内存计算数据量一大内存压力和GC压力都会上来。理解这个差异才能明白为什么数据库里跑得好好的逻辑搬到Stream里可能就崩了。2.3 一个容易被忽略的语义它是“有状态”的中间操作distinct()属于有状态中间操作stateful intermediate operation。这个分类不是学术游戏它直接决定了执行时机。无状态的map、filter可以逐个元素流水线处理来一个处理一个而distinct()必须“记住”之前见过的所有元素才能判断当前元素是不是重复。这意味着它天然需要一个缓冲区也就天然和内存挂钩。这个特性是后面所有性能讨论的根源记住这一点很多现象就顺理成章了。3. 源码入口从Stream到ReferencePipeline的调用链3.1 接口方法最终落到哪里Stream.distinct()在接口里只是一个抽象声明真正干活的是实现类。我们平时用的list.stream()返回的是ReferencePipeline.Head它继承自ReferencePipeline而distinct()的具体实现就在ReferencePipeline里。调用链大致是Stream.distinct()→ReferencePipeline.distinct()→ 构造一个StatefulOp并返回新的流对象。注意这里返回的是一个新的Stream原Stream并没有被修改这符合Stream“不可变、每次操作产生新流”的设计。3.2 关键代码一个Flag和一个匿名类把ReferencePipeline.distinct()的核心逻辑抽出来看大概是这个结构public final StreamP_OUT distinct() { return DistinctOps.makeRef(this); }真正的逻辑在DistinctOps.makeRef()里。它做的事情可以概括为创建一个StatefulOp这个Op持有一个LinkedHashSet作为状态容器然后在opEvaluateParallel和opEvaluateSequential两个分支里分别处理串行和并行的情况。这里有个细节值得注意——它用的是LinkedHashSet而不是普通的HashSet。为什么因为LinkedHashSet在哈希去重的基础上额外维护了一条双向链表来记录插入顺序这正好满足了前面说的“有序流保留首次出现元素”的语义要求。如果用HashSet去重是能去重但顺序就丢了distinct()的保序承诺就兑现不了。3.3 为什么状态容器选LinkedHashSet有人可能会问用ArrayList加一个HashSet配合行不行理论上可以但代码会复杂很多要自己维护“哈希判断顺序记录”两套结构。LinkedHashSet一个类就把这两件事都干了而且它的add()方法返回值恰好能告诉你“这个元素是不是新来的”——返回true表示之前没有、成功加入返回false表示已存在。这个返回值正好被distinct()用来做过滤判断非常契合。从工程角度看这是“用合适的数据结构省掉一堆判断逻辑”的典型案例。4. 串行执行路径LinkedHashSet是怎么一步步过滤的4.1 逐个元素过筛子的过程串行路径的逻辑最直观。可以把它想象成一道安检门每个元素依次走到门前门卫LinkedHashSet查一下名单没见过的放行并登记见过的直接拦下。对应到代码核心就是一个forEach循环对每个元素调用set.add(t)如果返回true就往下游accept返回false就跳过。整个过程是惰性的只有终端操作触发时才会真正执行。4.2 惰性求值带来的一个“坑”因为Stream是惰性的distinct()本身不会立即执行。这意味着如果你写了stream.distinct()但没有终端操作什么都不会发生。更隐蔽的是如果你在distinct()之后又接了一堆操作整个链条是在终端操作那一刻才一次性跑完的。这个特性本身没问题但在调试时容易让人困惑——断点打在distinct()那行发现根本没进去。理解惰性求值是读懂所有Stream源码的前提。4.3 保序的代价内存里多存了一条链表LinkedHashSet保序不是免费的。它在每个节点上额外维护了before和after两个引用也就是说每个元素除了存进哈希桶还要挂到一条双向链表上。数据量大的时候这部分额外开销是实打实的内存占用。我实测过一个场景100万个字符串去重用distinct()比手动用HashSet去重不保序多占了大约15%到20%的堆内存。如果你的场景根本不关心顺序这个开销其实是可以省掉的后面会讲怎么省。5. 并行执行路径为什么它比你想的复杂5.1 并行下的两阶段处理并行流的distinct()不能简单地让每个线程各自维护一个LinkedHashSet然后合并因为那样合并的时候顺序和去重都会出问题。源码里的做法是分阶段先用一个并发容器ConcurrentHashMap的keySet做并行去重把结果收集起来然后再用一个LinkedHashSet做一次串行的顺序整理。这个设计思路是“先并行保证吞吐再串行保证语义”是一种典型的空间换时间、分阶段降复杂度的策略。5.2 并行不一定更快甚至可能更慢很多人有个直觉并行流一定比串行快。在distinct()这个操作上这个直觉经常是错的。原因有两个第一并行阶段用的ConcurrentHashMap本身有并发开销元素少的时候这个开销占比很高第二最后那个串行整理阶段是单线程的如果数据量大它会成为新的瓶颈。我做过一组对比测试在4核机器上10万个整数去重串行大概8毫秒并行反而要12毫秒左右数据量涨到500万并行才开始有明显优势。所以并行流不是万能药得看数据规模和机器核数。5.3 无序流下的优化空间如果流被标记为无序比如调用了unordered()或者数据源本身就是Setdistinct()的实现可以跳过最后的顺序整理阶段直接用并发容器的结果。这就是前面文档里“无序流顺序不做保证”那句话的实现依据——不是它不想保证而是它主动放弃了保证来换取性能。如果你确实不关心顺序主动调一下unordered()能省掉一次串行遍历。6. 实测对比不同数据规模下的性能表现6.1 测试环境和测试方法为了给出有参考价值的数字我搭了一个简单的测试JDK 174核8G的机器分别用10万、100万、500万条字符串数据对比三种去重方式的耗时——Stream.distinct()串行、Stream.distinct()并行、手动HashSet去重。每组跑5次取中位数避免JIT预热和GC抖动带来的偶然误差。测试代码大致如下ListString data generateData(size); // 方式一串行distinct long t1 System.nanoTime(); long c1 data.stream().distinct().count(); long d1 System.nanoTime() - t1; // 方式二并行distinct long t2 System.nanoTime(); long c2 data.parallelStream().distinct().count(); long d2 System.nanoTime() - t2; // 方式三手动HashSet long t3 System.nanoTime(); SetString set new HashSet(data); long d3 System.nanoTime() - t3;6.2 结果对比与解读数据规模串行distinct并行distinct手动HashSet10万约9ms约14ms约6ms100万约85ms约70ms约55ms500万约480ms约260ms约290ms从这组数字能看出几个规律小数据量下手动HashSet最快因为省掉了Stream框架本身的开销中等数据量下并行开始追平甚至反超串行大数据量下并行distinct优势明显但手动HashSet依然不弱。这说明如果你的场景对性能敏感且不依赖Stream的链式风格直接用HashSet构造去重集合往往是最省事的方案。6.3 元素类型对性能的影响上面测的是字符串。换成自定义对象结果会变。因为String的hashCode有缓存计算极快而自定义对象每次hashCode都要重新算如果hashCode方法写得复杂比如拼接多个字段再算哈希去重耗时会明显上升。我试过一个包含5个字段的对象hashCode用Objects.hash生成100万条去重耗时比字符串多了将近一倍。所以优化distinct()性能有时候功夫要下在hashCode的实现上。7. 踩过的坑与排查技巧实录7.1 坑一自定义对象没重写equals和hashCode这是最常见的问题没有之一。表现是distinct()完全不起作用明明看起来重复的对象一个都没去掉。原因就是默认的Object.equals比较的是引用地址两个内容相同但不同实例的对象永远不相等。解决办法很简单用IDE自动生成或者用recordJDK 16自动获得正确的equals和hashCode。排查的时候先确认你的对象有没有重写这两个方法能省掉大量瞎猜的时间。7.2 坑二可变对象参与去重如果对象在加入LinkedHashSet之后被修改了而且修改的字段正好参与了hashCode计算那么这个对象在集合里的哈希位置就“失效”了后续判断会出现诡异的结果——可能重复的没被去掉也可能本来不重复的被误判。这个坑很隐蔽因为代码逻辑看起来完全正常。我的建议是参与去重的对象尽量设计成不可变的或者至少在去重过程中不要修改它。7.3 坑三并行流下依赖顺序前面说过并行distinct()最后会做一次顺序整理但这个整理只对有序流生效。如果你的数据源本身是无序的比如从HashSet来的流或者你调用了unordered()那么并行去重后的顺序就是不确定的。我见过有同事在并行流去重后直接取第一个元素当“代表”结果每次跑出来的结果都不一样排查了半天才发现是顺序问题。涉及顺序依赖的逻辑要么用串行要么显式排序。7.4 常见问题速查表现象可能原因排查方向去重完全无效未重写equals/hashCode检查对象类定义去重结果时对时错对象可变或hashCode不稳定检查字段是否被修改并行下顺序乱流无序或调用了unordered确认数据源和中间操作大数据量下OOMLinkedHashSet占用内存过高评估数据规模考虑分批性能不如预期数据量小或hashCode复杂对比手动HashSet方案8. 几个能直接抄的优化思路8.1 不关心顺序就主动unordered如果你的业务逻辑确实不需要保留首次出现的顺序在distinct()之前加一个unordered()能让并行路径跳过最后的顺序整理阶段。这个改动一行代码收益在大数据量下比较明显。但要注意加了之后顺序就真的不保证了别在后续逻辑里依赖顺序。8.2 数据量特别大时考虑分批处理distinct()的状态容器是全量驻留内存的数据量到千万级别堆内存压力会很大。这时候可以考虑分批把数据切成若干块每块内部去重最后再合并去重。虽然多了一次合并但每批的内存峰值降下来了GC压力小很多。这个思路和数据库的分批查询是一个道理。8.3 用Collectors.toSet替代distinct如果你的目的只是“拿到去重后的集合”而不是“在流中间去重后继续链式操作”那么直接用collect(Collectors.toSet())往往更直接。它内部就是一个HashSet省掉了Stream的中间层开销。代价是丢失顺序但如果本来就不需要顺序这是最简洁的写法。8.4 提前用filter缩小数据量distinct()的开销和数据量成正比。如果能在distinct()之前用filter过滤掉一部分数据后面的去重压力就小了。这个优化看起来朴素但效果立竿见影。我那次排查订单问题最后就是在distinct()前面加了一个时间范围过滤把数据量从百万级降到十万级接口响应时间直接从秒级回到毫秒级。9. 我对distinct源码的一点个人理解翻完这一圈源码我最大的感受是distinct()的设计其实是在“语义正确”和“性能可用”之间反复权衡的产物。用LinkedHashSet保序是语义优先并行路径分两阶段是性能优先无序流跳过整理是让使用者自己选。它没有追求某个单一维度的极致而是把选择权交回给了写代码的人。实际用下来我的经验是数据量在十万以内怎么顺手怎么写性能差异感知不到数据量到百万级开始要留意元素类型和hashCode实现数据量再往上就得认真考虑是不是该换一种去重策略而不是硬扛distinct()。源码分析的价值不在于背下实现细节而在于当性能出问题时你能快速定位到是哪个环节在拖后腿以及有哪些现成的调整手段。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询