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 Stream<P_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(),或者数据源本身就是Set),distinct()的实现可以跳过最后的顺序整理阶段,直接用并发容器的结果。这就是前面文档里“无序流顺序不做保证”那句话的实现依据——不是它不想保证,而是它主动放弃了保证来换取性能。如果你确实不关心顺序,主动调一下unordered(),能省掉一次串行遍历。
6. 实测对比:不同数据规模下的性能表现
6.1 测试环境和测试方法
为了给出有参考价值的数字,我搭了一个简单的测试:JDK 17,4核8G的机器,分别用10万、100万、500万条字符串数据,对比三种去重方式的耗时——Stream.distinct()串行、Stream.distinct()并行、手动HashSet去重。每组跑5次取中位数,避免JIT预热和GC抖动带来的偶然误差。测试代码大致如下:
List<String> 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(); Set<String> set = new HashSet<>(data); long d3 = System.nanoTime() - t3;6.2 结果对比与解读
| 数据规模 | 串行distinct | 并行distinct | 手动HashSet |
|---|---|---|---|
| 10万 | 约9ms | 约14ms | 约6ms |
| 100万 | 约85ms | 约70ms | 约55ms |
| 500万 | 约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自动生成或者用record(JDK 16+)自动获得正确的equals和hashCode。排查的时候,先确认你的对象有没有重写这两个方法,能省掉大量瞎猜的时间。
7.2 坑二:可变对象参与去重
如果对象在加入LinkedHashSet之后被修改了,而且修改的字段正好参与了hashCode计算,那么这个对象在集合里的哈希位置就“失效”了,后续判断会出现诡异的结果——可能重复的没被去掉,也可能本来不重复的被误判。这个坑很隐蔽,因为代码逻辑看起来完全正常。我的建议是:参与去重的对象尽量设计成不可变的,或者至少在去重过程中不要修改它。
7.3 坑三:并行流下依赖顺序
前面说过并行distinct()最后会做一次顺序整理,但这个整理只对有序流生效。如果你的数据源本身是无序的(比如从HashSet来的流),或者你调用了unordered(),那么并行去重后的顺序就是不确定的。我见过有同事在并行流去重后直接取第一个元素当“代表”,结果每次跑出来的结果都不一样,排查了半天才发现是顺序问题。涉及顺序依赖的逻辑,要么用串行,要么显式排序。
7.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 去重完全无效 | 未重写equals/hashCode | 检查对象类定义 |
| 去重结果时对时错 | 对象可变或hashCode不稳定 | 检查字段是否被修改 |
| 并行下顺序乱 | 流无序或调用了unordered | 确认数据源和中间操作 |
| 大数据量下OOM | LinkedHashSet占用内存过高 | 评估数据规模,考虑分批 |
| 性能不如预期 | 数据量小或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()。源码分析的价值不在于背下实现细节,而在于当性能出问题时,你能快速定位到是哪个环节在拖后腿,以及有哪些现成的调整手段。