刚从一场面试模拟辅导回来,一个后端候选人被问到“Set 如何保证元素不重复的”,对方答了“底层是 HashMap,用 hashCode 和 equals 判断”,然后就停住了。这个答案对吗?对,但只对了一半。面试官真正想听到的是:判断发生在哪一步、HashMap 是怎么配合的、不同的 Set 实现有什么不同的防重策略、以及你在实际项目里有没有踩过自定义对象去重的坑。
这篇文章就把这道高频面试题彻底讲透,从底层的判断逻辑、HashSet/LinkedHashSet/TreeSet 三条不同路线,到自定义对象的 equals/hashCode 黄金法则,再配合真实项目里“去重失败”的排查记录。不管你是准备面试,还是写代码时被 Set 的“玄学重复”坑过,这篇都能帮你把这块拼图补齐。
1. 这个问题的“标准答案”其实只有一句
1.1 面试官到底在考什么
这道题面上问的是“Set 如何保证元素不重复”,实际上考的是你对 Java 集合底层数据结构的理解深度。很多人的知识停留在使用层——知道 Set 不能放重复元素,但不知道为什么“不能”,更不知道不同实现类的判定逻辑完全不同。
面试官问这个问题的时候,脑子里通常带着三个预设考点:第一,HashSet 底层是不是 HashMap,add 方法到底做了什么;第二,hashCode 和 equals 在去重过程中的先后顺序和协作方式;第三,LinkedHashSet、TreeSet 是不是也走同一套逻辑。能把这三层讲清楚,这道题才算真正过关。
1.2 从“重复”的定义说起
要理解 Set 的防重机制,先得明白“重复”是什么意思。在很多人的直觉里,两个对象重复就是“长得一样”,比如两个字符串都是 “abc”,两个用户对象的 id 都是 1001。但在 Java 里,这种“长得一样”的判断不是自动完成的,而是通过 equals 方法来实现的。
String 之所以能直接用 Set 去重,是因为 String 类已经重写了 equals 和 hashCode,按字符内容比较;而你自己定义的普通类,如果没重写这两个方法,默认继承的是 Object 的实现——Object 的 equals 比较的是内存地址,两个 new 出来的对象地址不同,equals 结果就是 false,在 Set 眼里它们就是两个不同的元素。这是很多实际项目里“Set 去重失效”的最常见根源。
这里有个核心结论先记住:Set 判断元素是否重复,本质上是靠元素自身的 equals 方法定义“什么算相同”,再通过 hashCode 快速定位。这个机制对 HashSet 和 LinkedHashSet 成立,但对 TreeSet 不成立,TreeSet 用的是另一套比较逻辑,后面细说。
2. HashSet 的防重机制:哈希表 + hashCode/equals
2.1 底层结构:一个 HashMap 的“障眼法”
直接看 HashSet 的源码会发现,它内部几乎什么都没做,只维护了一个 HashMap:
public class HashSet<E> extends AbstractSet<E> implements Set<E> { private transient HashMap<E, Object> map; private static final Object PRESENT = new Object(); public HashSet() { map = new HashMap<>(); } public boolean add(E e) { return map.put(e, PRESENT) == null; } }注意这个 add 方法:元素被当作 HashMap 的 key 存进去,value 是那个固定不变的 PRESENT 占位对象。因为 HashMap 的 key 本身就不允许重复,Set 直接把 HashMap 的 key 特性拿来用了。
关键点在 map.put(e, PRESENT) 的返回值。HashMap.put 在 Key 已经存在时会返回该 Key 对应的旧 value,Key 不存在时返回 null。所以 HashSet.add 的逻辑就变成了:put 返回 null,说明这是新元素,添加成功,返回 true;put 返回了 PRESENT,说明元素已经存在,返回 false。这个设计非常巧妙,等于把 HashMap 的键唯一性完整地继承了过来,自身代码量少得可怜。
2.2 hashCode 定位与 equals 兜底
HashMap 在判断 key 是否重复时,走的是一套“两步走”流程。第一步,调用 key.hashCode() 计算哈希值,根据哈希值定位到数组中的某个桶(bucket)。第二步,如果这个桶里已经存在元素,再逐个调用 equals 来精确比对。
用生活场景来类比:hashCode 像是在图书馆里按索书号找到书架区,equals 是在这个区域里逐本核对具体是哪本书。如果你要找《三体》,先按索书号找到科幻文学区,再在区里逐本比对书名。hashCode 帮你把搜索范围缩小到一个小区域,equals 做最终确认。
这个流程对“不重复”意味着什么?两个对象要被认为是同一个元素,必须满足:hashCode 计算出的桶位置相同,且在该桶内 equals 比较为 true。这两个条件缺一不可。如果你只重写了 equals 但没重写 hashCode,两个 equals 为 true 的对象可能因为 hashCode 不同被分到不同桶里,Set 就会把它们当成两个元素——这就是“重复”的根本原因。
这里有个顺序细节值得强调:hashCode 先执行,equals 后执行。HashMap 不会先调用 equals 对所有元素做全量比较,那样性能无法接受。它先用哈希值缩小范围,再在小范围内精确比对。所以 hash 算法好不好,直接影响 HashMap 和 HashSet 的性能。
2.3 hash 冲突时会发生什么
当两个不同对象算出了相同的 hashCode,它们会落到同一个桶里,这就叫哈希冲突。HashMap 的桶里存的是链表结构,冲突的元素在链表上依次排列,equals 逐个探测;Java 8 之后,如果链表长度超过阈值 8 且数组容量达到 64,链表会转换成红黑树,把查找时间从 O(n) 优化到 O(log n)。
但对 Set 来说,哈希冲突本身并不影响“不能重复”这个特性,它只影响性能。冲突越严重,桶里的元素越多,每次添加时需要比较的次数就越多。极端情况下,所有元素的 hashCode 都一样,HashSet 的性能就退化成类似链表或红黑树的性能,不再是常数级的哈希表性能。
我在实际开发中遇到过一个问题:某个业务对象重写了 hashCode,直接用了一个 30 多个字段拼接的字符串的 hashCode,结果大量对象集中分布在小部分桶里,Set 操作从毫秒级退化到秒级。后来改成只取核心业务字段计算哈希,分布立刻均匀了。哈希值的均匀性对 HashSet 性能的影响,往往比很多人想象的大得多。
3. LinkedHashSet 与 TreeSet:两条完全不同的防重路线
3.1 LinkedHashSet 如何在保证不重复的同时记住顺序
LinkedHashSet 继承了 HashSet,但构造逻辑做了一个小调整:它传给父类的 map 是 LinkedHashMap 的实例,而不是普通的 HashMap。LinkedHashMap 在 HashMap 的基础上额外维护了一条双向链表,记录元素的插入顺序。
public class LinkedHashSet<E> extends HashSet<E> { public LinkedHashSet() { super(16, 0.75f, true); } // HashSet 的这个构造方法专门为 LinkedHashSet 准备 HashSet(int initialCapacity, float loadFactor, boolean dummy) { map = new LinkedHashMap<>(initialCapacity, loadFactor); } }防重机制上,LinkedHashSet 和 HashSet 完全一致,都走 hashCode + equals 流程;区别只在迭代顺序。HashSet 的迭代顺序是不确定的,甚至可能在扩容后发生变化,而 LinkedHashSet 会按照元素插入的顺序返回,适合既要快速去重、又要保持“先到先得”顺序的缓存场景。
我在某个项目里就用过这个特性:一批消息需要按到达顺序去重处理,先用 LinkedHashSet 聚合,既去掉了相同消息,又保住了首次到达的顺序。如果用 HashSet,顺序可能乱,后续处理就得额外排序,反而多一步。
3.2 TreeSet 的重复判断根本不看 equals
TreeSet 和 HashSet 的思路完全不同。它底层使用 TreeMap,基于红黑树实现,元素在内部是有序排列的。正因如此,TreeSet 要求元素要么实现了 Comparable 接口,要么在创建时传入一个 Comparator。
TreeSet 如何判断重复?它调用的是比较器的 compare 方法,或者元素自身的 compareTo 方法。如果 compareTo 返回 0,就认为两个元素是重复的,直接不插入;返回正数或负数,则按照红黑树的规则安置位置。
TreeSet<String> treeSet = new TreeSet<>(); treeSet.add("b"); treeSet.add("a"); treeSet.add("b"); // 重复,不会添加 // treeSet 内元素顺序为 [a, b]这里有三个关键坑。第一,TreeSet 完全依赖 compareTo/compare 的返回值,它根本不调用 equals 和 hashCode。如果你写了 equals 却忘了定义 compareTo,或者 compareTo 的实现逻辑和 equals 不一致,就可能出现 TreeSet 认为两个对象相同但 equals 认为不同(反之亦然),导致去重规则和业务预期不符。
第二,compareTo 返回 0 并不强制要求 equals 返回 true,这是 Java 规范里的一个“建议但不强制”的要求,但实际使用时必须保持两者一致,否则就会出问题。第三,如果元素在放入 TreeSet 后被修改,导致比较结果发生改变,这个元素在树里的位置可能就“错乱”了,后续的查询和删除都可能失败。
3.3 三种 Set 的对比
把三个 Set 的差异整理成一张表,面试前能快速过一遍:
| 实现类 | 底层结构 | 重复判定依据 | 迭代顺序 | 是否要求元素实现 Comparable |
|---|---|---|---|---|
| HashSet | HashMap | hashCode + equals | 不保证顺序,可能随扩容变化 | 否 |
| LinkedHashSet | LinkedHashMap | hashCode + equals | 按插入顺序 | 否 |
| TreeSet | TreeMap(红黑树) | compareTo / compare 返回值 | 按自然顺序或自定义比较器排序 | 需要 |
这个表值得背下来。面试时如果能当场说出“TreeSet 不走 equals/hashCode,而是根据 compare 的结果判断重复”,已经在区分度上赢过大多数只背了 HashSet 的候选人。
4. 自定义对象的重复判定陷阱与正确姿势
4.1 不重写任何方法时的默认行为
如果你定义了一个普通类,不重写 equals 和 hashCode,直接往 HashSet 里放,会发生什么?默认的 equals 是 Object 的方法,实现如下:
public boolean equals(Object obj) { return (this == obj); }它比较的是内存地址。你 new 了三个对象,哪怕三个对象内部的字段一模一样,在默认 equals 看来也是三个完全不同的对象,HashSet 会把它们全部收下。这不是“去重失败”,而是你根本没有告诉 Set“什么算相同”。
4.2 equals 和 hashCode 的黄金法则
Java 对 equals 和 hashCode 的约定非常严格,不遵守会引发各种诡异问题:
两个对象通过 equals 判断为相等,那么它们的 hashCode 必须相等。 两个对象的 hashCode 相等,equals 可以返回 false(哈希冲突),这是被允许的。 两个对象的 hashCode 不相等,equals 必须返回 false。
我去重踩坑的过程中深刻体会到,这几个规则不是一个“最佳实践”层面的建议,而是一个强制约束:HashMap 和 HashSet 的查找逻辑建立在“equals 相同的对象必然 hashCode 相同”这个前提上。违反这个前提,集合操作的结果就是不可预测的。
更隐蔽的坑是:compareTo 的实现也要与 equals 保持一致。TreeSet 以 compareTo 结果为准判断重复,如果你在 compareTo 里只比了 id,而 equals 里比了 id 和 name,就可能出现 equals 为 true 但 compareTo 不为 0,或者 compareTo 为 0 但 equals 为 false 的矛盾状态,结果就是 TreeSet 的去重逻辑和业务预期不一致。
4.3 正确的实现在代码里长什么样
实际项目里写自定义对象的 Set 去重,推荐用 Java 7+ 的 Objects 工具类,避免手写一堆容易出错的空指针判断:
public class User { private Long id; private String name; @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return Objects.equals(id, user.id) && Objects.equals(name, user.name); } @Override public int hashCode() { return Objects.hash(id, name); } }我见过很多项目里 equals 实现没问题,hashCode 却被写成返回固定值,比如 return 1。这从“是否重复”的角度看是对的,相等的还是相等,但所有元素都哈希到同一个桶,HashSet 的查询和插入性能立刻暴跌到链表级别。数据量小无所谓,数据量上万就明显卡顿。
反过来还有一种错误:只给一部分字段重写了 hashCode,比如 equals 比较 id 和 name,但 hashCode 只算 id。两个对象的 id 相同、name 不同,hashCode 相等会落到同桶,equals 返回 false,Set 允许共存——这个场景还算可控;但如果 equals 比较 id 和 name,hashCode 只算 name,两个字段相同但 id 不同,hashCode 相同进同桶后 equals 也能通过,Set 就会错误地认为它们是同一个对象。
所以在实际编码时,equals 用哪些字段,hashCode 必须用完全相同的字段,两者保持一致。这是避免“去重结果和业务预期不一致”的最基本原则。IDEA 等 IDE 的生成 equals/hashCode 功能也能自动保证这一点,但生成的代码不一定符合业务含义,字段選择仍然需要自己把控,通常选业务上能唯一标识对象的字段组合。
5. 面试追问盘点与实战排查记录
5.1 高频追问一:HashMap put 返回了什么
面试官问完“Set 如何保证不重复”以后,最喜欢顺着往下问 HashMap 的 put 细节。这个问题答得好,能给整场面试增色不少。
HashMap.put(K key, V value) 的返回值规则是:如果 key 已存在,返回旧 value,且不会把 key 替换成新对象,只会用新 value 覆盖旧 value;如果 key 不存在,返回 null。HashSet.add 正是利用这个返回值判断元素是否重复。
// HashMap 内部 putVal 逻辑简化版 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) { V oldValue = e.value; e.value = value; // 新值覆盖旧值 return oldValue; // key 已存在,返回旧 value }这个“key 相同但 value 被覆盖”的行为,在 HashSet 的场景里意味着什么?如果两个对象的 key 判断为相同,Set 不会把新对象替换成老对象。这一点在面试里讲出来是加分项:说明你看过源码,而不仅仅是背了答案。
5.2 高频追问二:为什么重写 equals 必须重写 hashCode
这是 HashSet 的配套题。前面已经解释过,Set 判断重复要先通过 hashCode 定位桶,然后在桶内用 equals 精确比较。如果 equals 返回 true 但 hashCode 不同,两个对象会被分到不同桶,Set 认为它们不重复——这直接破坏了“equals 相等则集合中只允许出现一个”的核心语义。
更麻烦的是,这种漏洞会连锁引发数据混乱。比如一个对象已经在 Set 里,你用它的 equals 等价对象去调用 remove,因为 hashCode 不同,HashMap 根本找不到那个桶,删除失败。Set 越来越大,重复越来越多,而你自己还找不到原因。
5.3 高频追问三:无序究竟指什么
HashSet 不保证迭代顺序,这里的“无序”不等于“随机”。它的顺序由哈希值经过扰动函数处理后对数组长度取模决定,所以元素的插入顺序和遍历顺序可能完全不一致。而且当 HashSet 扩容(rehash)时,元素会被重新分配桶位,遍历顺序可能彻底改变。
这个知识点面试里也容易出现:有人紧张时就答“HashSet 是随机的”,随后被面试官追问“那你能保证每次顺序都不同吗”就卡住了。精确的表述是:HashSet 不保证元素的迭代顺序,且该顺序不保证稳定,扩容后可能发生变化。如果你需要稳定顺序,要么用 LinkedHashSet,要么用 TreeSet。
5.4 实战排查:元素“去重失败”的几种现场
我在项目里遇到过一次很奇怪的问题:同一个用户对象往 Set 里 add 了三次,竟然出现了三个“一样”的元素。当场排查下来的原因是这个对象类只重写了 equals,没有重写 hashCode,两个逻辑上相等的对象因为默认 hashCode 不同,落进了不同的桶,Set 就当成了不同元素。
另一次是被 Set 的 remove 坑到:对象放入 Set 之后,某个字段被修改了,而这个字段又参与了 hashCode 计算,导致对象的哈希值发生变化。Set 存储时它的桶位置是根据旧哈希值算出来的,remove 时却拿着新哈希值去找桶,自然找不到。最终 Set 里出现了一个“存在但无法删除”的元素。解决方式是:放入 Set 的元素必须是不可变对象,或者至少在参与的 hashCode 和 equals 的字段上禁止修改。
还有一种比较隐蔽的情况是数据库里读取出来的数据,字段类型不同导致 equals 判断失败。比如一个字段在数据库里是 int,代码里却是 String,读取后“1”和 1 用 equals 比较返回 false,那 Set 同样认为它们不重复。这种问题在实体类和数据库字段类型不对齐时经常出现,排查时一定要看 equals 里参与比较的字段的实际类型。
6. 更值得琢磨的底层延伸
把答案组织到这一步,其实还可以再往下钻一层,去理解为什么 2 的 n 次幂、为什么要做扰动,把这层讲明白了,面试官基本就会在心里给一个不错的评价。
HashMap 里定位桶位不是直接用 hashCode,而是先做一次扰动函数处理(高 16 位与低 16 位异或),再与数组长度减一取与运算,即 hash & (n - 1)。这个式子成立的前提是 n 是 2 的幂。只有长度是 2 的幂时,等价于取模,而且位运算比取模更快。这也是为什么 HashMap 在扩容时会强制把容量调整成 2 的幂,并且每次扩容翻倍。
理解了这一点,再回头看 HashSet,它内部 map 的初始容量和扩容逻辑就都能串起来了。面试时如果能把这一层也讲清楚,说明你已经把 HashMap 的哈希策略背后的设计动机吃透了。
以我个人面试别人的经验,这道题最舒服的回答节奏是:先答“重复的定义”,再答“HashSet 的 hashCode + equals 两步判定”,接着对比 TreeSet 的比较器逻辑,最后补一句“所以重写 equals 必须重写 hashCode”。按这个顺序回答,逻辑自然,完全不是背题的感觉。如果你在准备面试,建议拿张纸把 HashSet.add 的源码默写一遍,再手写一个自定义对象放入 Set 的 demo 跑一遍,每个过程自己亲手验证过,面试现场就不会虚。