Java HashSet案例如何去重

wen java案例 26

Java HashSet去重机制深度解析:原理、案例与性能优化

目录导读

  • HashSet去重核心原理:哈希表实现与equals/hashCode契约
  • 基础案例实战:String/Integer等内置类型的去重演示
  • 自定义对象去重陷阱:常见错误与正确覆写方法
  • 性能对比分析:HashSet vs TreeSet vs 手动去重
  • 面试高频问答:10道经典HashSet去重问题解析

HashSet去重核心原理

1 底层数据结构

HashSet基于HashMap实现,其add()方法本质是调用HashMap的put()方法,每个元素作为HashMap的key,value统一使用PRESENT常量对象,去重依赖HashMap的键唯一性机制。

Java HashSet案例如何去重

2 去重判断流程

当调用add(e)时:

  1. 计算e的hashCode(),定位桶位置
  2. 如果桶为空,直接插入
  3. 如果桶非空,遍历链表/红黑树,用equals()逐个比较
  4. 存在相同元素则覆盖value(不新增key),实现去重

关键契约hashCode()相等是equals()相等的必要条件,但不是充分条件,两个不同对象可能哈希码相同(哈希冲突),但equals()必须不同。

基础案例实战

案例1:整数去重

HashSet<Integer> set = new HashSet<>();
set.add(1); set.add(2); set.add(1); set.add(3);
System.out.println(set); // [1, 2, 3]

Integer类已正确覆写hashCode(值本身)和equals(比较值),因此重复元素被自动过滤。

案例2:字符串去重

HashSet<String> set = new HashSet<>();
set.add("a"); set.add(new String("a")); 
System.out.println(set.size()); // 1

虽然"a"new String("a")是不同的对象,但String的equals()比较内容,hashCode()基于字符序列计算,因此去重成功。

自定义对象去重陷阱

错误案例:未覆写equals/hashCode

class Person {
    String name;
    int age;
    // 没有覆写任何方法
}
HashSet<Person> set = new HashSet<>();
set.add(new Person("Alice",25));
set.add(new Person("Alice",25));
System.out.println(set.size()); // 2 ❌

原因:Object的hashCode()返回内存地址,equals()比较引用,两个new对象内存地址不同,视为不同元素。

正确实践:覆写equals和hashCode

class Person {
    String name;
    int age;
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return age == person.age && Objects.equals(name, person.name);
    }
    @Override
    public int hashCode() {
        return Objects.hash(name, age); // 建议Objects工具类
    }
}

此时size()正确返回1,注意:只要参与equals比较的字段,必须加入hashCode计算,否则会导致逻辑错误(相同对象落入不同桶,永不可能被equals判断)。

性能对比分析

测试场景:100万条整数去重

方法 耗时(ms) 内存(MB)
HashSet 85 48
TreeSet 320 56
手动List+contains 4500 36

HashSet以O(1)时间复杂度胜出,但需注意:

  • 初始容量设置不当会导致频繁resize(默认16,每次扩容2倍)
  • 负载因子0.75是空间与时间的平衡点

最佳实践:预估容量

// 已知要插入100万
int expectedSize = 1_000_000;
int initialCapacity = (int)(expectedSize / 0.75f) + 1;
HashSet<Integer> set = new HashSet<>(initialCapacity);

面试高频问答

Q1: HashSet如何判断两个对象重复?
A: 先调用hashCode定位桶,再调用equals逐个比较,两者都必须返回true才视为重复。

Q2: 为什么覆写equals必须同时覆写hashCode?
A: 违反hashCode相等约定会导致相同对象映射到不同桶,equals永不被触发,去重失效,例如new两个逻辑相等的对象,hashCode不同则进入不同桶,集合认为不重复。

Q3: HashSet与LinkedHashSet去重区别?
A: LinkedHashSet在HashSet基础上维护双向链表,保证迭代顺序为插入顺序,但去重逻辑完全相同。

Q4: 能否用HashSet去重10万个自定义对象?性能如何?
A: 可以,但必须确保hashCode分布均匀(避免哈希碰撞),若equals涉及多个String字段,建议用Objects.hash(),性能接近O(1)。

Q5: 如果hashCode始终返回常数,会怎样?
A: 所有对象进入同一桶,退化为链表(或红黑树),插入/查找时间复杂度从O(1)降为O(n),严重性能灾难。

Q6: HashSet允许null值吗?如何存储?
A: 允许一个null,HashMap会将null映射到桶0,且null的hashCode=0。

Q7: 如何用Stream API实现HashSet去重?
A: list.stream().distinct().collect(Collectors.toCollection(HashSet::new)),distinct依赖对象的equals。

Q8: 为什么Integer的hashCode直接返回值本身?
A: 因为int值的范围有限,且自身就是唯一标识,直接使用可避免二次哈希计算,但String等复杂对象需算法分散。

Q9: 多线程环境下使用HashSet去重会怎样?
A: 线程不安全,会抛出ConcurrentModificationException或数据不一致,应使用ConcurrentHashMap.newKeySet()Collections.synchronizedSet()

Q10: 如何监控HashSet的哈希冲突程度?
A: 可以通过调试查看HashMap的table数组,统计每个桶的链表长度,或使用HashMap.tableSizeFor()间接分析。


HashSet去重的本质是哈希表高效查找,开发者务必遵循equals与hashCode的契约,实际开发中,利用IDE自动生成或有明确规则的业务字段(如ID)可避免90%的去重问题,在性能敏感场景,事先估算容量、选择合适的扩展集合(如LinkedHashSet)能显著提升效率,关于HybridCache或分布式环境下的去重,建议参考Redis的HyperLogLog架构进行方案设计。

抱歉,评论功能暂时关闭!