Set 如何保证元素不重复的?
结论先行:不同 Set 实现靠不同机制判重。HashSet 把元素当作 HashMap 的 key,用 hashCode 定位桶、再用 equals 确认;TreeSet 靠 compareTo / Comparator 返回 0 判定重复;LinkedHashSet 继承 HashSet 并额外维护插入顺序;CopyOnWriteArraySet 则退化为 equals 线性扫描。核心契约只有一句:equals 相等的对象,hashCode 必须相等。
1. 一张表看全家族
| 实现 | 底层结构 | 判重依据 | 顺序 | 单次操作复杂度 |
|---|---|---|---|---|
| HashSet | HashMap(value 为常量) | hashCode + equals | 无序 | 平均 O(1) |
| LinkedHashSet | LinkedHashMap | hashCode + equals | 插入顺序 | 平均 O(1) |
| TreeSet | TreeMap(红黑树) | compareTo / Comparator == 0 | 排序 | O(log n) |
| CopyOnWriteArraySet | CopyOnWriteArrayList | equals 线性扫描 | 插入顺序 | O(n) |
| ConcurrentSkipListSet | ConcurrentSkipListMap | compareTo | 排序 | O(log n) |
2. HashSet.add 的真身
// HashSet 内部就是一个 HashMap,value 是全局共享的占位对象
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT) == null; // put 返回旧值,null 说明是新增
}
add("a") → map.put("a", PRESENT)
│
├─ 桶为空 → 新建节点,返回 null → add 返回 true
│
└─ 桶中找到 equals 相同的 key
→ 覆盖 value,返回旧 value(PRESENT)
→ add 返回 false,size 不变
remove 同样委托给 map.remove(e) == PRESENT,contains 就是 map.containsKey(e)。所以 HashSet 的去重能力完全来自 HashMap 的键唯一性。
3. 两个条件缺一不可
- hashCode 不同:落到不同桶,
equals根本不会被调用,集合认为是两个不同元素。 - hashCode 相同:进入同一个桶,再逐个
equals比较,相等则覆盖、不等则挂到链上。
只重写 equals 不重写 hashCode,两个“相等”的对象会落到不同桶,于是都被保留——去重直接失效。
4. 代码:不重写 hashCode 的后果
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public class SetDemo {
static class User {
final String name;
User(String name) { this.name = name; }
@Override public boolean equals(Object o) {
return o instanceof User && Objects.equals(name, ((User) o).name);
}
// 故意不重写 hashCode,继承 Object 的地址相关实现
}
public static void main(String[] args) {
Set<User> users = new HashSet<>();
users.add(new User("Tom"));
users.add(new User("Tom"));
System.out.println(users.size()); // 输出:2,去重失败
Set<String> names = new HashSet<>();
names.add("Tom");
names.add(new String("Tom"));
System.out.println(names.size()); // 输出:1,String 已正确实现两个方法
}
}
5. TreeSet 的判重是“比较结果为 0”
- 元素必须实现
Comparable,或在构造时传入Comparator,否则抛ClassCastException。 - 只要
compareTo返回 0 就视为重复,哪怕equals返回 false。 - 因此要求比较逻辑与
equals保持一致,否则会出现“看起来不相等却被去重”的诡异现象。 - 自然排序下不接受 null;允许 null 需要自定义比较器。
import java.util.Set;
import java.util.TreeSet;
public class TreeSetDemo {
public static void main(String[] args) {
Set<String> set = new TreeSet<>();
set.add("b");
set.add("a");
set.add("b"); // compareTo 返回 0,被忽略
System.out.println(set); // 输出:[a, b]
System.out.println(set.first()); // 输出:a
}
}
6. 常见坑
- 可变对象入 Set 后修改参与 hash 的字段:hash 变了,
contains和remove都定位不到,元素变成“幽灵”,等价于内存泄漏。 - HashSet 允许一个 null 元素;TreeSet 自然排序下不允许。
Set.of(...)不可变,传入重复元素直接抛IllegalArgumentException,不是静默去重。- 用
stream().distinct()去重,内部是LinkedHashSet,会保留首次出现的顺序。 - 并发去重用
ConcurrentHashMap.newKeySet(),比Collections.synchronizedSet吞吐更好。 - 需要“去重 + 保留顺序”时选 LinkedHashSet,不要用 HashSet 再自己排序。
常见追问
- HashSet 的 value 为什么用同一个 Object? 只关心 key,共享一个占位对象可以省内存,
add靠返回值是否为 null 判断新增。 - HashSet 的迭代顺序稳定吗? 不保证,扩容会改变桶位,顺序随之变化。
- 如何按插入顺序去重? LinkedHashSet;它通过 LinkedHashMap 的双向链表维护顺序。
- 为什么 Set 没有
get(int index)? 没有下标语义,取元素只能迭代。 - equals / hashCode 的契约是什么? 自反、对称、传递、一致;
equals相等则hashCode必须相等,反之不要求。 - HashSet 和 TreeSet 怎么选? 只要去重且不在乎顺序用 HashSet;需要排序或范围查询用 TreeSet。
小结
Set 的去重不是“比较所有元素”,而是把判重下沉到 Map 的 key:哈希系靠 hashCode 找到桶、再用 equals 确认;树系靠比较器返回 0。记住两条硬规则——重写 equals 必须重写 hashCode;可变对象不要当 Set 元素。这样多数 Set 相关的追问都能顺势展开。