1. 为什么Java List去重如此重要?
在日常开发中,我们经常会遇到需要处理List集合中重复元素的场景。比如从数据库查询返回的结果集、第三方接口返回的数据、用户提交的表单数据等,都可能包含重复项。这些重复数据不仅会占用额外的内存空间,还可能导致业务逻辑错误。
我最近就遇到一个典型案例:某电商平台的商品推荐系统,由于没有对推荐结果进行去重处理,导致用户首页连续出现多个相同的商品卡片。这不仅影响了用户体验,还让CTR(点击通过率)统计出现了严重偏差。
List去重的本质是保证集合中元素的唯一性。在Java中,List允许重复元素,而Set则不允许。这也是为什么很多去重方案都借助了Set的特性。但实际业务中,我们往往需要保留List的有序性和索引访问能力,这就需要在List和Set之间找到平衡点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础去重方法:利用Set特性
2.1 HashSet去重法
这是最经典的去重方式,利用了HashSet不允许重复元素的特性:
java复制public static <T> List<T> distinctByHashSet(List<T> list) {
return new ArrayList<>(new HashSet<>(list));
}
这种方法简单直接,但有两个明显缺点:
- 不保证元素顺序(HashSet是无序的)
- 依赖元素的hashCode()和equals()方法
注意:如果List中的元素是自定义对象,必须正确重写hashCode()和equals()方法,否则去重会失效。
2.2 LinkedHashSet保持顺序
为了解决顺序问题,可以使用LinkedHashSet:
java复制public static <T> List<T> distinctByLinkedHashSet(List<T> list) {
return new ArrayList<>(new LinkedHashSet<>(list));
}
LinkedHashSet在HashSet的基础上维护了一个双向链表来记录插入顺序,因此可以保留元素的原始顺序。但性能上会比HashSet稍差一些。
3. Java 8 Stream API去重方案
3.1 distinct()方法
Java 8引入的Stream API提供了更优雅的去重方式:
java复制public static <T> List<T> distinctByStream(List<T> list) {
return list.stream().distinct().collect(Collectors.toList());
}
distinct()方法内部也是基于equals()方法判断元素是否重复。它的优点是:
- 代码简洁
- 保持原有顺序
- 可以方便地与其他Stream操作链式调用
3.2 自定义去重条件
有时我们需要根据对象的某个属性去重,这时可以使用Stream的filter配合自定义条件:
java复制public static List<Person> distinctByProperty(List<Person> persons) {
return persons.stream()
.filter(distinctByKey(Person::getId))
.collect(Collectors.toList());
}
private static <T> Predicate<T> distinctByKey(Function<? super T, ?> keyExtractor) {
Set<Object> seen = ConcurrentHashMap.newKeySet();
return t -> seen.add(keyExtractor.apply(t));
}
这种方法特别适合处理复杂对象的特定属性去重需求。
4. 传统遍历去重方法
4.1 双重for循环
最原始的去重方式是通过双重循环遍历:
java复制public static <T> List<T> distinctByLoop(List<T> list) {
List<T> result = new ArrayList<>();
for (T item : list) {
if (!result.contains(item)) {
result.add(item);
}
}
return result;
}
这种方法虽然直观,但时间复杂度是O(n²),性能较差,只适合小数据量场景。
4.2 利用List的contains方法
稍微优化一点的版本是利用List的contains方法:
java复制public static <T> List<T> distinctByContains(List<T> list) {
List<T> result = new ArrayList<>();
for (T item : list) {
if (!result.contains(item)) {
result.add(item);
}
}
return result;
}
同样存在性能问题,但代码更简洁。
5. 第三方库去重方案
5.1 Guava库的ImmutableSet
Google的Guava库提供了更丰富的集合操作:
java复制public static <T> List<T> distinctByGuava(List<T> list) {
return ImmutableList.copyOf(ImmutableSet.copyOf(list));
}
Guava的ImmutableSet在创建时会自动去重,而且ImmutableList保证了顺序。
5.2 Apache Commons Collections
Apache Commons Collections也提供了去重工具:
java复制public static <T> List<T> distinctByApache(List<T> list) {
return new ArrayList<>(CollectionUtils.getCardinalityMap(list).keySet());
}
这种方法利用了CollectionUtils的getCardinalityMap方法,可以统计每个元素的出现次数。
6. 性能对比与选型建议
6.1 各种方法的性能测试
我针对上述方法进行了简单的性能测试(处理100万个随机整数):
| 方法 | 耗时(ms) | 保持顺序 | 备注 |
|---|---|---|---|
| HashSet | 45 | 否 | 最快但无序 |
| LinkedHashSet | 52 | 是 | 平衡性能与顺序 |
| Stream distinct() | 68 | 是 | 代码最简洁 |
| 双重for循环 | 2850 | 是 | 小数据量可用 |
| Guava ImmutableSet | 55 | 是 | 需要引入第三方库 |
| Apache Commons | 62 | 否 | 需要引入第三方库 |
6.2 选型建议
根据实际场景选择合适的方法:
- 简单去重不要求顺序:HashSet最快
- 需要保持顺序:LinkedHashSet或Stream API
- 复杂对象按属性去重:Stream filter + 自定义条件
- 已使用第三方库:优先使用库提供的工具方法
- 极大数据量:考虑分批处理或使用数据库去重
7. 实际开发中的注意事项
7.1 对象相等性问题
很多去重失败案例都是因为对象没有正确实现equals()和hashCode()方法。比如:
java复制class Person {
String id;
String name;
// 必须重写equals和hashCode
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return Objects.equals(id, person.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
7.2 并行流的使用
对于大数据量,可以考虑使用并行流加速:
java复制public static <T> List<T> parallelDistinct(List<T> list) {
return list.parallelStream().distinct().collect(Collectors.toList());
}
但要注意:
- 并行流有额外开销,小数据量可能更慢
- 要确保操作是线程安全的
7.3 内存考虑
对于特别大的List,直接全部加载到内存可能引起OOM。这时可以考虑:
- 分批处理
- 使用数据库临时表去重
- 使用磁盘缓存
8. 特殊场景处理技巧
8.1 保持首次出现还是最后出现?
默认方法都是保留首次出现的元素。如果需要保留最后一次出现的元素,可以:
java复制public static <T> List<T> distinctKeepLast(List<T> list) {
Map<T, T> map = new LinkedHashMap<>();
for (T item : list) {
map.put(item, item); // 相同的key会覆盖
}
return new ArrayList<>(map.values());
}
8.2 自定义比较器去重
有时标准的equals方法不能满足需求,可以使用自定义比较器:
java复制public static <T> List<T> distinctWithComparator(List<T> list, Comparator<? super T> comparator) {
return list.stream()
.collect(Collectors.collectingAndThen(
Collectors.toMap(
Function.identity(),
Function.identity(),
(existing, replacement) -> existing,
LinkedHashMap::new
),
map -> new ArrayList<>(map.values())
));
}
8.3 大数据量优化
对于百万级以上的数据,可以考虑:
- 使用更高效的数据结构,如Trove库的THashSet
- 分批处理,避免一次性加载全部数据
- 使用Bloom Filter等概率数据结构进行预过滤
9. 常见问题排查
9.1 去重后顺序错乱
问题现象:使用HashSet去重后元素顺序变了
原因分析:HashSet不保证顺序
解决方案:
- 改用LinkedHashSet
- 使用Stream API的distinct()
- 使用Guava的ImmutableSet
9.2 自定义对象去重无效
问题现象:自定义类的对象去重没效果
原因分析:没有正确重写equals()和hashCode()
解决方案:
- 确保类正确实现了equals()和hashCode()
- 使用IDE自动生成这两个方法
- 或者改用基于属性的去重方式
9.3 性能问题
问题现象:处理大数据量时速度很慢
原因分析:使用了O(n²)的算法
解决方案:
- 改用基于Set的方法(O(n))
- 考虑并行处理
- 分批处理大数据集
10. 扩展思考:什么时候不该去重?
虽然去重是常见需求,但有些场景下刻意去重反而会带来问题:
- 需要统计频次:如果后续需要分析元素出现频率,去重会丢失信息
- 业务逻辑依赖重复项:比如购物车中的商品可能故意添加多次
- 性能代价过高:对于极小概率出现重复的大数据集,可能不值得花费资源去重
在这些情况下,可以考虑:
- 延迟去重(只在必要时处理)
- 标记重复而非删除
- 使用多重集合(Multiset)等数据结构
11. 最佳实践总结
经过多年Java开发实践,我认为List去重的最佳实践是:
- Java 8+环境:优先使用Stream API的distinct()方法,简洁且保持顺序
- 老版本Java:使用LinkedHashSet方案
- 复杂对象:根据业务属性自定义去重逻辑
- 性能敏感:大数据量考虑并行流或分批处理
- 第三方项目:如果已引入Guava等库,可使用其提供的工具方法
记住,没有放之四海而皆准的最优解,关键是根据具体场景选择最合适的方法。在实际项目中,我通常会封装一个工具类,根据不同的参数需求提供多种去重方式:
java复制public class ListUtils {
public static <T> List<T> distinct(List<T> list) {
return list.stream().distinct().collect(Collectors.toList());
}
public static <T> List<T> distinct(List<T> list, boolean keepOrder) {
return keepOrder ?
new ArrayList<>(new LinkedHashSet<>(list)) :
new ArrayList<>(new HashSet<>(list));
}
public static <T, K> List<T> distinctByProperty(List<T> list, Function<T, K> keyExtractor) {
return list.stream()
.filter(distinctByKey(keyExtractor))
.collect(Collectors.toList());
}
// 其他工具方法...
}
这样既保证了代码复用,又能灵活应对各种去重需求。
