深蓝黑背景封面:左侧原创示意图表现「套壳」结构——上方代码 add("golang") 经箭头进入外层青色大圆角框「HashSet(对外只有 Set 语义)」,框内嵌套紫色框「HashMap map(真正存储)」,内框里是一张两列小表:key 列放着元素 golang、rust,value 列全是同一个占位对象 PRESENT,底部注释「所有 value 都是同一个静态占位对象」;右侧大标题「HashSet 怎么去重 / 背后就是 HashMap」,副标题「add 的返回值,就是 map.put 的返回值」,补充语「去重判定 = hashCode 定位 + equals 确认」;左上标注「面试经典 · Java 后端」,右上圆角胶囊标注「Day 05 · 11-12」 图 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(按对象身份算)落进不同桶,被当成两个元素收下——契约破坏的后果不是报错,而是静默的错误数据。

左侧「一次 add 的去重之旅」流程图:add(e) 进入后先用 e.hashCode() 计算哈希、定位桶;菱形判断「桶里有节点吗」——没有则走左侧分支,直接放入空桶、add 返回 true;有则走右侧分支,遍历桶内节点做 == 与 equals 比对,相同则拒绝插入、返回 false,不同则插入、返回 true;流程底部提醒:先比 hashCode 再比 equals,顺序不能反。右侧三张选型卡片纵向排列——HashSet(青色,标签「纯去重」):底层是 HashMap,key 存元素、value 为占位对象,只去重、迭代顺序不保证;LinkedHashSet(紫色,标签「保插入序」):底层是哈希表加双向链表,按插入顺序迭代;TreeSet(灰色,标签「自动排序」):底层是 TreeMap 红黑树,元素按大小排序、支持范围查询。底部总结条:去重双保险是 hashCode 定位桶加 equals 确认重复,只重写其中一个,重复元素就会漏网 图 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 坑过的经历?欢迎聊聊。