1. 先弄明白:HashSet到底存的是什么?
很多初学者学 HashSet 时,第一反应是“这不就是个去重集合吗”,于是 new 一个 HashSet,往里 add 几个字符串,看到重复的被过滤了,就觉得自己会了。但等到面试官问一句“HashSet 的底层结构是什么?”,或者线上突然出现“明明对象内容一样,Set 里却有两个”的诡异现象,就立刻卡壳了。
我见得最多的场景是这样的:项目里要做用户 ID 去重,大家顺手写了个 Set<String> userIds = new HashSet<>(),跑起来没问题,皆大欢喜。可一旦去重的对象从 String 变成自定义的 User 对象,或者数据量突然涨到几百万条,各种问题就成群结队地冒出来了。今天这篇,我想把 HashSet 和 LinkedHashSet 这两兄弟一次性聊透,从底层原理到实际操作,再把我这些年踩过的坑、排过的错,全部摊开讲。
先说结论:HashSet 本身不存数据,它是个“壳”,内部真正干活的是一个 HashMap。你往 HashSet 里 add 一个元素,实际上是把元素当作 HashMap 的 key 塞进去,value 统一用一个固定的空对象占位。所以 HashSet 的去重规则,本质就是 HashMap 的 key 去重规则——也就是 hashCode 和 equals 的配合逻辑。
这一点看着简单,但信息量很大。它意味着:HashSet 的去重能力完全取决于你放入对象的 hashCode 和 equals 是否被正确实现。String 和 Integer 这些包装类已经帮你实现好了,所以用起来很省心。但自定义对象如果不重写这两个方法,就会发现“去重”完全失效,对象各存各的,一个都不少。这也是我见过最多的初级 Bug 之一。
另外还要注意,HashSet 允许存入一个 null。因为 HashMap 允许 key 为 null,底层会把它放到数组第 0 个位置的特殊逻辑里。这个特性在实战中偶尔会用到,但也容易带来隐患,比如你明明想判断“某个 key 是否不存在”,结果因为塞了个 null 进去,判断结果就反了。
接下来我按自己的理解,把这个结构从底到上拆开讲。
1.1 从 HashMap 说起:HashSet 的“借壳上市”
想真正搞懂 HashSet,先得把 HashMap 的存储逻辑看明白。HashMap 底层是一个 Node 数组,每个 Node 节点保存四个东西:hash 值、key、value,以及指向下一个节点的 next 指针。当多个 key 的 hash 值定位到同一个数组索引时,它们会以链表的形式串起来,Java 8 之后,链表长度超过 8 且数组容量大于等于 64 时,链表会转成红黑树,把查询时间复杂度从 O(n) 降为 O(log n)。
HashSet 的构造方法有一个细节很有意思:它内部直接 new 了一个 HashMap,并且把 PRESENT 这个静态空对象当作所有 key 对应的 value。我贴一段 JDK 源码里的逻辑(JDK 8 版本,后面都以此为基准):
java复制private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
我来解释下这两行代码为什么值得琢磨。
map.put(e, PRESENT) 的返回值是 HashMap 中该 key 之前对应的 value。如果这个 key 之前不存在,put 返回 null,说明添加成功,HashSet 的 add 就返回 true。如果 key 已经存在,HashMap 会用新 value 覆盖旧 value,但旧 value 是 PRESENT,不是 null,所以 HashSet 的 add 返回 false,表示元素重复。
这套逻辑用很巧妙的方式把 HashMap 的复用价值拉满,HashSet 不需要自己再写一套数据存储结构。你往 Set 里塞的对象,本质上全部活在 HashMap 的 key 维度上,这也就意味着 HashSet 的容量、扩容、哈希冲突处理,全部继承了 HashMap 的那套机制。
面试里经常问“HashSet 和 HashMap 有什么关系”,你如果能从源码层面讲清楚 add 方法的实现、PRESENT 占位对象的含义,基本就已经优于大部分候选者了。
1.2 数组、链表、红黑树:三种结构的分工与转换
很多人以为 HashSet 就是“一个大盒子,往里扔东西”,这是理解上的最大误区。实际上,HashSet 的存储单元是数组槽位(bucket),每个槽位后面可能挂着链表或红黑树。
我换个说法:假设你把存储空间想象成一排连续编号的储物柜,每个柜子门上贴着一个标签,标签上的数字是“元素 hash 值经过扰动和取模之后得到的索引位置”。当你 add 一个新元素,会先算出它的 hashCode,经过 HashMap 内部的 hash() 方法做高位扰动,再用 (n - 1) & hash 计算出它在数组中的下标(n 是数组长度,恒为 2 的幂次)。
如果这个下标位置还没有任何元素,直接放进去,一个节点搞定。如果已经有了元素,就要用 equals 逐个比较,判断是否存在“逻辑相等”的对象。没有相等对象,就在链表尾部追加;有相等对象,则判定重复,不添加。当链表过长、分布恶化,就会升级成红黑树来缓解单链查询过慢的问题。
这里有个细节值得记一下:链表转红黑树的触发条件是“链表长度达到 8 且数组容量至少为 64”。如果容量不足 64,即使链表超过 8,也会优先扩容,而不是转树。这个阈值的来历,源码注释里提过是基于泊松分布计算的——在随机哈希情况下,链表长度达到 8 的概率已经极其低,说明大概率是 hashCode 分布出了问题,这时候转树是止损方案,而不是常规优化手段。
实际工作中,我见过有人把自己的对象 hashCode 写得极其糟糕,比如所有对象都返回同一个固定值,结果 HashSet 退化成了链表,几万个元素一查就是 O(n),接口响应直接超时。这种问题很隐蔽,常规监控看不到,得结合数据分布和耗时变化才能定位。
1.3 初始容量和负载因子:两个影响性能的关键参数
HashSet 没有直接对外暴露设置初始容量和负载因子的构造方法,但它的底层是 HashMap,这两个参数实际上是在 HashSet 构造时通过参数传递进去的。创建时如果不指定,默认初始容量是 16,负载因子是 0.75。
负载因子 0.75 意味着:当元素个数超过容量乘以 0.75 时,就会触发扩容,容量翻倍。比如初始容量 16,存放的元素数达到 12 个,数组就会扩容到 32。扩容时,所有元素需要重新计算索引位置,整体搬移,这是一个相对昂贵的操作。
为什么默认是 0.75 而不是 1 或者 0.5?这是空间和时间的一个折中。负载因子越高,空间利用率越高,但哈希冲突概率越大,查询变慢;负载因子越低,冲突减少,但空闲槽位多,内存浪费大。0.75 是 JDK 团队在大量测试后选出的经验值,正常情况下不建议改。
但如果你明确知道要放很多数据,可以提前把容量设置得差不多够用,避免频繁扩容。这里有一个公式:期望元素个数除以负载因子,再加 1 取整,就是比较合理的初始容量。比如你预计放 1000 个元素,1000 / 0.75 + 1 ≈ 1334,那指定初始容量 1334 或 2048 都可以,比先创建默认容量再反复扩容要省不少开销。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 决定去重的两个关键方法:hashCode 和 equals
聊完底层结构,必须进入另一个核心话题:什么样的对象会被 HashSet 认为是“同一个对象”。这个问题不搞清楚,你在实战里一定会遇到“去重失效”或“误伤”两种极端情况。
HashSet 判断元素是否重复,分两步走:第一步比较 hashCode,第二步调用 equals。两个对象的 hashCode 不相等,那它们的 equals 不需要执行,直接判定为不同元素。两个对象的 hashCode 相等,再调用 equals 判断逻辑相等性,如果 equals 也返回 true,就判定为重复。
这个流程看似简单,但有一个致命陷阱:hashCode 相等的两个对象未必 equals 相等,而 equals 相等的两个对象 hashCode 必须相等。后者是 Java 对象规范里的硬性要求,违反这条规则,HashSet、HashMap 这些依赖哈希的集合会全部乱套。
2.1 两个方法如何配合完成“去重”判断
我把这个配合过程拆成伪代码来展示:
java复制// 1. 计算元素的哈希定位
int hash = hash(key.hashCode());
int index = (table.length - 1) & hash;
// 2. 遍历该索引位置的链表或红黑树
if (p.hash == hash && (p.key == key || (key != null && key.equals(p.key)))) {
// 判定为重复,不添加
}
这里注意一个容易忽略的细节:判断 p.hash == hash 是前置条件,如果 hash 值不相等,equals 压根不会被调用。所以即使你的 equals 写得再严谨,只要 hashCode 实现得有问题,equals 也没有机会发挥作用。
反过来还有一种情况:两个对象 hashCode 相同,但 equals 返回 false。这种情况是允许存在的,它们会被存放在同一个数组槽位的链表或红黑树上,查询时需要通过 equals 逐个过滤。这就是哈希冲突,它不影响功能正确性,但会影响查询效率。冲突越多,链表越长,性能越差。
所以 hash 值的设计原则是:分布尽量均匀,避免大量对象集中到少数几个槽位。String 类的 hashCode 实现是 s[0] * 31^(n-1) + s[1] * 31^(n-2) + ... + s[n-1],用 31 作为乘数的原因是它是一个奇素数,可以减少哈希碰撞,而且 JVM 对这个乘法做了优化。自定义对象时,不要直接用 Object 默认的 hashCode(它是基于内存地址生成的),否则两个内容一样的对象会被分到不同槽位,永远无法去重。
2.2 必须遵守的规则和一个典型错误示例
规则简而言之有三条:
- 两个对象 equals 相等,hashCode 必须相等。
- hashCode 相等,equals 不一定相等,这不算违规。
- 重写 equals 时必须重写 hashCode,重写 hashCode 时也需要保证 equals 语义一致。
我来说一个我实际接过的线上问题。有同事定义了一个 User 对象,里面只有 id 和 name 两个字段,他重写了 equals,但没重写 hashCode。于是往 HashSet 里 add 两个 id 相同的 User 对象,结果两个都进去了,重复数据没有过滤掉。排查时一看代码,equals 已经重写了,但 hashCode 是 Object 的默认实现,每个对象的内存地址不同,hashCode 自然不同,HashSet 在第一步就先认为它们不是同一个对象,根本走不到 equals 那一步。
这个坑的隐蔽之处在于:单个方法看都没问题,但组合起来就错了。很多面试题会把它包装成一个“找 Bug”的题目,其实考察的就是你有没有真正理解 hashCode 和 equals 在哈希集合中的协作关系。
2.3 自定义对象进 HashSet 的完整示例
写一个标准且安全的自定义对象,建议这样实现:
java复制public class User {
private Long id;
private String name;
// 构造方法、getter/setter 省略
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(id, user.id)
&& Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name);
}
}
Objects.hash 内部生成一个综合散列值,分布相对均匀,写起来也简洁。如果你的对象在运行期间某个参与 hashCode 计算的字段会发生改变,那必须提前意识到一个问题:对象加入 HashSet 之后,如果它的 hash 值变了,将再也无法从集合中被找到或删除。
举个例子。你把一个 User 对象 add 进 HashSet,然后改动了它的 name 字段,name 如果参与了 hashCode 计算,那么这个对象的 hash 值就变了,而它在数组中的位置还是基于旧 hash 值计算出来的。后续调用 contains 或 remove,都会用新的 hash 值去寻找,找到的槽位大概率不是它所在的槽位,结果就是集合里明明有这个东西,你却查不到、删不掉。这在缓存、在线用户管理等场景中可能造成内存泄漏。
所以,凡是放进 HashSet/HashMap 的对象,设计上尽量保证参与 hashCode 的字段是稳定不变的。如果实在无法避免,那就别用哈希集合,或者不要修改参与哈希计算的字段。
3. LinkedHashSet:带记忆的 HashSet
HashSet 一个很明显的缺点是它没有顺序——你遍历的时候,元素出现的顺序既不是插入顺序,也不是自然排序顺序,而是由哈希值决定的数组遍历顺序。这个顺序对用户来说是“随机”的,而且随着扩容会变化。
如果需要保证元素顺序,可以用 LinkedHashSet。它的全称是 LinkedHashSet,继承自 HashSet,但底层实现换成了 LinkedHashMap。在 HashMap 的基础上,LinkedHashMap 多维护了一条双向链表,用来记录元素的插入顺序。
3.1 底层实现:HashSet 加双向链表
LinkedHashSet 的构造逻辑可以用一句话概括:用 LinkedHashMap 代替 HashMap,其他逻辑不变。也就是说,去重机制和 HashSet 完全一致,只是额外维护了一条贯穿所有节点的链表,这条链表记录了元素的插入顺序。
在遍历 LinkedHashSet 时,迭代器不是按照数组下标逐个访问,而是沿着这条双向链表从头走到尾。所以你能得到一个稳定、可预测的顺序——插入顺序。
这里有个容易忽略的细节:如果已经存在的元素再次被 add,LinkedHashSet 不会改变它在链表中的位置。默认构造模式下,LinkedHashMap 的 accessOrder 是 false,即链表只记录插入顺序,不记录访问顺序。只有当你显式使用 new LinkedHashMap<>(capacity, loadFactor, true) 时,accessOrder 才会变成 true,此时每次 get 或 put 访问过的节点会被移动到链表尾部,这通常用于实现 LRU 缓存。
LinkedHashSet 没有对外暴露 accessOrder 参数,所以它只能保证插入顺序,不能直接拿来做 LRU 集合。如果你需要 LRU 语义,得自己用 LinkedHashMap 包装。
3.2 插入顺序场景下的实际价值
很多人会问:既然要顺序,为什么不用 TreeSet?TreeSet 也能排序,而且是基于红黑树,元素可以按照自然顺序或比较器排序。
关键在于“顺序”的类型不同。TreeSet 排序依赖 Comparable 或 Comparator 的语义,它做的是“排序”,而 LinkedHashSet 保持的是“插入顺序”。插入顺序记录的是业务数据到来的先后关系,这在很多场景下比字典序重要得多。
举个例子:你需要记录一个用户在一段时间内访问过的商品 ID,去掉重复的,同时还要保留第一次访问的时间顺序。HashSet 不能保证顺序,TreeSet 会把 ID 从小到大排序(或者按你自定义的比较器排),完完全全改变原始顺序,只有 LinkedHashSet 能同时满足“去重”和“保持首次出现顺序”两个需求。
类似的场景还有:接口返回的数据需要去重但保留原有顺序、消息处理时需要记录去重后的到达顺序、数据库查询结果做内存去重且不能打乱业务排序等。这些时候,LinkedHashSet 就是标准答案。
性能上,LinkedHashSet 的增删查时间复杂度和 HashSet 一样,都是平均 O(1),但是因为要额外维护双向链表指针,内存占用比 HashSet 高,单元素开销略有增加。数据量小时差别几乎感觉不到,数据量大时如果你的内存本身就紧张,就要考虑这个成本。
4. 实操对比与性能分析
这一节我想把一个很多人在面试里都答不准的问题聊透:HashSet 和 LinkedHashSet 到底差多少性能,值不值得为了“顺序”这个需求付出额外内存和一点速度代价。
先给一个直观的对比表格:
| 对比维度 | HashSet | LinkedHashSet |
|---|---|---|
| 底层结构 | HashMap | LinkedHashMap(HashMap + 双向链表) |
| 元素顺序 | 无保证,受哈希值影响 | 插入顺序(默认 accessOrder=false) |
| 时间复杂度 | 平均 O(1) | 平均 O(1) |
| 额外内存 | 较少 | 每个节点需要额外的 before/after 指针 |
| 迭代速度 | 遍历数组,可能遍历空槽位 | 沿链表遍历,无空槽位损耗 |
| 去重机制 | hashCode + equals | 与 HashSet 完全一致 |
| 适用场景 | 单纯去重、高吞吐缓存 | 需要稳定顺序且要求去重 |
注意一个反直觉的点:单看迭代操作,LinkedHashSet 可能比 HashSet 更快。原因在于 HashSet 的迭代是遍历整个 Node 数组,数组里有大量空槽位,比如容量是 1024,实际只放了 10 个元素,那你要跳过 1014 个空槽;而 LinkedHashSet 的迭代器沿着双向链表走,实际有多少元素就走多少步。
我做过一个简单的压力对比,向两个集合分别插入 10 万个字符串,然后遍历多次统计耗时。结果是:插入阶段两者几乎没差别,LinkedHashSet 略慢几个百分点;遍历阶段反而 LinkedHashSet 更快,尤其在容量空余比较多时优势更明显。这个结果很多人想不到,但确实是结构决定的——数组遍历的“空转”在容量膨胀后很吃亏。
从内存角度来说,LinkedHashSet 的每个节点要额外存两个指针(前驱和后继),在 64 位 JVM 开启压缩指针的情况下,每个节点多出约 16 字节。10 万个节点,额外开销大约 1.6MB。大部分业务场景都可以接受,但如果是内存受限的场景,比如移动端或者高并发缓存驻留大量对象,就要权衡。
另外,HashSet 的初始容量对性能影响很大。默认容量 16、负载因子 0.75,意味着存 12 个元素就会扩容。如果你知道会存几十万个元素,创建时就指定一个合理的初始容量,可以避免大量扩容重排。
java复制Set<String> userIds = new HashSet<>(1024);
Set<Long> orderIds = new LinkedHashSet<>(2048);
这个习惯看起来不起眼,但在高性能场景下积累下来的收益很可观。扩容不仅仅是复制数组那么简单,它要把所有已有元素重新计算 hash 索引,全部搬到新数组,期间会短暂占用双份内存。频繁扩容很可能触发 GC 压力,如果你的应用对延迟敏感,这就会变成线上事故。
5. 常见问题与排查技巧实录
踩坑经验永远是比理论更值钱的。下面这些是我和同事们在实际项目中遇到过的问题,每个都让我印象很深,分享出来帮大家少走弯路。
5.1 哈希冲突严重、链表过长导致查询变慢
有段时间我们一个接口的耗时数据异常,从平均 20ms 涨到 600ms 以上。看日志没有报错,数据库也没变慢,后来定位到是内存里的 HashSet 出了问题。里面存了几万个自定义对象,而对象的 hashCode 写得很随意,大量对象聚集在少数几个槽位上,链表拉得很长,每次 contains 的时间复杂度接近 O(n),整体性能被拖垮。
排查方法也很直接:临时在代码里打印 HashSet 底层数组的长度和几个关键槽位的链表长度,发现有一个槽位上挂了上千个节点,这就异常了。修复方式是重写 hashCode,让散列分布均匀,性能立刻恢复。
事后总结:自定义对象的 hashCode 设计一定要重视。别为了省事用固定值,也别用不稳定的字段。用 IDE 自动生成的 hashCode 模板通常比自己手写靠谱,至少能保证基本均匀。
5.2 可变对象入集合后无法删除
这个坑发生在一次缓存模块重构时。我们把用户的会话对象放进 Set 做管理,后来发现某些用户的会话一直清理不掉,即使手动调用 remove 也没效果,内存越占越多。
原因就是前文提过的“对象参与 hashCode 的字段被修改”。会话对象里有个 lastActiveTime 字段,每次访问都会更新,而这个字段被写进了 hashCode。对象放进去之后,只要时间一更新,hash 值就变了,HashSet 再也找不到它原来的位置,remove 自然无效。
解决办法是重新设计对象,把 hashCode 绑定在不可变的标识字段上,比如 userId。参与哈希的字段不能变,这是放进哈希集合的最基本要求。
5.3 多线程环境下线程不安全引发的数据错乱
HashSet 不是线程安全的,HashSet 内部直接是 HashMap,多线程并发 put 时,可能出现扩容时多个线程同时操作同一个数组,导致死循环或丢数据。这在 Java 7 时代是出了名的坑,Java 8 把头插法改成尾插法后,死循环问题有所缓解,但数据不一致依然存在。
如果你的 Set 需要被多个线程共享并且存在写操作,建议用 Collections.synchronizedSet 包一层,或者直接用 ConcurrentHashMap.newKeySet()。
ConcurrentHashMap.newKeySet() 底层是 ConcurrentHashMap,采用分段锁或 CAS 机制,并发性能比同步包装类好得多,而且 API 和 Set 一致。如果你用 JDK 8 及以上版本,这个方案几乎是最优解。
5.4 面试高频考点整理
面试这个主题时,有几个问题经常出现,我再快速串一下。
“HashSet 是如何保证元素不重复的?”标准答法:底层是 HashMap,把元素当 key,value 是固定占位对象。放入时先计算 hash,定位到数组槽位,再通过 equals 判断是否已有相同对象。
“HashSet 和 TreeSet 的区别是什么?”HashSet 基于哈希表,无序,时间复杂度 O(1);TreeSet 基于红黑树,有序,时间复杂度 O(log n)。如果只去重不要求排序,优先用 HashSet;要求自然排序或自定义排序,用 TreeSet。
“为什么重写 equals 必须重写 hashCode?”从集合逻辑上回答:如果 equals 相等但 hashCode 不同,HashMap 在找桶时就会把它们放到不同的桶里,无法通过 equals 判断到它们相等,导致两个相等对象同时存在于集合中,破坏 Set 的语义。
“HashSet 允许 null 吗?”允许,但只能有一个 null。因为 null 的 hash 值为 0,所有 null 会落到同一个槽位,后续的 null 会被 equals 判定重复。
6. 实际应用场景:哪些时候我首选它们
很多人日常工作里用到 List 比 Set 多,但我个人在下面几类场景一定会优先考虑 Set 系集合,而不是手工去写循环判重。
第一类:数据去重。比如从数据库查出用户填写的标签列表,可能包含重复项,直接放进 HashSet 一次搞定。如果还要求标签按出现顺序展示,就换成 LinkedHashSet。这类操作写起来极简,可读性也好。
第二类:快速判断存在性。判断一个元素是否在已知集合里,List 的 contains 是 O(n),而 Set 是 O(1)。一个典型的例子是防重复提交:把一段时间内处理过的请求 ID 放到 Set 里,新请求先查 Set 再处理。数据量小的时候感觉不出来,数据量大了,差别就是毫秒和秒的区别。
第三类:集合运算。对两个 Set 做交集、并集、差集,可以调用 retainAll、addAll、removeAll 方法,直接得到结果。比如计算两个用户群体的共同关注者,或者统计一批订单号和另一批批次之间的差异,用 Set 自带的集合运算能省下大量样板代码。
java复制Set<Long> batch1 = new HashSet<>(loadBatchIds(1));
Set<Long> batch2 = new HashSet<>(loadBatchIds(2));
// 交集:两个批次都包含的订单
batch1.retainAll(batch2);
// 并集:合并去重
Set<Long> all = new HashSet<>(batch1);
all.addAll(batch2);
// 差集:在 batch1 但不在 batch2 的订单
batch1.removeAll(batch2);
第四类:实现 LRU 缓存或热点数据管理。虽然 LinkedHashSet 不能直接设置 accessOrder,但 LinkedHashMap 可以。如果你想要一个“最近访问优先淘汰”的内存缓存,用 LinkedHashMap 加 accessOrder=true 再重写 removeEldestEntry 就能实现一个精简版。这段代码在很多简历项目里都能看到,但能亲手实现并说清楚原理的人不多。
java复制class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LRUCache(int maxSize) {
super(16, 0.75f, true);
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize;
}
}
这个类在访问已有 key 时,会把该节点移到链表尾部。当容量超过 maxSize,链表头部的“最久未使用”节点会被自动移除。用这种方式管理在线用户会话、热点商品列表之类的数据,效果不错,代码量也少。
我个人在实际操作中比较深的感触是:多数人在写 Java 时,只要遇到去重就反射性地用 HashSet,这没问题,但真正拉开差距的,是你知不知道它在什么情况下会失效、内存占用是多少、多线程下要怎么处理、需不需要保留顺序。这些细节决定了你的代码在真实生产环境里能不能扛得住。
如果你正在准备面试,我的建议是把 HashSet 的源码主动看一遍,尤其是 add、remove、contains 三个方法,再结合 HashMap 的 putVal、resize 逻辑一起理解。看完你会发现,面试里那几道所谓的高频“八股题”其实都有非常清晰且自然答案,不需要死记硬背。
如果你已经工作了,建议在建 Set 的时候多想一步:要稳定顺序吗?要并发安全吗?大概存多少数据?这三个问题想清楚,你选择的集合类型就不会差太多。
最后再分享一个小技巧:排查 HashSet 相关问题时,不要只盯着自己的业务代码,一定要把对象类的 hashCode 和 equals 拿出来重新读一遍。我排查过的几乎每一个“去重失效”案例,最终都指向这两个方法没写对,而不是集合本身有 Bug。理解了这一点,以后再遇到类似问题,定位速度会快得多。
