
简介一份专门讲解Java中List集合去重技巧的PDF资料面向有一定Java基础的开发者系统梳理了对象整体去重与按属性去重共8种实现方案。资料从HashSet、Stream distinct、Iterator配合Set到自定义Comparator、LambdaMap及通用方法均有覆盖并配有可运行代码示例与初始化数据说明方便读者理解各方法的适用场景、顺序保持能力及equals、hashCode对去重结果的影响。资源为单个PDF文件约190KB便于下载后随时查阅。目前已有29236人学习适合日常开发中需要处理集合去重、或准备Java面试的开发者参考。1. 对象列表去重真正难的是按属性去重一次接口排查中我发现订单列表里出现了两条“用户Id、商品Id、下单时间完全一致”的记录问题出在上游 join 后重复返回。SQL 加上 distinct 后内存里从 List 聚合数据时又遇到了同样的问题实体类没重写 equalsHashSet 居然去不掉重复对象。这个标题里说的“List集合对象去重及按属性去重”本质上要解决两件事一是让 JVM 判断“相同”的标准从内存地址变成业务字段二是把根据某个属性比如 userId去重的逻辑写得不丢顺序、不崩空指针。文章整理了 8 种写法前 4 种是全量去重后 4 种是按属性去重从 HashSet 到 Stream 的自定义谓词覆盖日常开发和面试八股文里最常被问到的实现方式。下文会给出每种方法的完整代码、参数说明和适用边界最后落到项目里的封装与验证。2. 全量去重HashSet、LinkedHashSet、Stream.distinct、contains 四种写法全量去重的前提是目标对象已经正确重写了equals()和hashCode()。很多人在“对象数组去重”场景里直接new HashSet(list)发现结果长度没变就是因为实体类还停留在 Object 的默认判等逻辑上只要不是同一个内存地址equals 永远返回 false。先看四种写法再解释它们各自的时间复杂度和顺序表现。2.1 HashSet 去重快但结果顺序不保证ListOrder uniqueOrders new ArrayList(new HashSet(orderList));这段代码里new HashSet(orderList)会先遍历原集合把每个元素放入 HashSet。放入时先计算hashCode()定位到哈希桶再用equals()比较同桶内的元素。重复对象添加失败最终 HashSet 里只剩一份。放到ArrayList构造器是为了把集合类型转回 List。这个写法时间复杂度是 O(n)性能最好但有两个限制第一顺序不可预期HashMap 的遍历顺序受桶数组长度和元素 hash 分布影响第二如果实体类重写了equals()却没重写hashCode()两个业务字段相同的对象会落到不同的桶去重完全失效。所以实体类里这两个方法必须同步重写标准做法是通过 IDE 生成不要手写。2.2 LinkedHashSet 去重同一套 API保留首次出现顺序ListOrder uniqueOrders new ArrayList(new LinkedHashSet(orderList));与 HashSet 的唯一区别是 LinkedHashSet 内部额外维护了一条双向链表记录元素的插入顺序。首次出现的元素会保留在链表里重复元素即使 add 失败链表的顺序也不受影响所以最终结果是从原列表首次出现顺序排列的。对于数据量不大、又不想引入 Stream 的代码这是最稳的写法。代价是内存占用比 HashSet 高一些因为每个节点都要存前驱后继指针。2.3 Stream.distinct() 去重链式写法里最顺手ListOrder uniqueOrders orderList.stream() .distinct() .collect(Collectors.toList());distinct()内部基于 LinkedHashSet 实现能记住第一次出现的元素因此保留顺序。它不会修改原列表适合已经走在 Stream 调用链里,后面还要接着 map、filter 的场景。需要留意的是distinct()是中间操作只有遇到终止操作比如collect才会真正执行去重循环。如果只在.stream().distinct()后没有收集结果去重不会发生。2.4 循环 contains老代码里的保险写法ListOrder result new ArrayList(); for (Order order : orderList) { if (!result.contains(order)) { result.add(order); } }contains(order)底层调用的还是equals()每次调用都会把已有元素逐个比较一遍整体时间复杂度退化为 O(n²)。数据量超过一万条时明显变慢但它不依赖任何 JDK 8 之后的语法对 Java 5 老项目友好。实际项目中我一般只在拿不到 stream、且集合规模很小百条以内时才用这种写法。写法时间复杂度顺序是否需要重写 equals/hashCodeJDK 版本HashSetO(n)不保证必须1.2LinkedHashSetO(n)首次出现顺序必须1.4Stream.distinctO(n)首次出现顺序必须1.8循环 containsO(n²)首次出现顺序必须全部提示四种写法对“相同”的定义都来自实体类的equals()。如果你的实体不想全局重写 equals又想按某些字段去重直接跳到后面按属性去重的方法。3. 按属性去重TreeSet、collectingAndThen、toMap 与 null 值处理按属性去重不依赖equals()而是临时指定“哪个字段相同就算重复”。以下三种是实际项目里出现频率最高的写法最后专门说 null 值这个隐藏坑。3.1 TreeSet Comparator让排序规则顺带判重// 按 userId 去重顺便按 userId 排序 SetOrder seen new TreeSet(Comparator.comparing(Order::getUserId)); seen.addAll(orderList); ListOrder result new ArrayList(seen);TreeSet 在add元素时使用 Comparator 做比较比较结果返回 0就认为两个元素重复新元素不会被放入。这里Comparator.comparing(Order::getUserId)返回的是按 userId 升序的比较器所以最终得到的列表并不是原顺序而是按 userId 重新排过序的。这个写法适合“去重后正好需要按该属性排序”的场景。它有一个隐患如果比较器只按 userId 判断而两个 userId 相同的对象其他字段不同丢弃的是后加入的那个。如果需要控制保留哪一条就要在比较器里再加一层判断比如Comparator.comparing(Order::getUserId).thenComparing(Order::getCreatedAt)这样 userId 相同但创建时间不同的对象会被视为不同达不到按属性去重的目的。所以用 TreeSet 时比较规则要的就是“唯一键”本身。3.2 collectingAndThen toCollectionStream 链中一步完成ListOrder result orderList.stream() .collect(Collectors.collectingAndThen( Collectors.toCollection( () - new TreeSet(Comparator.comparing(Order::getUserId))), ArrayList::new));collectingAndThen接收两个参数下游收集器toCollection和最终转换函数ArrayList::new。整条链的含义是先把元素收集进一个按 userId 排序的 TreeSet完成去重再用ArrayList::new把 TreeSet 转成 List。与 3.1 相比它不需要提前创建 Set 对象适合写在一整条 Stream 管道里比如前面已经有 filter、map 操作时。转换函数在收集完成后只执行一次所以把 TreeSet 转成 ArrayList 的代价是 O(n)。结果同样按 userId 排序而不是原顺序。3.3 Collectors.toMap用“键”强制只留一条ListOrder result new ArrayList(orderList.stream() .collect(Collectors.toMap( Order::getUserId, Function.identity(), (existing, replacement) - existing)) .values());Collectors.toMap第一个参数是键提取函数这里用Order::getUserId第二个参数是值提取函数Function.identity()表示元素本身第三个参数是合并策略当两个元素的 userId 相同时(existing, replacement) - existing表示保留先出现的那个。默认情况下 toMap 内部使用 HashMap所以结果的遍历顺序不保证而且values()返回的是 Collection不是 List需要包一层ArrayList。如果既要按属性去重又要保留原顺序可以加上第四个参数LinkedHashMap::newListOrder result new ArrayList(orderList.stream() .collect(Collectors.toMap( Order::getUserId, Function.identity(), (existing, replacement) - existing, LinkedHashMap::new)) .values());加上这个参数后键值对按照遇到的顺序放进 LinkedHashMap取values()时就是业务对象在原始列表中的首次出现顺序了。3.4 null 属性会炸三条细节必须记住按属性去重最先踩的坑就是属性为 null。TreeSet 的Comparator.comparing(Order::getUserId)在对两个 null 比较时会调用compareTo从而抛 NullPointerExceptiontoMap 的 key 提取函数返回 null 时HashMap 允许 null key所以同样去重条件下不会立刻报错但如果你把结果放入某些有序结构后续还是会出问题。处理 null 有一个通用办法比较器指定 null 排序位置。ComparatorOrder byUserId Comparator.comparing( Order::getUserId, Comparator.nullsFirst(Comparator.naturalOrder()));nullsFirst表示 null 值排在非 null 前面这样既不会抛异常也稳定了排序规则。如果你希望 null 属性直接被丢弃可以在进入去重前加一次filter(obj - obj.getUserId() ! null)。注意这里不能先过滤再按属性去重否则你会丢失“存在 null 属性”这条数据本身项目里要对这层语义做区分。4. 第 8 种写法distinctByKey 谓词与复合属性 Key 模型前面几种按属性去重的写法各有各的别扭TreeSet 会改变顺序toMap 写完很长。还有一种更符合 Stream 表达习惯的做法是定义一个可复用的distinctByKey谓词java 面试题里基本都会用这个方法作为 Stream 进阶考察点。4.1 用 ConcurrentHashMap 实现泛型 distinctByKeypublic static T, U PredicateT distinctByKey( Function? super T, ? extends U keyExtractor) { MapU, Boolean seen new ConcurrentHashMap(); return t - seen.putIfAbsent(keyExtractor.apply(t), Boolean.TRUE) null; }使用时只要一行ListOrder result orderList.stream() .filter(distinctByKey(Order::getUserId)) .collect(Collectors.toList());这段代码的逻辑是每次来一个元素先通过keyExtractor.apply(t)取出它的 useridputIfAbsent方法把 userid 作为 key 放入 Mapvalue 固定为 Boolean.TRUE如果 userid 之前不存在返回 null谓词返回 true元素保留如果 userid 之前已经出现过返回旧值 TRUE谓词返回 false元素被过滤掉。这里有几个参数和实现细节值得展开? super T用的是下界通配符表示 keyExtractor 可以处理 T 或其父类型的输入可读性上更严谨。ConcurrentHashMap是为了支持 parallelStream 并发流。如果用HashMap多线程同时执行putIfAbsent时同一键可能被插入多次去重结果会出错。并发场景下 put 和 putIfAbsent 都是原子操作这也是该方法成为标准答案的原因。谓词是有状态的同一个distinctByKey返回的谓词不能复用于两个不同的 Stream 去重流程。如果你想对两个列表分别按 userId 去重需要调用两次方法各自生成独立的 Map。4.2 复合属性去重从字符串拼接改为 Key 对象按一个属性去重没问题按“省份 城市 渠道”这种复合维度去重时新手容易写成.filter(distinctByKey(p - p.getProvince() - p.getCity()))字符串拼接的问题是分隔符冲突。省会是“成都-东区”、城市是“站前”之类组合时拼出来的键可能与另一组不同维度的数据相同。更合适的方式是定义一个私有的 Key 类重写 equals 和 hashCodeprivate static class AreaKey { private final String province; private final String city; AreaKey(String province, String city) { this.province province; this.city city; } Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof AreaKey)) return false; AreaKey that (AreaKey) o; return Objects.equals(province, that.province) Objects.equals(city, that.city); } Override public int hashCode() { return Objects.hash(province, city); } }使用的时候filter(distinctByKey(p - new AreaKey(p.getProvince(), p.getCity())))。本质上是用不可变对象的等价性来表达复合键避免字符串拼接的歧义。如果你用的 Java 16 以上可以直接定义成 recordequals 和 hashCode 自动生成可读性更好。4.3 distinctByKey 与 TreeSet 方案的边界distinctByKey 保留原始顺序TreeSet 按比较器排序这是两者最明显的分界。如果业务要求“按 userId 去重之后还要按创建时间倒序展示”单独用 TreeSet 是做不到的因为比较器一旦掺入时间字段userId 就不再是唯一键。distinctByKey 则把“去重键”和“排序”两个关注点拆开了先用谓词按 userId 过滤再用独立的Comparator.comparing(...).reversed()做 sorted 排序。这一点在代码评审里是加分项因为去重规则的变化不影响排序排序规则的变化也不影响去重。另一个边界是内存TreeSet 会把完整对象留在 Set 里distinctByKey 的 Map 只存“键 → Boolean 标记”如果对象的字段很多、键很窄后者的内存占用更小。5. 落地封装去重工具类、耗时验证与选型建议一次性把 8 种方法都写在业务代码里会让调用方难以维护。通常我会把其中两种沉淀成静态方法放进工具类distinctByKey负责按属性去重byOrderedIdentity负责全量去重保序。5.1 去重工具类的最小封装public final class DistinctUtil { private DistinctUtil() { } public static T, U PredicateT distinctByKey( Function? super T, ? extends U keyExtractor) { MapU, Boolean seen new ConcurrentHashMap(); return t - seen.putIfAbsent(keyExtractor.apply(t), Boolean.TRUE) null; } public static T ListT fetchDistinctList(CollectionT source) { return new ArrayList(new LinkedHashSet(source)); } public static T, U ListT filterDistinct( CollectionT source, Function? super T, ? extends U keyExtractor) { return source.stream() .filter(distinctByKey(keyExtractor)) .collect(Collectors.toList()); } }调用方只需要区分场景List 里的对象已经正确重写了 equals用fetchDistinctList需要按某个属性去重用filterDistinct(orderList, Order::getUserId)。参数 keyExtractor 建议写成方法引用而不是 lambda因为方法引用对 IDE 的调用链追踪更友好重构时如果字段改名编译器能直接报错。5.2 用 100 万条数据验证去重耗时与结果顺序ListOrder orders new ArrayList(1_000_000); for (int i 0; i 1_000_000; i) { orders.add(new Order(i % 100_000, order- (i % 100_000))); } long start System.nanoTime(); ListOrder distinct DistinctUtil.filterDistinct(orders, Order::getUserId); long cost System.nanoTime() - start; System.out.println(去重后条数: distinct.size() 耗时: (cost / 1_000_000) ms);这段验证代码里i % 100_000保证 userId 只有 10 万种原始数据是 100 万条理论上会去掉 90 万条。算出的耗时是用 System.nanoTime 前后相减得到的适合做相邻两次跑动的对比参考不适合统计精确执行时间。想更严谨就在测试框架里跑 5 次取中间值同时留意 JVM 预热。如果发现distinct.size()等于原长度先查实体类有没有重写 hashCode再看 keyExtractor 提取的属性是不是存在 null。5.3 按数据特征的选择建议与两个检查点数据特征推荐写法理由实体已重写 equals无需顺序HashSet一次构造完成O(n)实体已重写 equals需要顺序LinkedHashSet / distinct保留首次出现顺序数量小且 JDK 老循环 contains无新 API 依赖按单属性去重恰好要排序TreeSet comparing去重与排序一次完成Stream 链式写好了一半collectingAndThen不破坏管道结构保留原顺序的按属性去重toMap LinkedHashMap合并函数可以控制保留新旧按多字段组合去重distinctByKey Key 对象复合键语义最清晰两个检查点都要在代码评审时过一遍第一ConcurrentHashMap 的 key 类型必须稳定且 equals 实现正确如果用可变的 DTO 字段做 key字段后被修改会破坏 Map 的查找结构第二toMap的合并函数(existing, replacement) - existing保留的是先遇到的数据如果你想取“最新一条”合并函数应改为(existing, replacement) - replacement这个差异在接口幂等处理里会直接决定返回值到底是谁。把DistinctUtil放进 common 模块后以后遇到订单列表、用户标签、接口透传数据的去重都先确认唯一键是什么再决定调用哪一个方法。本文还有配套的精品资源点击获取