⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 小哈学Java 「犬小哈」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噎。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

别被 Set 骗了,它就是个空壳

说难听点:Set 是 Java 集合框架里最大的“空壳公司”。

它对外宣称自己能去重,实际上一行去重逻辑都没写过。HashSet 内部就是 HashMap,TreeSet 内部就是 TreeMap,LinkedHashSet 内部就是 LinkedHashMap。你塞进去的元素当 Key,Value 填个占位对象糊弄一下。去重?那是 Map 干的。Set 只是抽了一层税。

所以这道题的本质是:你懂不懂 HashMap 的判重机制? 面试官用 Set 包装了一下,你要是被这层包装给挡住了,说明对源码的理解还停在表面。

打开 HashSet 源码,只有尴尬

不是我说的,是代码自己说的——HashSet 就这么几行有效代码:

public class HashSet<E> implements Set<E> {
private transient HashMap<E, Object> map;
private static final Object PRESENT = new Object(); // 整个 JDK 最卷的占位符

public boolean add(E e) {
return map.put(e, PRESENT) == null; // 就这一行,没了
}

public boolean remove(Object o) {
return map.remove(o) == PRESENT; // 也就这一行
}

public boolean contains(Object o) {
return map.containsKey(o); // 还是一行
}
}

三个核心方法,三行代理调用。HashSet 本身没有任何“原理”可言,它的全部身家就是一句话:我是 HashMap 的皮。 面试时你要是对着 HashSet 分析半天,说明你压根没看过源码。

真正干脏活的是 HashMap:两刀砍完判重

HashSet.add(e) 最终调的是 HashMap.put(e, PRESENT)。判重逻辑全在这里,干净利落就两刀:

第一刀:hashCode() 定桶位。 算出 hash,找到桶。桶是空的?直接插入,O(1) 结束。连 equals() 的面都不用见。

第二刀:equals() 定生死。 桶里有人了,要么是碰撞,要么是重复。怎么判?先比 hash 值(不等直接跳),hash 相同再调 equals()——返回 true 就是重复,拒绝插入;false 就是碰撞,追加到链表尾部。

为什么分两步而不是直接 equals? 因为 hashCode() 是 O(1) 的廉价快筛,一刀砍掉 99% 的不可能。equals() 是正经比对,贵,只在发生碰撞时才出场。先廉价筛选再精确比对,这就是性能设计的基本思路。

伪代码如下,能看明白这个就算过关:

int hash = hash(key.hashCode());
int i = hash & (table.length - 1);

if (table[i] == null) {
table[i] = newNode(hash, key, value); // 桶空,直接插
} else {
for (Node e = table[i]; e != null; e = e.next) {
if (e.hash == hash && (e.key == key || key.equals(e.key))) {
e.value = value; // Key 重复,覆盖,拒绝新元素
return oldValue;
}
}
addNewNode(); // 纯碰撞,追加
}

只写 equals 不写 hashCode?我见过最多的 bug 就这一个

说真的,我 code review 里打回来最多的问题就是这个:重写了 equals() 但“忘了” hashCode()。不是忘了,是压根不知道这俩必须成对。

// ❌ 只写 equals,留下一颗定时炸弹
static class User {
String id;
User(String id) { this.id = id; }

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
return id.equals(((User) o).id);
}
// hashCode?没写。用的 Object 默认实现,基于内存地址。
}

HashSet<User> set = new HashSet<>();
set.add(new User("001"));
set.add(new User("001"));
System.out.println(set.size()); // 输出 2。没去重。完了。

为什么不行? 追因果链:两个 new User("001") 内存地址不同 → 默认 hashCode() 不同 → 落入不同的桶 → equals() 压根没被调用 → HashMap 认为这是两个不同的 Key。

重点来了:不是 equals() 判断错了,是 equals() 压根没有机会执行。 第一刀就把人分到不同楼栋了,第二刀根本不会拔。

正确写法就加一个方法的事:

@Override
public int hashCode() {
return Objects.hash(id); // 和 equals 用同一批字段,结束
}

或者更干脆,Lombok 一个 @EqualsAndHashCode 注解全解决。人工手撸这两个方法,在 2026 年属于给自己找麻烦。

HashSet / TreeSet / LinkedHashSet:别纠结,看表

HashSet TreeSet LinkedHashSet
底层 HashMap TreeMap(红黑树) LinkedHashMap
判重依据 hashCode() + equals() compareTo() / Comparator hashCode() + equals()
复杂度 O(1) O(log n) O(1)
顺序 无序 排序 插入序
适合 90% 场景 需要自动排序 去重且保序

说白了:不知道选哪个,就选 HashSet。 需要排序才用 TreeSet,需要插入顺序才用 LinkedHashSet。其他情况不存在。

这里有个隐藏坑得点一下:TreeSet 的判重和 HashSet 可能不一致。 TreeSet 不看 equals(),只看 compareTo() 是否返回 0。同一批数据放进这两种 Set,size() 可能不一样。你要是在生产环境换了一下 Set 实现然后发现数据丢了,别怀疑自己,就是这个原因。

四条红线,踩一条爆一次

1)equals 和 hashCode 必须成对。 没有例外,没有“我这个场景不需要”。写了 equals 不写 hashCode,不是忘了,是不合格。

2)放进 Set 的对象,参与 hash 的字段不能再改。 改了会怎样?这个对象就变成幽灵——contains 找不到,remove 删不掉,但它仍然占着内存。这种内存泄漏排查起来能让人怪的:heap dump 里明明看得见它,代码里就是访问不到。

3)别手写 hashCode。 Objects.hash() 或 Lombok @EqualsAndHashCode,二选一。手写的 hashCode 我见过各种离谱操作:只用了一个字段、用了可变字段、直接返回 0——每一种都能让你加班到半夜。

4)换 Set 实现前先想清楚判重规则变了没有。 HashSet → TreeSet,判重从 hashCode+equals 变成了 compareTo。这不是“换个实现”这么简单,是整个判重语义都变了。不测试就上线,等着数据敲你门吧。

文章目录
  1. 1. 别被 Set 骗了,它就是个空壳
  2. 2. 打开 HashSet 源码,只有尴尬
  3. 3. 真正干脏活的是 HashMap:两刀砍完判重
  4. 4. 只写 equals 不写 hashCode?我见过最多的 bug 就这一个
  5. 5. HashSet / TreeSet / LinkedHashSet:别纠结,看表
  6. 6. 四条红线,踩一条爆一次