简介:Java中List集合的去重及按属性去重是编程中的常见需求,这份资料以PDF文档形式整理了8种实现方法,覆盖HashSet、LinkedHashSet、Stream distinct、TreeSet、自定义Comparator、Map与Lambda表达式等写法,既包含对象整体去重,也针对对象属性去重给出可复用方案,适合Java初学者系统学习,也适合开发者在编码时对照选用。压缩包仅1个PDF文件,大小约190KB,内容精炼,示例代码可直接运行。已有29236人浏览学习,口碑较好。文档按“整体去重”和“按属性去重”两个维度展开,结合重复数据初始化示例对比各方法的去重效果、顺序保持情况及适用场景,并提醒equals和hashCode的正确实现,能帮助读者快速掌握不同去重方式的优劣,提升实际开发效率。
1. 从重复数据到去重:List 去重先分清楚整体和属性
上周一个数据同步任务抛给我一个问题:接口返回的 List 里塞了一万条用户信息,里面既有整行重复,也有 userId 相同但年龄不同的数据。直接new HashSet<>(list)只能解决整行重复,解决不了按 userId 保留一条的需求。Java 里 List 去重最常用的 8 种写法,正好分成两类:按集合元素整体去重,以及按对象某个(或某几个)属性去重。前者的核心是 equals 和 hashCode,后者的核心是 Comparator 或断言过滤。这 8 种方法拆开看,既能应付 java 基础面试题里的手写题,也能直接用在数据清洗、导入去重、批量任务这些真实场景里,关键是先搞清楚你的去重维度是什么。
2. 整体去重的四种姿势:从 HashSet 到 Stream distinct 与顺序取舍
在写代码前,先记住一个前提:整体去重是以“对象内容”为单位的。List<String>不用额外处理;List<Player>一定要确认 Player 重写了 equals 和 hashCode。如果没有重写,两个 name、age 都一样的对象也会被HashSet当成不同元素,去重后一个不少。这不是方法的问题,是对象相等性定义的问题。
2.1 HashSet 构造器:最短但会打乱顺序
List<String> newList = new ArrayList<>(new HashSet<>(list)); System.out.println("去重后的集合:" + newList);HashSet底层是HashMap,key 是元素本身,value 是统一常量。添加元素时先用 hashCode 定位桶,再用 equals 确认是否重复。把 List 传给 HashSet 构造器,重复元素自然只剩一个;再把 Set 转回 ArrayList,就是去重后的结果。
代价是HashSet的默认迭代顺序不等于插入顺序,所以输出是[kobe, james, zimug, curry],和原 List 的[kobe, james, curry, zimug]不一致。如果对顺序没有要求,这是速度最快的写法之一。如果还需要排序,可以换成new TreeSet<>(list),字符串按字母表排序,对象按 Comparable 实现排序,但同样不保持原顺序。
2.2 Stream.distinct:有序流保留第一次出现的位置
List<String> newList = list.stream() .distinct() .collect(Collectors.toList()); System.out.println("去重后的集合:" + newList);Stream.distinct()依赖元素的 equals 方法做去重,和HashSet的语义一致。对于ArrayList这种有序数据源,distinct()会保留第一次出现的位置,所以输出是[kobe, james, curry, zimug],原顺序没有被破坏。
这里有一个常见误区:网上不少文章说distinct()会打乱顺序,多半是先把数据放进了HashSet再转成 Stream。真正容易踩的坑是collect会生成一个新 List,如果你在原 List 上做大循环,注意别把引用覆盖掉。
2.3 Set.add 返回值:既能去重又能保序的经典写法
Set<String> set = new HashSet<>(); List<String> newList = new ArrayList<>(); for (String str : list) { if (set.add(str)) { // 重复时返回 false newList.add(str); } } System.out.println("去重后的集合:" + newList);Set.add的返回值表示“这次添加是否改变了集合”。如果元素已经存在,返回 false;如果第一次出现,返回 true。利用这个返回值,一个循环里同时完成“记录已见元素”和“收集去重结果”两件事。
这段代码是 java 基础面试里经常让手写的版本,比单纯new HashSet更能体现对集合接口的理解。注意不要写成先if (!set.contains(str))再set.add(str),虽然也能跑,但多了两次哈希查找,语义反而不如返回值清晰。
2.4 List.contains:简单但复杂度高
List<String> newList = new ArrayList<>(); for (String cd : list) { if (!newList.contains(cd)) { newList.add(cd); } } System.out.println("去重后的集合:" + newList);contains的内部实现是遍历元素并调用 equals,每加入一个元素都从头扫一遍,整体时间复杂度是 O(n^2)。n 等于 1 万时大约要执行 5000 万次 equals,n 等于 10 万时会到百亿级别,List 越大越失控。
这个方法只适合数据量很小、或者当前环境不方便创建 Set 的场景。生产环境里的批量去重不建议用,这也是为什么下面要按数据规模选型。
2.5 整体去重选型依据
| 方法 | 是否保持原顺序 | 时间复杂度 | 适用场景 |
|---|---|---|---|
| HashSet 构造器 | 否 | O(n) | 数据量大、顺序无所谓 |
| Stream.distinct | 是 | O(n) | 默认首选,语义清晰 |
| Set.add + 新 List | 是 | O(n) | 面试手写、需要在循环里做额外处理 |
| List.contains | 是 | O(n^2) | 只有几十个元素的小集合 |
如果 Player 没有重写 equals 和 hashCode,上面四种整体去重都会失效。排查时先写一个assertEquals(expectedCount, newList.size())的测试,再去检查对象类的两个方法是否都重写了。
3. 按属性去重:TreeSet 与 Comparator 的使用边界
实际开发中碰得更多的需求是:整行重复的不多,同一个 name 下有多条 age 不同的数据,要保留其中一条。整体去重帮不上忙,因为 age 不同的 Player 在 equals 下本来就不同,必须换一套按属性去重的规则。
3.1 TreeSet 用 Comparator 判定重复
Set<Player> playerSet = new TreeSet<>(Comparator.comparing(Player::getName)); playerSet.addAll(playerList); new ArrayList<>(playerSet).forEach(System.out::println);TreeSet内部是TreeMap,插入元素时通过 Comparator 的 compare 方法决定元素放在红黑树的哪个位置;当 compare 返回 0 时,就认为 key 已经存在,新元素不会进入集合。所以“按 name 去重”的本质是告诉 TreeSet 两个 Player 什么时候算重复。
Comparator.comparing(Player::getName)等价于手写 lambda:
Set<Player> playerSet = new TreeSet<>((o1, o2) -> o1.getName().compareTo(o2.getName()));两种写法结果一致:三个 zimug 因为 name 重复,只保留第一个 age 为 27 的;同时元素顺序不再按原 List,而是按 name 排序。如果需求是“按 name 去重并且按 name 排序”,这个方法最直接。
这里有一个容易被忽略的坑:name 为 null 时,compareTo会抛空指针。如果业务里 name 可能为 null,需要先用Comparator.nullsFirst(String::compareTo)包装,或者在进入 TreeSet 前过滤掉 null。
3.2 用 collectingAndThen 把去重塞进 Stream
List<Player> newList = playerList.stream() .collect(Collectors.collectingAndThen( Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(Player::getName))), ArrayList::new)); newList.forEach(System.out::println);Collectors.collectingAndThen接收两个参数:下游收集器和结果转换函数。这里下游是Collectors.toCollection,它把流元素收进一个 TreeSet 并按 Comparator 去重;转换函数用ArrayList::new把 TreeSet 再转回 List。
结果和 3.1 完全一样,区别只是把中间变量藏进了 Stream 链条。如果你的代码已经在一个stream()管道里,不想单独声明Set<Player>,这种写法可以让整段逻辑保持链式。但可读性确实差一些,队伍里有新人维护时容易被绕晕。
3.3 Comparator 语义不等于 equals
一旦给 TreeSet 传了 Comparator,集合里的“重复”就和 equals/hashCode 不再一致。同一个 Set,contains判断一个 name 相同但 age 不同的对象可能返回 true,而这个对象用 equals 判断又是 false。
所以在用 TreeSet 做按属性去重时,最好把结果当成“排序过的去重视图”来用,不要再把它塞回依赖 equals 的 HashMap key 或另一个 Set 里做二次判断。另一个注意点是:放进 TreeSet 后不要修改参与排序的属性,否则红黑树的结构和去重结果都会乱掉。
4. 保持顺序的按属性去重:Predicate distinctByKey 与多字段组合
TreeSet 会改变元素顺序,但在报表导出、数据同步、接口透传场景里,顺序是下游接口的约定,不能因为去重被改变。这时候distinctByKey是一种比 TreeSet 更合适的方案,它只做保留,不做排序。
4.1 distinctByKey 谓词的完整写法
@Test void testRemove7() { List<Player> newList = new ArrayList<>(); playerList.stream() .filter(distinctByKey(p -> p.getName())) // true 保留,false 过滤 .forEach(newList::add); newList.forEach(System.out::println); } static <T> Predicate<T> distinctByKey(Function<? super T, ?> keyExtractor) { Map<Object, Boolean> seen = new ConcurrentHashMap<>(); // putIfAbsent 返回 null 表示 key 第一次出现 return t -> seen.putIfAbsent(keyExtractor.apply(t), Boolean.TRUE) == null; }这段代码的巧妙之处在putIfAbsent的返回值:如果 map 里没有这个 key,就放入(key, Boolean.TRUE)并返回 null;如果 key 已经存在,返回之前的 value。谓词写成返回 null == null,第一次出现返回 true 保留,之后出现返回 false 过滤。
因为filter处理的是ArrayList流,forEach按 encounter order 执行,所以新 List 保持了原始顺序。输出是 kobe、james、curry、zimug,zimug 只保留 age=27 那条。
这里的ConcurrentHashMap不是炫技:distinctByKey是静态方法,方法里的 map 会被每次调用共享,如果配合并行流parallelStream(),普通HashMap在并发写入时可能出问题,ConcurrentHashMap更安全。但要注意ConcurrentHashMap不允许 null key,所以去重字段为 null 时会抛空指针,需要在进入前过滤或返回哨兵值。
4.2 多字段去重:组合 String 与 thenComparing
原文里多字段去重用的是组合字符串:
Set<Player> playerSet = new TreeSet<>(Comparator.comparing(o -> (o.getName() + "" + o.getAge()))); playerSet.addAll(playerList); new ArrayList<>(playerSet).forEach(System.out::println);原理是把多个去重依据合成一个 key:name 和 age 都相同,拼接串才相同;任何一个不同,拼接串就不同。这种写法很直观,但隐患也明显:如果不加分隔符,name="ab", age="c"和name="a", age="bc"会拼出同一个字符串,两个完全不同的对象被当成重复。
我一般不会在需要严谨去重的场景里用字符串拼接,而是换成thenComparing:
Set<Player> playerSet = new TreeSet<>( Comparator.comparing(Player::getName) .thenComparing(Player::getAge) ); playerSet.addAll(playerList);thenComparing会先按 name 比较,name 相同再按 age 比较,只有两个属性都相同才判定重复。它还能继续往下串联第三个、第四个字段,语义比拼接字符串明确得多。
4.3 按属性去重方法横向对比
| 方法 | 是否保持原顺序 | 空值处理 | 多字段扩展 |
|---|---|---|---|
| TreeSet + Comparator | 否,会排序 | 需 nullsFirst | thenComparing |
| collectingAndThen | 否,会排序 | 需 nullsFirst | thenComparing |
| distinctByKey | 是 | ConcurrentHashMap 不支持 null key | Arrays.asList 或分隔符 |
| 多字段拼接 String | 否,取决于容器 | 拼接会变成 "null" | 有歧义风险 |
5. 去重工具类落地:封装、并发 Map 与排序边界
把第 4 章的谓词抽成一个工具类,生产项目里可以直接复用。下面这个版本同时提供 Stream 用的 Predicate 和普通 List 用的静态方法:
public final class DistinctUtil { private DistinctUtil() { } public static <T> Predicate<T> distinctByKey( Function<? super T, ?> keyExtractor) { Map<Object, Boolean> seen = new ConcurrentHashMap<>(); return t -> seen.putIfAbsent(keyExtractor.apply(t), Boolean.TRUE) == null; } public static <T> List<T> distinctByKey(List<T> source, Function<? super T, ?> keyExtractor) { Map<Object, Boolean> seen = new ConcurrentHashMap<>(); List<T> result = new ArrayList<>(source.size()); for (T item : source) { Object key = keyExtractor.apply(item); if (seen.putIfAbsent(key, Boolean.TRUE) == null) { result.add(item); } } return result; } }第一个方法给 Stream 用,第二个方法给普通循环用。第二个方法里初始化ArrayList的容量为source.size(),是为了避免小 List 在扩容上花时间;数据量大时能省掉几次数组复制。调用方式很直接:
List<Player> result = DistinctUtil.distinctByKey(playerList, Player::getName); assertEquals(4, result.size());用assertEquals验证去重是否生效,比肉眼打印更可靠。如果你做的是按 name 去重但最后还要按 age 排序,我一般会先distinctByKey再排序,而不是把去重和排序搅在一个 TreeSet 里:
List<Player> result = playerList.stream() .filter(DistinctUtil.distinctByKey(Player::getName)) .sorted(Comparator.comparing(Player::getAge)) .collect(Collectors.toList());最后提醒一个边界:ConcurrentHashMap.putIfAbsent不接受 null key,所以工具类里如果keyExtractor.apply(item)返回 null,要单独处理。稳妥的做法是在调用处先定义 key 的兜底规则,比如p -> p.getName() == null ? "" : p.getName(),而不是去改DistinctUtil的并发结构。否则后面接上并行流时,你很难定位是哪个属性引起的空指针。
本文还有配套的精品资源,点击获取