Set 的唯一职责是"不许重复",但它判重的依据不是"你心里的相等",而是 hashCode + equals,或者比较器的返回值。所以去重失效几乎从不是 Set 的 bug,而是判重口径没对齐。
最短的证据链是同一批 BigDecimal:HashSet 里剩两个,TreeSet 里剩一个。下面从 add 的判重路径拆起,再把六种失效情形逐一对上号。
1. HashSet 的判重路径:哈希 → 桶 → equals
HashSet 内部就是一个 HashMap,元素挂在 key 上,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; }
add 的三轮判定与各自失败时的表现:
| 阶段 | 动作 | 失败表现 |
|---|---|---|
| 1 求哈希 | h = key.hashCode(),再 h ^ (h >>> 16) 扰动 |
哈希不稳定 → 落错桶 |
| 2 定位桶 | (n - 1) & hash,n 为 2 的幂 |
桶内超 8 个且容量 ≥ 64 → 转红黑树 |
| 3 桶内比较 | 先 == 再 equals,命中即判重 |
equals 不对称 → 同一逻辑元素进两次 |
| 返回 | 已有元素给 false,否则插入并给 true |
用返回值表达"是否新增"还是"是否已存在"要分清 |
Set<String> s = new HashSet<>();
System.out.println(s.add("k")); // true
System.out.println(s.add("k")); // false —— 集合没有变化
add 返回 false 的准确语义是"集合未改变"。对 LinkedHashSet,重复 add 一个已有元素不会把它挪到队尾——顺序只在首次插入时确定。
2. TreeSet 判等靠 compareTo,LinkedHashSet 买的是顺序
TreeSet 底层是 TreeMap,判重用的是比较器返回值是否为 0,与 equals 无关:
Set<BigDecimal> hash = new HashSet<>();
hash.add(new BigDecimal("1.0"));
hash.add(new BigDecimal("1.00"));
System.out.println(hash.size()); // 2 —— equals 区分标度
Set<BigDecimal> tree = new TreeSet<>();
tree.add(new BigDecimal("1.0"));
tree.add(new BigDecimal("1.00"));
System.out.println(tree.size()); // 1 —— compareTo 返回 0
同一批数据、两个实现、两个大小,差别全在判等口径。五种 Set 的取舍:
| 实现 | 底层 | 判等依据 | 顺序 | add/contains | 允许 null |
|---|---|---|---|---|---|
HashSet |
HashMap |
hashCode + equals |
无序 | O(1) 均摊 | 一个 |
LinkedHashSet |
LinkedHashMap |
同上 | 插入顺序 | O(1),每节点多两个指针 | 一个 |
TreeSet |
TreeMap |
compareTo / Comparator |
排序 | O(log n) | 不允许(自然序 NPE) |
EnumSet |
位向量 | 枚举本身 | 声明顺序 | O(1) 位运算 | 不允许 |
CopyOnWriteArraySet |
CopyOnWriteArrayList |
equals(线性扫) |
插入顺序 | 写 O(n),读 O(1) | 允许 |
比较器一旦比 equals 更粗,元素就会被"吞":
record User(String name, int age) { }
Set<User> set = new TreeSet<>(Comparator.comparingInt(User::age)); // 只按 age 比
set.add(new User("a", 30));
set.add(new User("b", 30));
System.out.println(set.size()); // 1 —— "a" 被当成重复项丢弃
3. 六种去重失效情形
| # | 情形 | 现象 | 根因 |
|---|---|---|---|
| 1 | 只重写 equals 不重写 hashCode |
两个"相等"对象共存 | 默认走 identity 哈希 |
| 2 | 参与哈希的字段可变 | 元素找不到、重复插入 | 哈希漂移 |
| 3 | TreeSet 比较器与 equals 不一致 |
不同元素被吞掉 | 返回 0 即判等 |
| 4 | double / BigDecimal 精度 |
数量比预期多或少 | 二进制浮点误差、scale 不同 |
| 5 | null 与空串混用 |
出现两项"看起来一样" | 语义不同:未知 vs 空值 |
| 6 | 多线程写非并发集合 | 丢元素、size 偏小、偶发 CME |
无同步,扩容竞态 |
第 2 种的现场最隐蔽:元素在集合里,但任何查找都失败。第 6 种则常被误判成"代码里没 add 成功":
Set<String> shared = new HashSet<>(); // 多线程 add:可能丢元素、size 偏小、偶发 ConcurrentModificationException
Set<String> safe = ConcurrentHashMap.newKeySet(); // 并发去重正解,add 是原子的
第 1 种与第 3 种方向正好相反:只重写 equals 时,两个"相等"对象哈希不同、落进不同桶,Set 认为它们没关系——"没认出重复";而比较器返回 0 时,TreeSet 主动判重并丢弃后来者——"认错重复"。
第 4 种要区分两件事:double 的 0.1 + 0.2 != 0.3 是二进制表示问题;BigDecimal 的 1.0 与 1.00 不相等是标度问题。前者该换 BigDecimal,后者该统一 setScale。
#5 属于语义层:null 表示"未知",空串表示"已知为空",Set 按 equals 把它们当两项并没有错,错在业务上把它们当成同一件事。要合并,得显式归一化:
Set<String> normalized = raw.stream()
.map(v -> v == null ? "" : v.trim())
.collect(Collectors.toSet());
另外,Set.of("a", "a") 会在构造时直接抛 IllegalArgumentException: duplicate element,而不是静默去重——不可变集合选择"早失败",这与 HashSet 的"静默去重"是两种设计取向,接参时别混用。
4. 正确去重的三种做法、语义与代价
(1) 让对象自己说清"什么叫相同"——字段全部不可变,equals/hashCode 成对生成:
public record OrderKey(String tenant, long id) { } // 逐字段生成,天然满足契约
(2) 用 TreeSet 并统一比较口径——只在需要有序、或判等口径本就该由比较器定义时使用:
Set<String> ids = new TreeSet<>(Comparator.naturalOrder());
(3) 按业务键去重——对象相等 ≠ 业务唯一,这是最常被混淆的一层:
List<Order> raw = List.of(/* ... */);
// 按单号保留第一条
Collection<Order> unique = raw.stream()
.collect(Collectors.toMap(Order::orderNo, o -> o, (first, dup) -> first))
.values();
// 只要业务键集合
Set<String> orderNos = raw.stream().map(Order::orderNo).collect(Collectors.toSet());
| 做法 | 判等依据 | 适用 | 注意 |
|---|---|---|---|
规范 equals/hashCode |
对象身份 | 值对象 | 字段必须不可变 |
TreeSet + 比较器 |
比较器 | 需有序或自定义口径 | 比较范围大于 equals 会吞元素 |
stream().distinct() |
equals |
契约已正确的流 | 依赖对象自身,不能换成业务键 |
toMap(key, v, merge) |
业务键 | 列表按唯一键聚合 | 值为 null 时 HashMap.merge 抛 NPE |
Collectors.toSet() |
equals |
只取键 | 返回 HashSet,顺序丢失 |
性能对照:
| 操作 | HashSet |
TreeSet |
|---|---|---|
add / contains 均摊 |
O(1) | O(log n) |
| 最坏情况 | 全碰撞退化,树化后 O(log n) | 平衡树,稳定 O(log n) |
| 迭代 | O(n) 量级 | O(n) |
| 常数因子 | 小:一次哈希 + 一两次比较 | 大:每次比较都是一次方法调用 |
| 额外内存 | 数组 + 节点 | 节点 + 颜色位 |
两个最常见的坑:Set 里放数组,以及用 List.contains 做去重:
Set<int[]> set = new HashSet<>();
set.add(new int[]{1, 2});
set.add(new int[]{1, 2});
System.out.println(set.size()); // 2 —— 数组是 identity 语义,内容相同也不算相等
List<String> out = new ArrayList<>();
for (String s : input) {
if (!out.contains(s)) out.add(s); // O(n^2),一万条就是五千万次比较
}
// 等价且线性、还保留首次出现顺序:
Set<String> dedup = new LinkedHashSet<>(input);
5. 排错:五个"明明重复却没去掉"的现场
| 现象 | 先查什么 | 处理 |
|---|---|---|
size() 比预期大,contains 又为 false |
哈希字段是否可变 | 字段改 final,或换不可变 key |
| 元素数量对,但取出来的不是想要的 | 比较器是否比 equals 粗 |
比较器改用业务键全集,或去掉多余字段 |
| 十万条数据去重后还多出几十条 | 是否混了精度不同的数值 | 统一 setScale 或改 long 表示分 |
| 并发下偶尔多出重复 | 用的是不是非并发集合 | 换 ConcurrentHashMap.newKeySet() |
| 去重后顺序乱了 | 用的是 HashSet |
换 LinkedHashSet 保插入序 |
6. 边界:Set 不该背的锅
| 场景 | 更合适的结构 | 理由 |
|---|---|---|
| 需要"最近 N 条"或 LRU | LinkedHashMap + removeEldestEntry |
Set 不表达淘汰策略 |
| 元素本身是数组或可变集合 | 包一层不可变 key | Set 只能按对象的 equals 判重 |
| 需要按多个维度分别去重 | 多个 key 视图或 Map<K, Set<V>> |
一个 Set 只能有一种口径 |
| 数据量极大且内存敏感 | 布隆过滤器 + 精确集合兜底 | 位数组省内存,但有假阳性 |
| 去重后还要高频随机读取 | Map<Key, Entity> |
Set 取值需要额外查找 |
| 只想去重但要求保序且高频写 | LinkedHashSet 而非 TreeSet |
排序成本通常不必要 |
一句话:Set 只回答"是不是同一个","算不算重复"必须由你在 key 上定义清楚。
7. 自测
HashSet.add返回false的确切含义是什么?和"元素已存在"有区别吗?TreeSet判定重复用的是什么?equals返回false的两个对象会被它去重吗?- 举一个"元素在集合里但
contains永远为false"的构造,并说明哈希在其中起什么作用。 Set<int[]>里放两个内容相同的数组,size()是多少?为什么?stream().distinct()与按业务键toMap去重,语义差别在哪?- 用
List.contains去重的时间复杂度是多少?等价的线性写法是什么?