2362 字
约 7 分钟
15
S04-X10 · Set 去重原理与去重失效的六种情形

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. 自测

  1. HashSet.add 返回 false 的确切含义是什么?和"元素已存在"有区别吗?
  2. TreeSet 判定重复用的是什么?equals 返回 false 的两个对象会被它去重吗?
  3. 举一个"元素在集合里但 contains 永远为 false"的构造,并说明哈希在其中起什么作用。
  4. Set<int[]> 里放两个内容相同的数组,size() 是多少?为什么?
  5. stream().distinct() 与按业务键 toMap 去重,语义差别在哪?
  6. 用 List.contains 去重的时间复杂度是多少?等价的线性写法是什么?
S04-X10 · Set 去重原理与去重失效的六种情形
https://www.clxhxhhr.top/posts/5756/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。