资讯动态

Java Set如何保证元素不重复?HashSet、TreeSet底层原理与去重实战

发布时间:2026/10/20 17:30:14 来源:尧图企业网站定制
刚从一场面试模拟辅导回来一个后端候选人被问到“Set 如何保证元素不重复的”对方答了“底层是 HashMap用 hashCode 和 equals 判断”然后就停住了。这个答案对吗对但只对了一半。面试官真正想听到的是判断发生在哪一步、HashMap 是怎么配合的、不同的 Set 实现有什么不同的防重策略、以及你在实际项目里有没有踩过自定义对象去重的坑。这篇文章就把这道高频面试题彻底讲透从底层的判断逻辑、HashSet/LinkedHashSet/TreeSet 三条不同路线到自定义对象的 equals/hashCode 黄金法则再配合真实项目里“去重失败”的排查记录。不管你是准备面试还是写代码时被 Set 的“玄学重复”坑过这篇都能帮你把这块拼图补齐。1. 这个问题的“标准答案”其实只有一句1.1 面试官到底在考什么这道题面上问的是“Set 如何保证元素不重复”实际上考的是你对 Java 集合底层数据结构的理解深度。很多人的知识停留在使用层——知道 Set 不能放重复元素但不知道为什么“不能”更不知道不同实现类的判定逻辑完全不同。面试官问这个问题的时候脑子里通常带着三个预设考点第一HashSet 底层是不是 HashMapadd 方法到底做了什么第二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/equals2.1 底层结构一个 HashMap 的“障眼法”直接看 HashSet 的源码会发现它内部几乎什么都没做只维护了一个 HashMappublic class HashSetE extends AbstractSetE implements SetE { private transient HashMapE, 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 对应的旧 valueKey 不存在时返回 null。所以 HashSet.add 的逻辑就变成了put 返回 null说明这是新元素添加成功返回 trueput 返回了 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 LinkedHashSetE extends HashSetE { 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 的重复判断根本不看 equalsTreeSet 和 HashSet 的思路完全不同。它底层使用 TreeMap基于红黑树实现元素在内部是有序排列的。正因如此TreeSet 要求元素要么实现了 Comparable 接口要么在创建时传入一个 Comparator。TreeSet 如何判断重复它调用的是比较器的 compare 方法或者元素自身的 compareTo 方法。如果 compareTo 返回 0就认为两个元素是重复的直接不插入返回正数或负数则按照红黑树的规则安置位置。TreeSetString 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 的差异整理成一张表面试前能快速过一遍实现类底层结构重复判定依据迭代顺序是否要求元素实现 ComparableHashSetHashMaphashCode equals不保证顺序可能随扩容变化否LinkedHashSetLinkedHashMaphashCode equals按插入顺序否TreeSetTreeMap红黑树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 返回 falseSet 允许共存——这个场景还算可控但如果 equals 比较 id 和 namehashCode 只算 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 跑一遍每个过程自己亲手验证过面试现场就不会虚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑