资讯详情

Java集合Set详解:HashSet去重、LinkedHashSet保序与TreeSet排序

📅 2026/10/10 16:23:57 | 华诺云谱 👁 阅读
Java集合Set详解:HashSet去重、LinkedHashSet保序与TreeSet排序
1. 整体设计与思路拆解Set到底在解决什么问题聊到Java集合很多人第一反应是ArrayList、HashMap这类“用得最勤快”的容器Set往往被一笔带过。但真正到了面试或者线上排查问题的时候你会发现Set才是最容易翻车的那一个。不是说它有多难而是大家对它的理解经常停留在“Set就是去重”这个层面上一旦往深了问HashSet怎么去重LinkedHashSet和TreeSet区别在哪为什么重写equals就必须重写hashCode很多人就卡壳了。我写这篇复习笔记的初衷很简单把Set这个体系从头到尾捋一遍从设计定位到底层实现到实际踩坑一次讲透。不管你是刚学完Java基础正在刷题的学生还是工作了两三年想回头补基础的后端开发这篇内容都能直接拿来用。毕竟集合容器是所有业务代码的地基地基不稳上层写再多设计模式也是白搭。先明确一个概念Set是一种不包含重复元素的集合。注意这个“不重复”的定义——它不是说你往里面放两个相同对象就报错而是重复的数据会被静默丢弃。Set和List最本质的区别就在这里List关注顺序和重复Set关注唯一性和快速查找。所以当你面对“这个业务数据到底该不该重复”的场景时Set就是你第一个该想的容器。Java里Set有三个主流实现各自侧重点完全不同HashSet最常用基于哈希表查重和插入效率极高但是遍历顺序不定。LinkedHashSet在HashSet基础上维护了一个双向链表记住插入顺序代价是略多一点内存。TreeSet基于红黑树元素天然有序可按自然顺序或自定义比较器排序代价是读写效率降低到O(log n)。这个选择题其实就是典型的“用空间换时间还是用时间换空间”的取舍。业务上绝大多数去重需求HashSet就够用如果你又想去重又要保持插入顺序比如做登录会话的顺序记录那就选LinkedHashSet如果还要排序比如需要按序输出商品标签那就得上TreeSet。这三者的选择几乎能覆盖日常80%的业务场景。另外要说清楚一点Set的“去重”并不仅仅是工具层面的价值。很多初学者会自己写双层循环判断contains然后add到List里最后还要手动排序这套操作在数据量小的时候没问题但一旦数据量到万级以上O(n²)的复杂度立刻就会让你尝到苦头。Set之所以是更优解不是因为它“恰好能去重”而是因为它的核心数据结构从设计上就是为了快速判重、快速查找而存在的。理解这一点你才算是真正理解了Set。2. 核心细节解析与实操要点三个实现类的底层逻辑2.1 HashSet的底层其实是HashMap这是Java集合里最经典的一个“表面是Set内核是Map”的设计。HashSet内部维护着一个HashMap字段public class HashSetE extends AbstractSetE implements SetE, Cloneable, java.io.Serializable { private transient HashMapE,Object map; private static final Object PRESENT new Object(); public boolean add(E e) { return map.put(e, PRESENT) null; } }看到没有HashSet的add方法其实就是调用了HashMap的put方法把元素本身当作key然后统一塞一个占位对象PRESENT作为value。这个设计妙在HashMap天然就不允许key重复所以Set的去重能力实际上是借了Map的光。理解了这层关系你就能推断出HashSet的一堆行为特征了无序性来自HashMap的哈希算法元素存放的位置是由hash值经过扰动、取模之后决定的和插入顺序毫无关系。null值可以被放进HashSet因为HashMap允许key为null并且null的hash值定为0。不是线程安全的因为HashMap本身就不是线程安全的。很多人会忽略的一点HashSet的contains方法本质上也是HashMap的containsKey。也就是说HashSet判断一个元素是否存在时间复杂度可以达到O(1)级别这比List的线性扫描快了不是一点半点。日常如果你需要频繁判断某个对象是否在集合里Set就是最优解别再用List去contains了。2.2 去重的核心约束hashCode和equals必须保持一致这是整个Set系列最关键、面试也最爱问的一个点必须拎出来单讲。HashSet判断两个元素是否相同的逻辑分两步走先比较hashCode是否相同。如果hashCode不同直接判定两个对象不相等。如果hashCode相同再调用equals判断。如果equals返回true才判定两个对象是重复的。这两步缺一不可。你只重写equals不重写hashCode就会出现一个非常隐蔽的bug两个业务逻辑上完全相等的对象因为hashCode不同被放进了不同的哈希桶里Set里就会出现“逻辑重复”的数据。举个最典型的案例public class User { private String name; private int age; // 只重写了equals没有重写hashCode Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; User user (User) o; return age user.age Objects.equals(name, user.name); } }这个时候你往HashSet里放两个name和age都一样的UserSetUser userSet new HashSet(); userSet.add(new User(张三, 18)); userSet.add(new User(张三, 18)); System.out.println(userSet.size()); // 输出竟然是2这个输出结果跟直觉完全相反但底层逻辑非常清晰两个对象的hashCode不一样所以HashSet压根没走到equals那一步直接就认定是“不同元素”。这就是为什么Java规范里明确约定重写equals就必须重写hashCode。这已经不是风格问题而是正确性问题。反过来说hashCode相同但equals不同的情况呢会出现哈希碰撞两个不同元素被放到同一个桶里此时链表或红黑树会把这些元素串起来性能会下降但结果是正确的。所以一个好的hashCode算法要尽量减少碰撞。2.3 LinkedHashSet如何在HashSet基础上记住顺序LinkedHashSet的实现看起来复杂本质上就是HashSet的子类public class LinkedHashSetE extends HashSetE implements SetE, Cloneable, java.io.Serializable { public LinkedHashSet(int initialCapacity, float loadFactor) { super(initialCapacity, loadFactor, true); } }注意这个super第三个参数它调用的是HashSet的一个“包级私有”构造器专门用来创建LinkedHashMap。这个LinkedHashMap在原本HashMap的基础上给每个entry之间增加了一条双向链表的链接保存插入的前后关系。所以LinkedHashSet的读取顺序和插入顺序完全一致。它解决的问题很简单HashSet虽然快但顺序不确定这对很多业务场景来说是致命的。比如你要记录用户最近浏览的商品ID去重的同时还必须按浏览时间排序如果直接用HashSet取出来顺序全乱了用LinkedHashSet既去重又保持时间顺序一个容器搞定。代价是额外的内存开销和略低的性能。但说实话在业务主机的内存面前这点差异几乎可以忽略我实际项目中经常直接用LinkedHashSet兜底既不失顺序也不用担心重复。2.4 TreeSet的排序机制和红黑树TreeSet和前面两个完全不同它不依赖哈希表底层是TreeMap也就是红黑树结构。元素的存储位置严格按照大小顺序排列。构造TreeSet的时候你必须给元素指定一种“大小规则”否则元素本身必须实现Comparable接口// 方式一元素实现Comparable接口 SetInteger numbers new TreeSet(); numbers.add(5); numbers.add(1); numbers.add(3); System.out.println(numbers); // 输出 [1, 3, 5] // 方式二传入自定义Comparator SetString names new TreeSet((a, b) - b.compareTo(a)); names.add(Java); names.add(Spring); names.add(Redis); System.out.println(names); // 按字典序倒序输出TreeSet的排序能力在面试里是必考题尤其喜欢问“给你一个对象列表怎么按指定字段去重并排序”。用TreeSet加Comparator就是标准答案之一。TreeSet的性能特征是O(log n)。和HashSet的O(1)相比它慢一些但它能持续维护顺序。这在某些需要“时刻保持有序”的场景里非常值钱比如排行榜、定时任务调度器里的延迟队列。而且红黑树这种数据结构本身就是“自平衡二叉查找树”它能保证在最坏情况下树的高度依然是对数级别不会因为插入顺序而退化成链表。这一点是面试里经常延伸的高频知识点建议顺手把红黑树的性质一并复习了。3. 实操过程与核心环节实现从基础操作到业务实战3.1 基础操作与快速初始化Set的日常操作就是add、remove、contains、size、遍历这一套直接贴代码import java.util.HashSet; import java.util.Set; public class SetBasicDemo { public static void main(String[] args) { SetString set new HashSet(); // 添加元素 set.add(Java); set.add(Python); set.add(Go); // 重复添加返回false不会改变集合内容 boolean added set.add(Java); System.out.println(重复添加结果 added); // 判断是否存在 System.out.println(是否包含Python set.contains(Python)); // 删除元素 set.remove(Go); // 遍历推荐用增强for或forEach for (String lang : set) { System.out.println(lang); } } }从Java 9开始Set提供了更优雅的初始化方式SetString set Set.of(Java, Python, Go);不过要提醒一句Set.of创建的集合是不可变的任何add、remove操作都会抛出UnsupportedOperationException。而且它也不允许null元素会直接抛NullPointerException。所以它适用于常量集合定义如果后面还要动态操作老老实实用new HashSet。3.2 经典实战案例一对List去重并保持原顺序这个需求在业务开发里太常见了从数据库或者外部接口拿到一批ID列表里面可能有重复你想去重还希望剩下的元素保持第一次出现的顺序。网上很多答案用HashSet去重结果顺序全乱了。如果你稍微多想一步用LinkedHashSet就能完美解决public static T ListT removeDuplicates(ListT list) { // LinkedHashSet内部有链表能记录插入顺序 return new ArrayList(new LinkedHashSet(list)); }就这么一行去重和保序一次搞定。我实测过一个场景每次从消息队列拉取最新的商品促销标签重复标签要去掉标签顺序又不能变直接传进LinkedHashSet转回List就拿到干净的有序列表。注意这里有个细节如果list本身size很大比如上百万条LinkedHashSet的初始化容量可以提前算好避免扩容损耗new LinkedHashSet(list.size() / 0.75f 1)类似HashMapHashSet扩容的负载因子默认也是0.75提前算好容量能减少resize次数这个优化在小数据量时无所谓大数据量时还是能省下不少时间的。3.3 经典实战案例二按对象字段去重List里存对象时去重逻辑要比基本类型复杂得多。假设我们有一个订单列表每个订单有订单号和服务商编码现在要按服务商编码去重保留一条记录public class Order { private String orderId; private String supplierCode; // 构造器、getter、setter省略 // 同一个服务商视为重复 Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; Order order (Order) o; return Objects.equals(supplierCode, order.supplierCode); } Override public int hashCode() { return Objects.hash(supplierCode); } }这里又回到第2节说的约束equals和hashCode必须一致都只针对supplierCode。这样HashSet就会自动把同一服务商的订单合并成一个。不过这里要特别提醒用业务字段去重往往意味着“后一条数据不能覆盖前一条”。如果希望保留最先出现的订单那用LinkedHashSet如果希望保留最后一条那就得先倒序处理或者干脆不用Set改用Map的merge操作。实际操作中我经常用Collectors.toMap配合函数式写法能实现更多控制ListOrder uniqueOrders new ArrayList( orders.stream().collect( Collectors.toMap( Order::getSupplierCode, Function.identity(), (first, last) - last, // 保留后一条 LinkedHashMap::new // 保持键的插入顺序 ) ).values() );这套写法的本质其实是利用了Map key唯一的特性跟Set殊途同归但控制力更强。你有空可以对比感受一下。3.4 经典实战案例三TreeSet完成自定义排序去重面试里常有一个变体题给一个Student列表要求先按成绩从高到低成绩相同按年龄从小到大同时要去重。这时候TreeSet就很合适public class Student { private String name; private int score; private int age; // 省略构造器、getter/setter } SetStudent sortedStudents new TreeSet( Comparator.comparingInt(Student::getScore) .reversed() .thenComparingInt(Student::getAge) ); sortedStudents.addAll(studentList);注意TreeSet去重的逻辑它是通过比较器结果是否为0来判定两个元素是否相等的。所以你的Comparator写得好不好直接决定去重是否准确。如果Comparator里漏了一个字段两个不同学生就可能被误判为同一个。另外还有一个和HashSet不一样的地方TreeSet不要求你重写equals和hashCode。它只信赖Comparator的结果。但为了保证代码规范、方便后续在其他集合里使用我还是建议实体类里equals、hashCode、compareTo三者保持一致的设计。还要注意TreeSet不能放null元素因为排序时没法比较null和其他元素会抛NullPointerException。这是和HashSet最大的区别之一。3.5 迭代安全遍历时修改Set的问题很多人在遍历List时知道不能用for循环去remove否则抛ConcurrentModificationException。Set也一样而且更隐蔽SetString set new HashSet(Arrays.asList(A, B, C)); // 下面这段会抛ConcurrentModificationException for (String s : set) { if (B.equals(s)) { set.remove(s); } }正确姿势是使用Iterator的remove方法IteratorString it set.iterator(); while (it.hasNext()) { String s it.next(); if (B.equals(s)) { it.remove(); } }或者Java 8之后的removeIfset.removeIf(B::equals);这套写法简单明了底层同样是迭代器实现不会有并发修改问题。3.6 线程安全场景下的Set选择单线程环境下Set三兄弟随便用。但一旦涉及多线程读写就必须考虑线程安全。Set没有像CopyOnWriteArrayList那样直接的兄弟常见方案就三个使用Collections.synchronizedSet包装SetString syncSet Collections.synchronizedSet(new HashSet());JUC包下的CopyOnWriteArraySetSetString cowSet new CopyOnWriteArraySet();ConcurrentSkipListSetSetString skipSet new ConcurrentSkipListSet();三种方案的取舍synchronizedSet简单但是锁粒度粗并发高时会争抢激烈CopyOnWriteArraySet适合读多写少的场景写操作会复制整个数组写频繁时不划算ConcurrentSkipListSet是基于跳表的并发有序Set支持排序性能非常均衡就是内存占用稍高。我个人的经验是高并发下如果只是为了去重优先考虑CopyOnWriteArraySet代码侵入小、读性能好如果既要并发又要排序用ConcurrentSkipListSet。这两个类在JUC包里都很成熟别再自己加锁了。4. 高频问题与排查技巧实录面试题与运维坑4.1 面试必问题HashSet和TreeSet怎么选面试官不会直接问“Set有哪些实现”他很可能会换一个姿势给你一个场景让你选集合类型并说明理由。常见的考察点有这些我列成一张速查表方便记忆需求场景推荐实现核心理由单纯去重不关心顺序HashSetO(1)判重性能最优去重且保持插入顺序LinkedHashSet双向链表记录顺序去重且需要排序输出TreeSet红黑树天然有序高并发环境下去重CopyOnWriteArraySet读写分离读多写少场景优秀高并发有序去重ConcurrentSkipListSet跳表实现并发安全且有序选型的关键就一句话性能、顺序、排序三者只能按需取舍没有全能选手。4.2 可变对象放进Set后的致命陷阱这是很多人写代码时完全没有意识到的坑把一个对象放进HashSet之后如果你修改了这个对象的hashCode相关字段会发生什么SetHashSetTest set new HashSet(); HashSetTest obj new HashSetTest(A); set.add(obj); obj.setName(B); // 假设name参与hashCode计算 System.out.println(set.contains(obj)); // 大概率输出false你有没有想过contains一个明明还留在集合里的对象返回的却是false原因很简单修改对象字段后它的hashCode变了HashSet里存储位置还是基于旧的hashCode算出来的。现在用新的hashCode去查找自然找不到。这个问题的可怕之处在于对象还在Set里但你用contains查不到也无法remove形成逻辑泄漏。如果是内存敏感的场景这些“幽灵对象”还可能导致内存无法释放。教训就一条放进Set的对象要么设计成不可变字段用final修饰要么在修改字段时先把对象从Set中移除改完再放回去。尤其在做缓存、会话管理的时候不可变性几乎是必须的。4.3 自动装箱和equals的坑这个坑主要出现在新手代码里尤其是在HashSet使用Integer等包装类型时。举个例子SetInteger set new HashSet(); set.add(1000); set.add(1000); System.out.println(set.size()); // 1没问题 SetInteger set2 new HashSet(); set2.add(1); set2.add(1); System.out.println(set2.size()); // 1也没问题但如果你写的是SetLong set new HashSet(); set.add(1000L); set.add(1000); // 编译报错类型不匹配Integer和Long是不同类equals比较时会直接返回false。所以你在用contains判断的时候务必保证类型一致。这种错误不会给你任何提示编译过了跑起来结果就是错的排查起来特别耗时间。4.4 Redis的SET类型和Java的Set有什么关系因为Redis的SET类型在业务里用到得太频繁了很多Java开发会把它和Java的Set混为一谈。这里重点区分一下Redis的SET是无序、不可重复的字符串集合底层实现是哈希表或整数集合取决于元素类型和数量它解决的问题和Java的HashSet几乎一致都是判重和集合运算交集、并集、差集。但两者完全是不同层级的东西Java的Set跑在你的JVM进程里Redis的SET跑在独立的服务器上。Java的Set并发不安全Redis的SET天生支持高并发。Java的Set操作是进程内的内存操作Redis的SET每次操作都有网络开销。业务上常见的组合拳是用Redis的SET做分布式环境的去重比如用户每日签到ID用Java的Set做单机JVM内的去重比如一次请求内的标签列表。理解了各自的边界你才不会在系统设计里选错工具。4.5 其他高频小细节HashSet的初始容量和负载因子默认16和0.75如果你能预估数据量建议构造时指定初始容量。比如知道大概要放10000个元素可以这样new HashSet(10000 / 0.75f 1)避免中途多次扩容影响性能。遍历Set时增强for、forEach、Iterator三者的选择增强for最简洁但需要在遍历中删除时用IteratorforEach配合Lambda适合做“只读”操作。Set的serialVersionUID如果实体类实现了Set接口并被序列化务必声明serialVersionUID否则每次类结构变动序列化ID都会变可能导致反序列化失败。性能对比实测心得我曾经对100万个字符串分别做List和HashSet的contains测试List耗时是好几个数量级的劣化。这不是理论猜想而是真实的线上数据量大之后集合类型选错了性能差距是肉眼可见的。4.6 Set与Stream API的联动Java 8之后Set和Stream配合得很好。比如统计某个字符串集合里有多少个包含字母“a”的元素SetString words new HashSet(Arrays.asList(java, python, go, rust, c)); long count words.stream().filter(w - w.contains(a)).count();再比如把两个Set求交集、并集、差集不用自己写循环SetInteger setA new HashSet(Arrays.asList(1, 2, 3, 4)); SetInteger setB new HashSet(Arrays.asList(3, 4, 5, 6)); SetInteger union new HashSet(setA); union.addAll(setB); // 并集1,2,3,4,5,6 SetInteger intersection new HashSet(setA); intersection.retainAll(setB); // 交集3,4 SetInteger difference new HashSet(setA); difference.removeAll(setB); // 差集1,2这几个方法看起来简单但表达力极强。尤其是retainAll做交集的效率比嵌套循环高得多因为底层走的是HashMap的查找逻辑每个元素只要O(1)时间就能知道在不在另一个集合里。这是我在刷算法题和日常编码里最常用的操作。我在实际使用中最大的体会是Set的很多“坑”并不会立刻爆出来它往往是在你上线运行一段时间之后伴随着脏数据、并发流量、顺序颠倒等问题才慢慢显现。所以从一开始就选对实现、遵守equals和hashCode的约定、注意可变对象的风险比事后排查省心得多。复习Java集合不要只盯着API怎么调底层怎么实现、数据怎么流动这些才是让你真正和普通开发者拉开差距的地方。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑