hashset去重快但内存开销大,不适用于千万级以上数据;应评估重复率、字符串特征,分批添加、预设容量,并确保正确重写hashcode()和equals()。

HashSet 去重快,但不是所有场景都适合直接用
直接用 HashSet 对海量数据(比如千万级字符串)去重,内存可能爆掉,而且不一定会比预期内存占用低。它底层是哈希表 + 数组 + 链表/红黑树,插入时要计算 hashCode()、处理哈希冲突、动态扩容——这些在数据量大时都会放大开销。
实操建议:
- 先评估数据特征:是否重复率高?字符串长度是否差异极大?
hashCode()是否容易碰撞(比如大量以相同前缀开头的 ID)? - 若原始数据可分批读取(如从文件或数据库流式拉取),别一次性
addAll(),改用循环add()并监控size()和 GC 表现 - 初始化时指定合理初始容量,避免频繁扩容:
new HashSet(expectedSize / 0.75f)(0.75 是默认负载因子) - 对字符串等不可变对象,注意 JVM 字符串常量池和
intern()的副作用——一般不建议手动调用,反而增加 GC 压力
为什么 add() 返回 false 就代表重复,但 equals() 还得自己写好
HashSet.add() 返回 false 表示该元素已存在,判断依据是:先比 hashCode(),再比 equals()。如果自定义类没重写这两个方法,那就退化成引用比较,所有新 new 出来的对象都算“不重复”,即使业务上它们逻辑相等。
常见错误现象:
- 用
new User("alice", 25)和new User("alice", 25)分别add(),结果 size 变 2 - 日志里看到
HashSet容量飞涨,但业务上明显有大量重复 ID
必须确保:
-
hashCode()实现稳定:同一对象多次调用返回相同值;相等对象的hashCode()必须相同 -
equals()满足自反、对称、传递、一致性,且不抛异常 - 若字段含
null,Objects.equals(a, b)比a.equals(b)更安全
当数据量超过内存承受范围,HashSet 就该让位了
假设你有 5 亿条 UUID 字符串(约 10 GB 原始文本),想统计唯一数,硬塞进 HashSet<string></string> 很可能触发 OOM 或导致 STW 时间过长。JVM 堆内对象还有对象头、引用指针等额外开销,实际内存占用往往是原始数据的 3–5 倍。
替代方案按优先级排序:
- 用布隆过滤器(BloomFilter)做初筛:内存占用小,支持超大数据集,但有误判率(只报“可能存在”,不报“一定存在”)
- 外部排序 + 归并去重:把数据分块排序后合并,边读边比前一个,空间复杂度降到 O(1)
- 用磁盘映射(
MappedByteBuffer)或列式存储(如 Parquet + Spark)配合分布式去重 - 如果只是判断某条数据是否见过,考虑 Redis 的
SET或BF.ADD,但要注意网络延迟和序列化成本
HashMap 底层结构直接影响 HashSet 性能
HashSet 内部封装了一个 HashMap,所有元素作为 key 存入,value 固定为 PRESSENT(一个静态 Object)。所以它的性能瓶颈、扩容逻辑、链表转红黑树阈值(8)、树化条件(桶中节点 ≥ 8 且 table.length ≥ 64)全部继承自 HashMap。
关键参数影响:
- 初始容量太小 → 频繁 resize → 每次 rehash 要遍历所有已有元素 → CPU 突增
- 负载因子设为 1.0 → 减少扩容但拉长链表 → 查找退化为 O(n),尤其在哈希碰撞多时
- JDK 8 后,单个桶中链表长度 ≥ 8 且数组长度 ≥ 64 才转红黑树;否则仍是链表 → 碰撞多又数据量小时,别指望自动优化
调试时可打印 map.size()、map.entrySet().stream().map(e -> e.getKey().hashCode() % map.capacity()).distinct().count() 来粗略看哈希分布是否均匀。
真正麻烦的不是“怎么写”,而是“什么时候不该用”。比如日志去重,如果只要最终数量,用 HyperLogLog 更省;如果要保留首次出现的完整对象,那得自己维护索引映射——HashSet 只管存在性,不管顺序和上下文。










