资讯详情

资讯详情

Java Set集合核心原理与实战:HashSet去重、equals/hashCode避坑指南

如果你在 Java 里做过数据去重、做过来自两个数据源的用户交集分析、或者写过类似“这个 ID 之前见过没有”的判断那一定绕不开 Set。它是 Java 集合框架里几个“看起来很简单一用就翻车”的类型之一面试必问、日常必用、出坑率也极高。去重、集合运算、判重缓存这些能力全都靠它。这篇内容我会从 Set 的三个核心实现类讲起结合源码层面拆解去重原理最后给出一套可以直接抄作业的实操代码和踩坑排查清单。无论你是刚学完 Java 基础准备刷题还是工作两三年想补一补集合这块的底层认知都能从中拿到点东西。内容以常用实践为准不涉及框架层面的花活讲的都是最务实的用法。1. 为什么每个 Java 程序员都该吃透 Set 集合1.1 Set 到底是什么解决了什么问题Set 是 java.util 包下的一个接口继承自 Collection。它的核心语义只有一句话不包含重复元素的无序集合。这里有两个关键词需要拆开看。“不包含重复元素”意味着往 Set 里 add 两次同一个对象集合里只会保留一份。判断“重复”的标准是 equals 方法也就是说两个对象在 equals 比较下相等就会被 Set 视为同一个元素。这个特性和 List 完全不同List 允许重复并且每一个元素都有明确的位置索引。“无序”则取决于具体实现类。HashSet 不保证任何顺序LinkedHashSet 保持插入顺序TreeSet 按自然顺序或自定义比较器排序。所以准确地说Set 是一个“不重复”的集合但“有没有序”要看你怎么选实现类。那它到底解决了什么问题我举几个高频场景你就明白了。比如日志分析中需要统计一天的独立用户 ID一万条日志里可能有大量重复访问你只需要一个 Set 把 ID 全部丢进去最后 size 就是独立用户数。再比如黑名单校验把需要拦截的用户 ID 放进 Set每次请求只要 contains 一下O(1) 完成判断。还比如商品标签系统要给一个商品打标签、去重、做交集Set 天生就是干这个的。Set 核心解决的三个问题去重存储、快速判重、集合运算。这三个能力让它成为算法题、业务开发、数据处理场景中的常客也是 Java 面试里高频出现的基础题。1.2 三个主流实现类的选型对比Java 里最常用的 Set 实现类有四个HashSet、LinkedHashSet、TreeSet还有一个并发场景下的 CopyOnWriteArraySet。前三个是面试和日常开发的主力它们之间的差异主要集中在顺序、性能、排序能力三个维度上。我把它们的核心差异整理成一张表方便你直接对照选型实现类底层结构是否有序排序规则平均时间复杂度使用场景HashSetHashMap无序不排序add/remove/contains 均为 O(1)绝大多数去重、判重场景LinkedHashSet哈希表 双向链表插入顺序按插入先后与 HashSet 基本一致需要保持去重后原始顺序TreeSet红黑树有序自然顺序或 ComparatorO(log n)需要排序、范围查询如取最小、区间子集CopyOnWriteArraySet数组插入顺序不排序写 O(n)读 O(n)高并发读多写少场景你可能会问为什么 HashSet 的 add 和 contains 能 O(1)因为它在内部维护了一个 HashMap元素本身作为 keyvalue 统一用一个固定空对象占位。哈希表通过数组加链表的结构让插入和查询在绝大多数情况下只需要计算一次哈希、定位一个桶时间复杂度接近常数级。选型的核心逻辑其实很简单默认无脑用 HashSet要保序用 LinkedHashSet要排序用 TreeSet。我见过不少同事在需要“去重后还要按原顺序输出”时用了 HashSet结果输出顺序随机被打乱查问题查了半小时最后换成 LinkedHashSet 一行代码解决。记住这个原则能省很多不必要的排查时间。2. 去重的底层密码从 HashMap 到哈希表2.1 HashSet 去重的核心原理很多人会把 HashSet 当成一个“黑盒”来用add 进去一个元素重复的就没了至于怎么做到的说不清楚。这一节我们把盒子拆开。先看 HashSet 的源码这里以 JDK 8 为例。它内部有三个关键字段// 底层mapHashSet的所有操作都委托给这个HashMap private transient HashMapE,Object map; // PRESENT 是一个占位对象所有value都是它 private static final Object PRESENT new Object();构造函数直接 new 了一个 HashMappublic HashSet() { map new HashMap(); }所以 HashSet 的本质是你在操作了一个“只关心 key 不关心 value”的 HashMap。当你调用 add(e) 时实际上执行的是public boolean add(E e) { return map.put(e, PRESENT) null; }HashMap 的 put 方法返回值是“之前该 key 对应的旧 value”。如果旧 value 是 null说明之前没有这个 key本次插入属于新增put 返回 nulladd 就返回 true如果之前已经有相同的 keyput 会返回 PRESENT非 null那么 add 返回 false。源码里这个“返回值是否为空”的判断就是去重的核心机制。remove 同理public boolean remove(Object o) { return map.remove(o) PRESENT; }contains 的实现就更简单了public boolean contains(Object o) { return map.containsKey(o); }理解了这个机制你就理解了HashSet 的去重并不是“插入时扫描全集合比较一遍”而是借助 HashMap 的哈希定位用 key 的 hashCode 快速定位桶再用 equals 比较确认。所以它的去重速度极快数据量大了以后和 List 的 contains 一比差距非常明显。2.2 equals 与 hashCode 的黄金契约既然 HashSet 的去重依赖 HashMap 定位那就必须聊聊 Java 世界里那条最经典的约定equals 相等的两个对象hashCode 必须相等hashCode 相等的两个对象equals 不一定相等。这个约定的原因其实是为了效率。当 HashMap 要判断一个 key 是否已经存在时它首先用 hashCode 计算桶的位置然后在这个桶里的链表或红黑树上用 equals 真正比较元素。如果两个对象 hashCode 不同它们在哈希表里可能就分在不同的桶根本不会走到 equals 这一步HashMap 直接判定为不同元素。如果两个对象 hashCode 相同、equals 也相同就判定为重复。如果两个对象 hashCode 相同但 equals 不同它们会待在同一个桶里形成链表或树虽然性能有损耗但语义正确。这就是为什么如果只重写 equals 而不重写 hashCode会导致两个 equals 相等的对象 hashCode 不同被 HashSet 分到不同的桶最终出现“明明逻辑上相等却去重不了”的闹剧。反过来如果只重写 hashCode 而不重写 equals会频繁发生哈希碰撞性能断崖式下跌甚至出现“hashCode 相同但 equals 不同”的元素被错误聚到一起虽然不会误删但严重拖慢速度。实操中最推荐的写法用 IDEIDEA、Eclipse自动生成 equals 和 hashCode用业务主键作为比较字段。比如一个人对象的唯一标识是 idCard那就用 idCard 来生成这两个方法而不是把 name、age、address 全放进去否则身份证号相同但地址不同的人会被判定为两个元素这种去重逻辑就是错的。2.3 从源码看 add 方法到底做了什么这一段我们走一遍完整的 add 流程帮你把零散的概念串起来。调用set.add(user)时的内部路径如下计算 user 的 hashCode得到哈希值 h。哈希值经过扰动处理JDK 8 的 hash 方法把高 16 位和低 16 位做异或减少碰撞概率。根据 (table.length - 1) h 定位数组下标也就是桶的位置。如果桶为空直接创建新节点放入。如果桶不为空遍历桶内的链表或红黑树用 equals 逐个比较找到相等的 key说明重复返回旧值。没有相等的 key把新节点挂在链表尾部或插入红黑树。返回旧值null 或不 nullHashSet 据此决定 add 返回 true 还是 false。如果插入后哈希表的使用量超过了阈值数组当前长度 × 负载因子 0.75就会触发扩容数组翻倍所有旧元素重新计算桶位并搬迁。这是中间最耗费性能的一个点因此批量插入大集合时最好在初始化时预估容量。这一套流程解释了四个面试高频问题为什么 Set 无序因为桶下标是哈希值决定的。为什么去重这么快因为哈希定位绕过了全量扫描。为什么扩容慢因为需要 rehash 全部元素。为什么 hashCode 出问题 Set 就出错因为整个定位机制都建立在哈希值上。3. 核心细节解析与实操要点3.1 哈希冲突与扩容别小看容量和负载因子哈希表的核心是数组加链表数组的每个位置称为一个桶。理想情况下每个桶里只有一个元素插入和查询都是 O(1)。可现实中没有完美的哈希函数当两个不同对象的 hashCode 落到同一个桶时就发生了哈希冲突。冲突多了链表变长查询退化成 O(n)。Java 8 为了缓解这个问题在链表长度达到 8、数组长度达到 64 时会把链表转成红黑树查询复杂度降到 O(log n)。这里有两个参数值得专门说明初始容量initialCapacity哈希表创建时数组的长度默认 16。它必须为 2 的幂这样(length - 1) hash才能正确替代取模运算。负载因子loadFactor默认 0.75表示数组使用率达到 75% 时就触发扩容。为什么负载因子选 0.75这是时间与空间的折中。负载因子太小空间浪费严重数组明明很大却用了一小半就扩容负载因子太大桶内冲突概率升高查询速度下降。0.75 是 JDK 作者在大量实验后认为空间利用率和时间效率都比较均衡的取值。实战建议如果你提前知道要放入 1000 个元素直接new HashSet(2000)会比默认容量 16 一路扩容到 2048 更高效。估算容量的一般公式是“期望元素个数 ÷ 负载因子 1”向上取整即expectedSize / 0.75 1。我见过有同事对容量毫不关心往 Set 里一次性灌入几十万条数据结果扩容发生了十几次单次任务耗时从 2 秒变成 8 秒。虽然这种情况不算多但在性能敏感的场景里提前规划容量是一个成本极低、收益明显的优化点。3.2 树化与链表化Java 8 在底层做的那些事很多人不知道 Java 8 对 HashMap也就是对 HashSet做了一次重要的结构升级把原本只有数组加链表的存储结构扩展成“数组 链表 红黑树”三种形态。触发树化的条件是链表长度超过 8且数组长度不小于 64。两者缺一不可。如果数组长度还没到 64即使某个桶的链表已经有 10 个节点也只是先扩容数组让元素重新分布而不是立刻树化。树化其实是哈希函数不够理想时的一种“兜底策略”。正常业务数据下链表长度极少超过 8因为哈希分布足够均匀。但当外部攻击者构造大量 hashCode 相同的数据来触发系统最坏情况时红黑树能把查询复杂度从 O(n) 压到 O(log n)避免服务被拖垮。当红黑树里的元素被不断删除、节点数降到 6 以下时又会退化成链表。这个阈值和树化阈值不一样中间有 7 的缓冲区是为了防止在边界反复横跳导致频繁转换。作为使用 Set 的人来说这些底层细节一般不需要干预但它们能帮你理解一个现象为什么 HashMap 分配的桶数量总是 2 的幂因为这样才能用位运算替代取模让分布更均匀、定位更快。理解了树化机制也能解释为什么极端情况下 HashSet 的性能下降没有想象中那么恐怖。3.3 可变对象入 Set 是最大的坑这一条是我实际排查问题中遇到次数最多、也最容易忽略的坑把一个可变对象放入 Set 之后又修改了它参与 hashCode 的字段那么这个对象就“丢”了。举个例子。你有一个 Person 类idCard 作为业务主键参与了 hashCode 和 equals 的生成。现在把一个 Person 放入 HashSetPerson p new Person(110101199001011234, 张三); SetPerson set new HashSet(); set.add(p); System.out.println(set.contains(p)); // true然后你修改它的 idCardp.setIdCard(110101199512123456); System.out.println(set.contains(p)); // false元素明明在里面却查不到问题出在哪当你把它放入 HashSet 时哈希表根据旧 idCard 的 hashCode 把它放进了某个桶。修改 idCard 后这个对象的 hashCode 已经改变了。再 contains 时哈希表按新 hashCode 定位到了另一个桶自然找不到。更可怕的是如果此时你再 add 一个“新对象”进 Set哈希表先去新桶里找没有找到旧对象于是把新对象插了进去。两个逻辑本应重复的对象在集合里同时存在。更糟糕的是这个“丢失”的对象永远不会被 remove 掉。因为你 remove 时按新 hashCode 找桶同样找不到旧对象所潜伏的桶。规避方式很简单Set 中存储的元素尽量是不可变对象。业务上无法避免时规则是“放入集合后不再修改参与 hashCode 的字段”。如果确实需要修改那么先把元素从 Set 里 remove 掉修改完字段后再 add 回去。这一步看起来多余但能避免最恶性的数据幽灵问题。3.4 空值、初始化与不可变 Set关于空值和初始化有几个容易混淆的小点值得一次性说清。null 支持HashSet 和 LinkedHashSet 允许存储一个 null因为 HashMap 允许 null key。TreeSet 不允许 null因为红黑树需要调用 compareTo 来比较元素null 无法参与比较直接抛 NullPointerException。做算法题时在 TreeSet 里塞 null 是新手很容易踩的雷。快速创建 SetJava 9 以后推荐使用Set.of(e1, e2, e3)创建不可变 Set但要注意它有几个限制元素不能为 null、元素不能重复、不能修改。Java 8 及以前只能先创建 HashSet 再一个一个 add。如果只是需要固定集合Set.of简洁得多。还有一种写法是双花括号初始化new HashSetString() {{ add(a); }}它创建了一个匿名内部类会在每次使用时额外生成 class 文件还可能引发内存泄漏不推荐在正式代码里用。Collections.unmodifiableSet如果你想让一个 HashSet 对外只读可以Collections.unmodifiableSet(set)包一层。不过要记住它是浅层只读只是阻止了修改操作内部的元素对象本身还是可以被修改的。做到“集合不可变 元素不可变”才算真正的不可变。线程安全HashSet 本身不是线程安全的并发修改会引发 ConcurrentModificationException甚至破坏内部结构。如果想要线程安全的 Set可以考虑Collections.synchronizedSet(new HashSet())或者并发包里的ConcurrentSkipListSet。还有一个专门的 CopyOnWriteArraySet读多写少场景表现不错后续我会在问题排查章节里具体对比。4. 实操过程与核心环节实现4.1 环境准备与基础用法这一节我带你完整走一遍实操流程。环境方面只要 JDK 8 及以上都可以我这里用 JDK 17 演示。不需要引入任何第三方依赖全部基于 JDK 原生集合框架。先用最朴素的写法感受一下 Set 的基本操作// 1. 创建集合并添加元素 SetString set new HashSet(); set.add(Java); set.add(Python); set.add(Java); // 重复add 会返回 false set.add(Go); System.out.println(集合大小: set.size()); // 3 // 2. 判断是否包含 System.out.println(set.contains(Python)); // true System.out.println(set.contains(C)); // false // 3. 删除元素 set.remove(Go); System.out.println(set.size()); // 2 // 4. 遍历 for (String s : set) { System.out.println(s); }注意一个细节第 3 行和第 5 行加入的都是字符串“Java”但字符串内容相同equals 返回 true所以 HashSet 判定重复add 方法返回 false。此时集合长度是 3 而不是 4。如果写成boolean first set.add(Java)、boolean second set.add(Java)你会看到 first 是 true、second 是 false。这个返回值非常实用可以用来做“是否首次出现”的判断。如果需要保序把 HashSet 换成 LinkedHashSet遍历顺序就是插入顺序。如果需要排序换成 TreeSet默认为自然顺序。三种实现的 API 完全一样只是底层和顺序特性不同。4.2 四种创建方式与初始化细节实战中创建 Set 的方式不止一种不同写法适合不同场景。我把常用的四种列出来// 方式1默认创建 SetInteger set1 new HashSet(); // 方式2指定初始容量适合预知数据量 SetInteger set2 new HashSet(1024); // 方式3带负载因子的完整构造 SetInteger set3 new HashSet(1024, 0.8f); // 方式4从现有集合复制 ListInteger list Arrays.asList(1, 2, 3, 2, 1); SetInteger set4 new HashSet(list); System.out.println(set4); // [1, 2, 3]第四种方式其实就是“用 List 去重”最常见的写法把带重复数据的 List 扔进 HashSet 构造器一次性完成去重。不过要注意这种写法的结果不保证顺序。如果你需要“保持原顺序去重”换成new LinkedHashSet(list)即可。还有一种从数组快速构建集合的写法Integer[] nums {5, 3, 8, 3, 1}; SetInteger set new HashSet(Arrays.asList(nums));在 Java 8 的环境下这种写法很中庸在 Java 9 可以更简洁地写Set.of(5, 3, 8, 3, 1)但 Set.of 不允许重复元素传重复会直接抛 IllegalArgumentException。所以如果你拿到的数据表示“概念上就不该有重复”用 Set.of 反而等于多了一层天然校验如果数据本身可能重复就用构造器复制的方式。初始化时我推荐遵循一个小习惯在可以预估集合大小的地方显式传入初始容量。不需要精确差一两倍也问题不大只要避免从默认容量 16 开始反复扩容就行。数据量几十个时无所谓几千几万时差异实实在在看得到。4.3 核心场景代码去重、交集、并集、差集业务开发中 Set 的四大经典操作是去重、交集、并集、差集。Java 8 之前集合运算需要自己写循环嵌套Java 8 引入了retainAll、removeAll和addAll这几个批量方法配合 Set 的语义可以非常优雅地完成运算。下面是一段完整的演示代码SetInteger base new HashSet(Arrays.asList(1, 2, 3, 4, 5)); SetInteger target new HashSet(Arrays.asList(4, 5, 6, 7, 8)); // 交集base 中同时存在于 target 的元素 SetInteger intersection new HashSet(base); intersection.retainAll(target); System.out.println(交集: intersection); // [4, 5] // 并集两者全部元素 SetInteger union new HashSet(base); union.addAll(target); System.out.println(并集: union); // [1, 2, 3, 4, 5, 6, 7, 8] // 差集在 base 中但不在 target 中 SetInteger difference new HashSet(base); difference.removeAll(target); System.out.println(差集: difference); // [1, 2, 3]关键的编码要点是一定不要直接对原集合调用 retainAll 或 removeAll先拷贝一份副本。new HashSet(base)这一步先把原始数据复制到新的 Set 中然后再做批量操作避免污染原有数据。如果直接在 base 上调用 retainAllbase 本身就会被改掉后续的并集、差集就全乱了。这种写法在权限系统里尤其好用。比如一个用户可以拥有的角色、菜单 ID 集合做“新增角色时计算需要分配的 ID”“移除时计算需要回收的 ID”全部靠这三个操作完成。相比写三层 for 循环Set 的方式更简洁也更不容易出边界错误。4.4 自定义对象去重的完整实现自定义对象去重是面试必考也是业务中真正容易写错的地方。核心问题就一个你希望“根据什么业务字段”判断两个对象相同先定义一个标准的实体类这里用 IDE 的自动生成方式实现 equals 和 hashCode基于业务主键 idCardpublic class Person { private String name; private String idCard; public Person(String name, String idCard) { this.name name; this.idCard idCard; } Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; Person person (Person) o; return Objects.equals(idCard, person.idCard); } Override public int hashCode() { return Objects.hash(idCard); } // getter / setter / toString 省略 }现在实测去重逻辑SetPerson people new HashSet(); people.add(new Person(张三, 110101200001011234)); people.add(new Person(李四, 110101200001011234)); // idCard 相同视为同一人 people.add(new Person(王五, 110101199901019999)); System.out.println(people.size()); // 2不是 3这里有三个关键点每一条都能单独成为面试题第一equals 和 hashCode 必须基于同一个业务字段集合。这里两个方法都只用 idCard不会出现“equals 认为相同但 hashCode 不同”的错位。第二用Objects.equals而不是直接idCard.equals(o.idCard)因为这样可以在两边为 null 时安全处理。Objects.equals(null, null)返回 true不会抛空指针。第三equals 里首先if (this o) return true是快速路径直接比较引用提高性能然后检查类型用getClass() ! o.getClass()而不是instanceof这样更严格避免子类对象与父类对象在 equals 上产生不对称问题。如果你不想改实体类也可以使用标准的TreeSet加 Comparator 或者 Java 8 的流式去重比如list.stream().distinct()。但distinct()使用的是对象的 equals 方法不适合“按字段去重”的需求。要按字段去重最稳妥的方式是借助Collectors.toCollection配合 TreeSet 的 Comparator 构造器ListPerson list Arrays.asList( new Person(张三, 110101200001011234), new Person(李四, 110101200001011234) ); // 按 idCard 去重 ListPerson deduped list.stream() .collect(Collectors.collectingAndThen( Collectors.toCollection(() - new TreeSet(Comparator.comparing(Person::getIdCard))), ArrayList::new )); System.out.println(deduped.size()); // 1这种写法不需要修改实体类运行时通过 Comparator 临时定义去重规则灵活性很高。不过 TreeSet 的语义是基于 Comparator 排序的所以去重之后还会按 idCard 排序如果业务流程对顺序有要求需要额外处理。4.5 数据量大的性能实测与选型建议纸上谈兵到这里我给你补一组可以在自己机器上复现的实测思路。用 100 万条字符串数据对比List 去重、HashSet 去重、LinkedHashSet 保序去重、TreeSet 去重四种做法的耗时差异。大概的测试框架如下ListString data new ArrayList(1_000_000); Random random new Random(42); for (int i 0; i 1_000_000; i) { data.add(user_ random.nextInt(500_000)); // 大量重复 } // List 去重 ListString listDedup new ArrayList(); long start System.nanoTime(); for (String s : data) { if (!listDedup.contains(s)) { listDedup.add(s); } } long cost1 (System.nanoTime() - start) / 1_000_000; // HashSet 去重 start System.nanoTime(); SetString setDedup new HashSet(data); long cost2 (System.nanoTime() - start) / 1_000_000; // 保序去重 start System.nanoTime(); SetString linkedDedup new LinkedHashSet(data); long cost3 (System.nanoTime() - start) / 1_000_000; // 排序去重 start System.nanoTime(); SetString treeDedup new TreeSet(data); long cost4 (System.nanoTime() - start) / 1_000_000;在常规机器上你大概率会看到这样的趋势List 去重最慢耗时可能是 HashSet 的几百倍HashSet 最快LinkedHashSet 比 HashSet 稍慢一点点TreeSet 比 HashSet 慢几倍到几十倍因为红黑树插入的 O(log n) 和对象间比较的开销摆在那里。这个结果说明了两个道理。第一只要不要求顺序去重首选 HashSet它和“顺序无关”的需求天然匹配。第二不要在不需要排序的地方用 TreeSet。我看到过有同学仅仅因为“听说 TreeSet 可以去重”就把普通的去重逻辑写成 TreeSet数据量一上来性能肉眼可见地下降其实一个 HashSet 就够用了。排序和去重是两件事别混在一起。5. 常见问题与排查技巧实录5.1 equals 和 hashCode 不一致引发的“幽灵去重”这是我在代码评审里见过最多的一类问题。现象是你觉得两个对象业务上相同但 Set 里全都保留了或者反过来两个不同的对象被错误地当成同一个导致数据丢失。两个方向的原因不同。“应该去重却没去重”多半是只重写了 equals 没重写 hashCode。两个 equals 相同的对象因为 hashCode 不同被分到了不同的桶HashSet 根本不会拿它们做 equals 比较直接当作不同元素保留。“不应该去重却去重了”多半是 hashCode 写得太宽泛。比如用对象的 hashCode 把所有字段都卷进去或者两个不同对象的 hashCode 碰撞了恰好在同一个桶里且 equals 实现有误导致误判。排查思路很固定先用一个最小测试确认 equals 和 hashCode 行为再检查 Object.equals 和 Object.hashCode 是否基于同一组字段。最简单的定位方法Person p1 new Person(张三, 110101200001011234); Person p2 new Person(李四, 110101200001011234); System.out.println(p1.equals(p2)); // true 说明业务相同 System.out.println(p1.hashCode() p2.hashCode()); // 如果 false就是“应该去重却没去重”的根源这两个判断一旦结果不一致先改 hashCode让它的计算字段与 equals 对齐。对齐之后问题基本都能解决。5.2 Set 元素无法删除或查不到“contains 返回 falseremove 没反应但 set 确实有东西”这是 Set 使用中最诡异的现象。绝大多数情况下原因只有一个元素在放入 Set 后参与 hashCode 的字段被修改了。前文已经详细解释过原理哈希表是用 hash 定位桶的你修改了 hash桶就变了原来的桶里已经没有这个人了。要验证也很简单把元素取出来打印 hashCode再和 contains 时打印 hashCode 对比就能看到不一致。推荐的治疗方案是“行为约束”而不是“事后修复”。在业务代码里凡是需要放入 Set 的实体尽量只用不可变字段参与 hashCode或者规定放入 Set 后的对象只读不写。如果修改是不可避免的业务需求那必须先 remove、再修改、再重新 add这是唯一能同时保证数据一致性和查找正确性的操作路径。另外还有一种更容易忽略的原因你用了两个不同 classloader 加载的同一个类。此时 getClass() 不同equals 返回 falseHashSet 会认为它们不是同一个对象即使字段完全相同。这种情况在应用服务器热部署、插件隔离等场景会偶发排查思路被人忽略时容易在“哈希表原理”上绕很久。提前知道有这种可能性排查时就能少走弯路。5.3 排序失效与 Comparator 隐患TreeSet 的排序好理解也不容易出问题但如果使用不当最容易踩的坑是Comparator 与 equals 不一致时TreeSet 的行为会和 HashSet 完全不同。TreeSet 判断元素是否重复的依据是 Comparator.compare 是否返回 0而不是 equals。这意味着如果你写了一个只看 name 的 Comparator那么两个 name 相同但其他字段不同的对象在 TreeSet 里会被判定为同一个元素并丢弃。这种逻辑在某些场景是好事按字段去重但在另一些场景就是数据丢失的炸弹。另一个高频坑是 Comparator 没有传递性。比如先按年龄排年龄相同再按姓名排如果只用return a.age - b.age当年龄相同时返回 0TreeSet 会认这两个人都“相等”后一个被丢弃。朴素写法o1.getId() - o2.getId()还有整数溢出风险当差值超过 int 范围时结果不对。更稳妥的写法是ComparatorPerson byId Comparator.comparing(Person::getId);或者干脆用Integer.compareComparatorPerson byAge (a, b) - Integer.compare(a.getAge(), b.getAge());排序失效还有一个常见原因TreeSet 的元素是可变对象放入后字段被改了但红黑树的排序位置不会自动更新。这时 contains 和 remove 都可能失败行为表现和 HashSet 的“元素丢失”很像。解决办法同样是“放入后不再改参与排序的字段”。5.4 线程安全与并发环境下的替代方案HashSet 不是线程安全的这不只是理论上的说法。两个线程同时 add可能一个把另一个的元素覆盖也可能在扩容时两个线程同时操作同一个桶导致数据错乱迭代过程中另一个线程修改集合会直接抛 ConcurrentModificationException。如果你在并发场景下需要一个 Set有三个选择我逐个说清楚它们的适用边界Collections.synchronizedSet(SetT)用一个全局锁包裹所有方法写并发时简单可靠但如果读操作也非常频繁锁竞争明显吞吐量上不去。ConcurrentSkipListSetT基于跳表的有序并发 Set支持排序和范围操作并发读性能好写操作不与读互斥适合有序并发场景。CopyOnWriteArraySetT底层是 CopyOnWriteArrayList读操作不加锁写操作每次复制整个底层数组因此读多写极少的场景很合适但数据量大、写频繁时复制开销巨大。我给一个简单的选型判断并发写多、只需要去重不要求排序优先用 synchronizedSet并发读多写极少、集合不大用 CopyOnWriteArraySet并发且需要排序用 ConcurrentSkipListSet。如果你对并发集合的底层实现不熟悉先从 synchronizedSet 开始用它在绝大多数业务场景下已经够稳避免为了性能优化引入不必要的复杂度。最后一个非常隐蔽的并发问题即使你用了线程安全的 Set如果“判断不存在再添加”是分开的两步操作整体仍然不是原子的。比如if (!set.contains(id)) { set.add(id); }两个线程可能同时通过 contains 判断同时执行 add造成最终数据超量。如果你需要“不存在才加入”的原子语义应该使用并发集合提供的复合方法比如 ConcurrentHashMap 的putIfAbsent配合keySet()或者用ConcurrentHashMap.newKeySet()返回的并发 Set它能保证此类操作的原子性。这是我在写并发判重任务时比较常用的一招实测很稳推荐你也试试。最后说一点个人体会。Set 的 API 简单到十分钟就能上手但真正能把它用好的人一定都理解了它背后那张哈希表。很多看起来莫名其妙的问题往底层一追其实都是 hashCode、equals、可变对象这三个老伙计在作怪。遇到异常行为时先别急着改业务逻辑写两行代码打印一下 hashCode 和 equals 的结果定位往往比你想的更快。希望这篇文章能帮你把 Set 的底层逻辑和实战技巧串起来在面试和日常开发里都少踩几个坑。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →