1. 为什么 distinct() 看似简单却在真实项目里频频翻车Java Stream 的distinct()方法名字起得特别直白——“去重”。刚学完集合操作的新手看到文档里那行stream.distinct().collect(Collectors.toList())心里大概会想“哦这不就是个语法糖嘛跟 Set 去重一个道理。”我当年也是这么想的直到在支付对账模块里用它处理一批含 23 万条交易记录的 List 结果返回了 229,998 条——少了两条但日志里没报错业务方反馈“数据对不上”整整排查了六小时。问题出在哪不是distinct()有 bug而是它背后藏着三个被绝大多数教程刻意忽略的“静默契约”对象必须正确重写equals()和hashCode()流中元素必须是不可变的或至少在去重过程中不被修改底层依赖的是LinkedHashSet的插入顺序与唯一性保障机制。这三个点任何一个没踩准distinct()就会变成一个优雅的“幽灵过滤器”——它确实执行了也确实返回了结果但结果是否符合你的业务语义它一概不管。比如你有一个User类只重写了equals()根据 id 判断相等却忘了同步更新hashCode()。distinct()内部用LinkedHashSet存储已见元素而HashSet查重时先比hashCode()再比equals()。如果两个逻辑上相等的User对象hashCode()不同它们就会被当成两个不同对象放进集合最终distinct()完全失效。这不是 Java 的缺陷而是你没履行契约义务。再比如你在流处理中边遍历边修改对象字段如user.setLastLoginTime(new Date())而distinct()正在用旧的hashCode()值做判断——这已经不是语义错误而是多线程安全级别的隐患。distinct()本身不保证线程安全但它要求你提供的数据源是“稳定”的。所以这篇文章不讲“怎么用”而是带你钻进distinct()的源码缝里看它如何调用LinkedHashSet::add看AbstractPipeline::wrapAndCopyInto如何把流元素喂给这个集合再结合真实生产环境里那些“数据少了一条”“重复数据没去掉”“性能突然暴跌”的案例把每个坑的土层、深度、救援方案都给你挖清楚。你不需要背 API你需要知道什么时候能放心交给distinct()什么时候必须亲手写Collectors.toMap()或TreeSet自定义比较器以及当监控告警响起时第一行该查什么日志、第二步该验哪个对象的hashCode()实现。2. distinct() 的底层机制不是魔法是一次精准的 LinkedHashSet 插入要真正掌控distinct()必须拆开它的外壳看清里面跑的是什么。很多人以为它是 Stream 自己实现的去重逻辑其实不然——distinct()是一个无状态中间操作stateless intermediate operation它本身不保存任何数据所有去重工作全部委托给了LinkedHashSet。这个设计非常关键它决定了distinct()的一切行为边界。我们从 JDK 17 的ReferencePipeline.java源码切入。distinct()方法的返回值是一个新的StatefulOp实例Override public final StreamP_OUT distinct() { return new DistinctOp(this); }而DistinctOp的核心在于其opWrapSink方法Override SuppressWarnings(unchecked) protected P_IN SinkP_IN opWrapSink(int flags, SinkP_OUT sink) { return new DistinctSink(sink); }真正的动作发生在DistinctSink::accept里Override public void accept(P_OUT t) { // 注意这一行它用的是 LinkedHashSet::add if (seen.add(t)) { sink.accept(t); } }这里的seen是一个LinkedHashSet实例初始化代码在DistinctSink构造函数中DistinctSink(Sink? super P_OUT sink) { super(sink); this.seen new LinkedHashSet(); }所以distinct()的全部逻辑就浓缩在这三行字里创建一个空的LinkedHashSet对流中每个元素t调用seen.add(t)如果add()返回true表示该元素之前未在集合中出现过则将t传递给下游sink否则丢弃。LinkedHashSet::add的行为又完全由其父类HashSet::add决定而HashSet::add的核心是HashMap::put。这意味着distinct()的去重效率直接取决于HashMap的哈希表性能——平均时间复杂度 O(1)最坏情况大量哈希冲突退化为 O(n)。提示distinct()的空间复杂度是 O(n)因为它需要额外的LinkedHashSet存储所有“已见”元素。如果你处理的是超大数据集如千万级日志行且内存受限distinct()可能直接触发OutOfMemoryError。此时必须考虑分片处理或外部排序去重而不是硬扛。LinkedHashSet的另一个关键特性是保持插入顺序。这也是为什么stream.distinct().collect(Collectors.toList())返回的列表元素顺序与原始流一致。HashSet无法保证顺序TreeSet虽然能去重但按自然序或自定义序排列打乱原始逻辑。LinkedHashSet在哈希表基础上加了一个双向链表完美兼顾了唯一性与顺序性——这正是distinct()设计的精妙之处。但这也带来一个隐性成本LinkedHashSet的内存占用比普通HashSet略高因为要维护链表节点。在极端性能敏感场景如高频实时风控规则匹配这个开销可能被放大。不过对于绝大多数业务系统这个代价完全可以接受。3. 三大经典翻车现场从日志报错到数据静默丢失distinct()的坑往往不体现在编译错误或运行时异常上而是以“数据不对”这种最折磨人的形式出现。下面三个案例全部来自我过去三年参与的金融、电商、SaaS 项目的真实排障记录每一个都曾让团队加班到凌晨。3.1 场景一实体类只重写 equals()hashCode() 还是 Object 默认实现现象某电商平台的商品 SKU 列表List 经stream.distinct()处理后本应合并的同一商品不同规格如颜色、尺码依然被当作不同对象保留导致前端展示重复商品卡片。排查过程第一步确认Sku类是否重写了equals()和hashCode()。public class Sku { private Long id; private String skuCode; private String name; // ... 其他字段 Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; Sku sku (Sku) o; return Objects.equals(id, sku.id); // 仅用 id 判断相等 } // 注意这里没有重写 hashCode() }第二步验证hashCode()行为。Sku sku1 new Sku(1001L, SKU-001, iPhone 15); Sku sku2 new Sku(1001L, SKU-002, iPhone 15 Pro); System.out.println(sku1.hashCode()); // 输出123456789随机值 System.out.println(sku2.hashCode()); // 输出987654321另一个随机值即使id相同sku1和sku2的hashCode()也不同因为继承自Object默认是内存地址哈希。LinkedHashSet::add先计算hashCode()发现不同直接认为是不同对象根本不会调用equals()。修复方案必须同步重写hashCode()且逻辑与equals()保持一致。使用 IDE 自动生成如 IntelliJ 的AltInsert→Generate hashCode() and equals()是最稳妥的方式Override public int hashCode() { return Objects.hash(id); // 仅 hash id 字段 }注意Objects.hash()内部会调用id.hashCode()。如果id是Long其hashCode()就是id.longValue()的 int 截断值完全可预测。切忌手动写return id.intValue();这会导致Long.MAX_VALUE和Long.MIN_VALUE的hashCode()冲突。3.2 场景二Stream 中元素是可变对象且在 distinct() 前已被修改现象某银行对账系统读取 CSV 文件生成Transaction对象流需按transactionId去重。但distinct()后仍有重复transactionId出现且重复条数不稳定。根因分析CSV 解析后Transaction对象被放入一个ListTransaction然后通过list.stream()创建流。问题在于解析过程中某些Transaction对象的transactionId字段被上游服务动态修改过如添加渠道前缀而distinct()执行时LinkedHashSet存储的是修改后的对象。但由于hashCode()是基于原始transactionId计算的hashCode()在对象创建时已确定不会随字段修改而改变add()判断失败。更隐蔽的是distinct()是惰性求值的。当你调用stream.distinct().collect(...)时整个流才开始执行。如果在collect()之前有其他代码修改了List中的Transaction对象distinct()看到的就是被篡改后的状态。验证方法在distinct()前加一行日志打印第一个和第二个相同transactionId的对象的hashCode()ListTransaction transactions parseCsv(); System.out.println(Before distinct: transactions.get(0).hashCode()); System.out.println(Before distinct: transactions.get(1).hashCode()); ListTransaction unique transactions.stream().distinct().collect(Collectors.toList());如果两个hashCode()不同但transactionId相同基本可以锁定此问题。修复方案首选确保Transaction是不可变对象Immutable。所有字段final构造函数一次性赋值不提供 setter。这样hashCode()和equals()的结果从创建起就稳定。次选如果必须可变确保hashCode()和equals()仅依赖final字段或在对象生命周期内绝不修改参与hashCode()计算的字段。临时补救在distinct()前对List做一次深拷贝隔离外部修改影响ListTransaction safeCopy transactions.stream() .map(Transaction::new) // 假设 Transaction 有拷贝构造函数 .collect(Collectors.toList()); ListTransaction unique safeCopy.stream().distinct().collect(Collectors.toList());3.3 场景三distinct() 与 parallelStream() 混用引发不可预测的并发问题现象某物流公司的运单轨迹查询接口为提升响应速度将ListTrackingEvent转为parallelStream()并调用distinct()。压测时发现相同请求偶尔返回不同数量的结果有时 5 条有时 6 条且无任何异常日志。原理剖析parallelStream()会将数据分割成多个子流并行处理。distinct()的内部LinkedHashSet seen是一个共享的、非线程安全的对象。当多个线程同时调用seen.add(t)时LinkedHashSet的add()方法继承自HashSet不是线程安全的。JDK 文档明确警告“If multiple threads access a hash set concurrently, and at least one of the threads modifies the set, it must be synchronized externally.”实际发生的是线程 A 和线程 B 几乎同时处理两个hashCode()相同的TrackingEvent。LinkedHashSet::add的底层HashMap::put在并发下可能丢失更新导致seen.add(t)对其中一个线程返回false误判为已存在而另一个线程返回true误判为新元素。结果就是本该去重的两个事件一个被丢弃一个被保留或者两个都被保留。验证方式将parallelStream()强制改为stream()问题消失。这是最快速的定位手段。修复方案绝对禁止parallelStream().distinct()组合。这是反模式。替代方案一推荐先用parallelStream()做耗时计算如解析、转换再转回串行流去重ListTrackingEvent processed events.parallelStream() .map(this::enrichEvent) // 耗时的 enrich 操作 .collect(Collectors.toList()); // 收集为 List ListTrackingEvent unique processed.stream().distinct().collect(Collectors.toList());替代方案二大数据量使用线程安全的收集器如Collectors.toConcurrentMap()MapString, TrackingEvent map events.parallelStream() .collect(Collectors.toConcurrentMap( TrackingEvent::getEventId, // key: 去重依据 Function.identity(), // value: 对象本身 (e1, e2) - e1 // 冲突时保留第一个 )); ListTrackingEvent unique new ArrayList(map.values());4. 比 distinct() 更可靠、更灵活的五种去重策略当distinct()因对象契约、并发或业务逻辑复杂而变得不可靠时你需要一套“备胎方案”。这些方案不是为了炫技而是为了解决distinct()无法覆盖的真实战场。4.1 方案一Collectors.toMap() —— 基于任意字段的精准去重distinct()只能基于对象整体相等性去重而业务中常需“按某个字段去重保留最新/最早的一条”。Collectors.toMap()是最优雅的解法。场景用户操作日志ListLogEntry需按userId去重且保留timestamp最大的那条即用户最后一次操作。MapLong, LogEntry latestLogMap logs.stream() .collect(Collectors.toMap( LogEntry::getUserId, // key: 去重依据 Function.identity(), // value: 日志对象 (log1, log2) - log1.getTimestamp().isAfter(log2.getTimestamp()) ? log1 : log2 // 冲突解决保留时间更新的 )); ListLogEntry latestLogs new ArrayList(latestLogMap.values());优势字段自由key 可以是任何表达式如log.getOrderId() - log.getEventType()支持复合去重。逻辑可控merge function(log1, log2) - ...可以写任意业务逻辑如“保留金额最大的”、“保留状态为 SUCCESS 的”。性能优秀底层是HashMapO(1) 平均查找且无需额外LinkedHashSet开销。注意toMap()返回的Map不保证顺序。如果需保持原始流顺序用Collectors.toMap()的四参数重载指定LinkedHashMap::new作为 mapSupplierMapLong, LogEntry map logs.stream() .collect(Collectors.toMap( LogEntry::getUserId, Function.identity(), (l1, l2) - l1.getTimestamp().isAfter(l2.getTimestamp()) ? l1 : l2, LinkedHashMap::new // 保证插入顺序 ));4.2 方案二TreeSet Comparator —— 按自定义规则排序去重当去重依据不是简单的相等而是“按某种顺序只留第一个”时TreeSet是利器。场景商品价格列表ListPrice需按productId分组每组内只保留effectiveDate最早的有效价格。// 先按 productId 分组 MapLong, ListPrice groupedByProduct prices.stream() .collect(Collectors.groupingBy(Price::getProductId)); // 对每组价格用 TreeSet 排序并取第一个 MapLong, Price earliestPrice new HashMap(); for (Map.EntryLong, ListPrice entry : groupedByProduct.entrySet()) { TreeSetPrice sortedSet new TreeSet((p1, p2) - p1.getEffectiveDate().compareTo(p2.getEffectiveDate()) ); sortedSet.addAll(entry.getValue()); earliestPrice.put(entry.getKey(), sortedSet.first()); }优势天然排序TreeSet基于红黑树插入即排序first()/last()时间复杂度 O(1)。规则灵活Comparator 可以写任意复杂逻辑如“先比状态状态相同时比时间”。劣势时间复杂度 O(n log n)比HashMap的 O(n) 稍慢。需要手动分组代码略长。4.3 方案三自定义 Collector —— 完全掌控去重逻辑当以上方案都无法满足时如需去重同时统计重复次数就得祭出终极武器自定义Collector。public class DistinctWithCountT implements CollectorT, MapT, Integer, MapT, Integer { Override public SupplierMapT, Integer supplier() { return LinkedHashMap::new; // 保持顺序 } Override public BiConsumerMapT, Integer, T accumulator() { return (map, t) - map.merge(t, 1, Integer::sum); // 计数 } Override public BinaryOperatorMapT, Integer combiner() { return (map1, map2) - { map2.forEach((t, count) - map1.merge(t, count, Integer::sum)); return map1; }; } Override public FunctionMapT, Integer, MapT, Integer finisher() { return Function.identity(); } Override public SetCharacteristics characteristics() { return EnumSet.of(Characteristics.IDENTITY_FINISH); } } // 使用 MapString, Integer countMap strings.stream() .collect(new DistinctWithCount()); // countMap 的 key 是去重后的字符串value 是出现次数优势极致灵活你可以把去重、计数、聚合、甚至写入数据库等逻辑全部封装在一个 Collector 里。可复用定义一次到处可用符合 DRY 原则。门槛需要理解Collector的四个核心方法supplier, accumulator, combiner, finisher适合中高级开发者。4.4 方案四数据库层面去重 —— 大数据量的终极归宿当数据量达到百万、千万级且来源是数据库时把去重逻辑下推到数据库是性能最优解。场景订单表orders需查询所有唯一的customer_id。-- 用 DISTINCT最简单 SELECT DISTINCT customer_id FROM orders; -- 或用 GROUP BY可扩展如同时查客户总数 SELECT customer_id, COUNT(*) as order_count FROM orders GROUP BY customer_id;优势IO 最小化数据库在磁盘/内存中完成去重只返回结果集网络传输量极小。索引加速如果customer_id有索引DISTINCT性能极佳。实践建议在 MyBatis 中直接写 SQL不要用ListOrder orders mapper.selectAll(); orders.stream().distinct()...。对于复杂去重如“每个客户最新一笔订单”用窗口函数SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY create_time DESC) rn FROM orders ) t WHERE t.rn 1;4.5 方案五Guava 的 Equivalence —— 为不可修改类定制相等性当你无法修改第三方类如org.joda.time.DateTime的equals()/hashCode()但又需要按特定字段去重时Guava 的Equivalence是救星。import com.google.common.base.Equivalence; // 定义一个按日期年月日相等的 Equivalence EquivalenceDateTime dateOnlyEquivalence new EquivalenceDateTime() { Override protected boolean doEquivalent(DateTime a, DateTime b) { return a.withTimeAtStartOfDay().equals(b.withTimeAtStartOfDay()); } Override protected int doHash(DateTime t) { return t.withTimeAtStartOfDay().hashCode(); } }; // 使用 Equivalence.wrappedList() 包装流 ListDateTime uniqueDates dates.stream() .collect(Collectors.collectingAndThen( Collectors.toList(), list - { SetEquivalence.WrapperDateTime wrappedSet new LinkedHashSet(); ListDateTime result new ArrayList(); for (DateTime dt : list) { Equivalence.WrapperDateTime wrapper dateOnlyEquivalence.wrap(dt); if (wrappedSet.add(wrapper)) { result.add(dt); } } return result; } ));优势零侵入无需修改目标类即可定义任意相等逻辑。类型安全Equivalence.Wrapper是泛型的编译期检查。前提需引入 Guava 依赖com.google.guava:guava。5. 生产环境 checklist上线前必须验证的七件事distinct()用得好是锦上添花用得不好是定时炸弹。以下是我整理的、在每次涉及distinct()的代码上线前必须逐项核对的 checklist。它不是教条而是用血泪换来的经验。序号检查项验证方法不通过后果我的实操备注1equals()和hashCode()是否成对重写在 IDE 中右键类 →Generate→equals() and hashCode()确认两者都存在且逻辑一致。检查hashCode()是否只依赖equals()中用到的字段。distinct()完全失效所有对象都被视为不同。我习惯在hashCode()方法上加注释// MUST match equals() on field X提醒自己和同事。2参与hashCode()计算的字段是否为final查看字段声明。如果不是final检查是否有 setter以及 setter 是否在distinct()流执行期间被调用。hashCode()值变化导致distinct()行为不可预测。对于必须可变的业务对象我强制要求hashCode()只基于id主键计算因为主键永不变更。3流的数据源是否会被外部修改检查distinct()前的所有代码特别是List的引用是否被其他线程或方法持有并修改。数据静默丢失或重复难以复现。我的团队约定所有传入 Stream 的List在stream()调用后立即Collections.unmodifiableList()封装杜绝意外修改。4是否在parallelStream()中使用了distinct()全局搜索parallelStream().distinct()或distinct().parallelStream()。结果随机压测必现线上偶发。我们在 SonarQube 中配置了自定义规则禁止此组合CI 阶段直接失败。5去重后的数据量是否符合预期编写单元测试用固定数据集含已知重复项验证distinct()前后数量。例如assertThat(original.size()).isEqualTo(10); assertThat(unique.size()).isEqualTo(7);上线后业务方投诉“数据少了”。我的测试数据集包含 3 种重复模式完全相同对象、equals()相同但hashCode()不同、hashCode()相同但equals()不同。6内存占用是否在安全阈值内用 JProfiler 或 VisualVM 监控distinct()执行时的堆内存增长。估算LinkedHashSet的大小n * (object_size hash_entry_overhead)。OutOfMemoryError服务宕机。我们规定单次distinct()处理的数据量上限为 10 万条。超过则必须分页或改用数据库去重。7是否有兜底的日志或监控在distinct()后添加日志log.info(Distincted {} items to {}, originalSize, unique.size());并在 Prometheus 中埋点监控distinct_ratio。问题发生时缺乏第一手线索排查时间翻倍。我们把distinct_ratio去重后/去重前作为一个核心 SLO 指标低于 0.95 时自动告警。这个 checklist 的价值不在于它有多复杂而在于它把模糊的“感觉”转化成了可执行、可验证的动作。每一次勾选都是对线上稳定性的一次加固。记住distinct()的简洁是以你对契约的严格履行为前提的。你省下的每一行代码都可能在未来某个深夜变成你不得不面对的告警。我在实际使用中发现最有效的预防措施不是写更多代码而是把distinct()的使用场景标准化。比如我们团队内部约定所有distinct()操作必须配合一个DistinctBy注解标注在方法上说明去重依据如DistinctBy(id)或DistinctBy(userId, timestamp)并强制要求对应的单元测试覆盖。这看似增加了几行代码却让所有成员对去重逻辑一目了然彻底杜绝了“这个 distinct 是按什么去的”这种低效沟通。技术的成熟往往就藏在这些微小的、可落地的约定里。