图 1:本文封面,HashSet 的「套壳」结构:真正存储的是里面的 HashMap。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「HashSet 是怎么去重的」表面考一个容器,实际考的是两件事:你知不知道 HashSet 根本没有自己的存储,以及你讲不讲得清 hashCode 与 equals 在去重里的分工。JDK 官方文档把第一件事写得很直白:HashSet 由一个哈希表支撑,而这个哈希表实际上就是一个 HashMap 实例。
拆开套壳:元素当 key 存,value 是同一个占位对象。
打开 HashSet 的源码,核心结构就几行:内部持有一个 HashMap 字段,add(e) 的实现是 map.put(e, PRESENT) == null。你的元素被当成 key 存进 HashMap,value 列填的是同一个静态占位对象 PRESENT——所有元素共享它,不额外占内存。add 的返回值直接借用 map.put 的语义:put 在 key 已存在时返回旧 value(也就是 PRESENT,非 null),add 于是返回 false 表示「重复,没插进去」;key 不存在时 put 返回 null,add 返回 true。Set 的「不重复」语义,一行代码就委托给了 Map 的「key 唯一」约束。这也是为什么 HashSet 允许存一个 null——HashMap 本来就允许一个 null key。
去重判定两步走:先 hashCode 定位,再 equals 确认。
add(e) 进去之后发生了什么?第一步,计算 e.hashCode(),经过扰动后定位到某个桶;桶是空的,直接放入,add 返回 true。第二步,桶里已有节点,就逐个比对:先比引用(==),再调 equals,任何一个节点判定相同,就拒绝插入、返回 false;全比完都不相同,才插入新节点。这个顺序不能反——equals 可能很贵,hashCode 定位是把比较范围从「全集合」缩到「一个桶」的关键。
由此引出那条著名契约:hashCode 与 equals 必须成对重写。equals 相等的两个对象,hashCode 必须相等,否则它们会被定位到不同的桶,第二步的 equals 比对根本不会发生,重复元素就此漏网。
用代码把返回值、选型与契约破坏一次跑齐。
下面的程序验证三件事:add 的返回值语义、LinkedHashSet 与 TreeSet 的迭代顺序差异、只重写 equals 不重写 hashCode 时去重失效。用 JDK 21 编译运行(javac SetDemo.java && java SetDemo):
import java.util.*;
public class SetDemo {
// 只重写 equals、不重写 hashCode 的反例
static class BadKey {
final String name;
BadKey(String name) { this.name = name; }
@Override public boolean equals(Object o) {
return o instanceof BadKey b && b.name.equals(name);
}
// hashCode 没有重写:继承 Object 的「按身份算」实现
}
public static void main(String[] args) {
Set<String> s = new HashSet<>();
System.out.println("add golang -> " + s.add("golang")); // true
System.out.println("add rust -> " + s.add("rust")); // true
System.out.println("add golang -> " + s.add("golang")); // false:重复,拒绝
System.out.println("size = " + s.size());
Set<String> linked = new LinkedHashSet<>(List.of("c", "a", "b", "a"));
Set<String> tree = new TreeSet<>(List.of("c", "a", "b", "a"));
System.out.println("LinkedHashSet 迭代 = " + linked); // 插入序,重复被吞
System.out.println("TreeSet 迭代 = " + tree); // 排序
Set<BadKey> bad = new HashSet<>();
bad.add(new BadKey("dup"));
bad.add(new BadKey("dup"));
System.out.println("只重写 equals 的 BadKey 集合大小 = " + bad.size() + "(去重失效)");
}
}
实测输出:
add golang -> true
add rust -> true
add golang -> false
size = 2
LinkedHashSet 迭代 = [c, a, b]
TreeSet 迭代 = [a, b, c]
只重写 equals 的 BadKey 集合大小 = 2(去重失效)
第三次 add 返回 false、集合停在 2 个元素,返回值语义坐实。最有信息量的是最后一行:两个 name 都是 dup 的 BadKey,equals 明明相等,却因为各自继承 Object 的 hashCode(按对象身份算)落进不同桶,被当成两个元素收下——契约破坏的后果不是报错,而是静默的错误数据。
图 2:一次 add 的完整判定流程与三个 Set 实现的选型卡。
选型:三个 Set 各保一种秩序。
HashSet 只保证去重,迭代顺序不做任何承诺,胜在哈希散列良好时 add、remove、contains 都是常数时间(官方文档明确这是以「哈希函数把元素均匀散到桶里」为前提的)。LinkedHashSet 在哈希表上叠一条双向链表,按插入顺序迭代,去重能力不变。TreeSet 底层是 TreeMap 的红黑树,元素自动按大小排序、支持范围查询,代价是基本操作变成对数时间,且它判重用 compareTo 或比较器而非 equals——这是面试里常埋的坑。另外记住 HashSet 不是线程安全的,并发场景要么外部加锁,要么用 Collections.synchronizedSet 包装。
回到面试现场,建议的答题结构:先点破「HashSet 是 HashMap 的套壳,元素存 key、value 是占位对象,add 复用 put 的返回值」;再走一遍「hashCode 定位桶、equals 确认重复」的两步判定,强调契约必须成对重写及破坏后果;最后用 HashSet、LinkedHashSet、TreeSet 的选型对照收尾。套壳是及格线,契约与选型是加分项。你项目里的去重逻辑用的是哪个 Set?有没有被 equals 或 hashCode 坑过的经历?欢迎聊聊。