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

发布时间:2026/10/10 16:23:43

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怎么调底层怎么实现、数据怎么流动这些才是让你真正和普通开发者拉开差距的地方。
延伸阅读

更多相关文章

2026/10/10 16:23:43

基估宝云端同步完全教程:Supabase多设备数据同步设置详解

【免费下载链接】real-time-fund 基金实时估值查看 项目地址: https://gitcode.com/gh_mirrors/re/real-time-fund 点击查看 免费下载 基估宝是一款基于 Next.js 的基金实时估值工具,支持基金估值、持仓收益、交易记录与定投管理。它的默认数据保存在浏…

2026/10/10 16:23:42

OUC编译原理实验全链路解析:从词法分析到x86-64代码生成

简介:本资源为中国海洋大学2020年春季《编译原理》课程全套实验代码与配套文档,面向计算机专业本科生及编译技术初学者,系统覆盖词法分析、语法分析、语义分析、中间代码生成、代码优化、目标代码生成、错误处理及编译器集成等八大核心环节&a…

2026/10/10 17:34:45

LoRA微调与知识蒸馏联合优化实战指南

1. 为什么“更省的微调”和“更小的学生”不是营销话术,而是工程落地的刚性需求LoRA 和知识蒸馏这两个词最近在大模型圈里被反复提起,但很多人一看到“微调”就下意识想到租三台A100跑一周、显存爆满、checkpoint动辄30GB——结果还没调完,预…

2026/10/10 17:34:45

从Demo到生产:Agent架构与多智能体协作实战指南

过去一年里被问得最多的问题,不是“怎么用 LangChain 写一个 Agent”,而是“写完 Demo 之后怎么办”。前后端联调跑通了,Prompt 也调得挺顺,但一放到生产环境,各种奇怪问题全冒出来:上下文错乱、工具调用超…

2026/10/10 17:34:45

Agent从Demo到生产落地:架构、多智能体与可靠性指南

这是最近被问得最多的一类问题:Agent 的 Demo 跑得风生水起,一上生产就露怯。我自己也经历过这个阶段——在内部验证会上,一个能自动查库存、写邮件、汇报结果的智能体把在场的人都看嗨了,可等它真的接到业务系统里,第…

2026/10/10 17:34:45

OpenClaw配置QQ机器人保姆级教程:WSL2+NapCat+OneBot全流程实战

很多朋友第一次接触OpenClaw,都是被那句“让你的AI自己用电脑”吸引过来的。但真到自己动手配置QQ机器人时,才发现坑远比想象中多——WSL2环境报错、Node.js版本不对、MySQL连不上、NapCat转发器配置完但消息就是发不出去……这套组合拳下来,…

2026/10/10 17:34:45

Shell 脚本入门:写好第一个脚本的必备基础

学 Linux 自动化运维,Shell 是绕不过去的第一关。这篇把「Shell 是什么、脚本怎么跑起来、变量怎么写」一次讲清,读完你能独立写出并执行第一个脚本。文中的变量基础部分与旧文《Shell 变量与环境变量》互补,重复的演示已压缩并注明去处。 本…

2026/10/10 17:29:42

LL(1)文法与四元式:IF-ELSE翻译程序的核心实现

简介:针对编译原理课程中IF-ELSE条件语句的翻译程序设计任务,这份资源提供了基于LL(1)分析法并输出四元式的完整工程实现。资源包共17个文件,压缩包仅417KB,包含Visual Studio工程文件(sln、vcproj)、C源代…

2026/10/10 7:31:36

Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化

1. 从“Jev”说起:为什么我要把Agent接进浏览器“Jev”这个词最近在圈子里出现的频率越来越高,很多人第一次听到会以为是某个新模型的名字,其实它更像是一种思路——把Jev模型的能力当作底座,通过Agent的方式去接管浏览器&#xf…

2026/10/9 20:15:56

多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系

1. 从"单兵作战"到"集群协同":多智能体编排到底在解决什么问题如果你最近在折腾 Agent 相关的东西,大概率会有一种感觉:单个 Agent 能做的事情,其实很快就摸到天花板了。你给它一个提示词,挂几个工…

2026/10/8 6:05:44

无源低通滤波器设计实战:从RC到LC,手把手教你避开那些坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 0:04:53

从逻辑门到计算机:数字电路核心原理与全加器搭建实战

如果你拆过一台旧电脑的主板,盯着那些黑乎乎的小芯片看上一会儿,可能会冒出同一个疑问:这堆引脚密集的元件,到底是怎么“变”出那么复杂的应用的?答案并不在某个神秘的部件里,而是在所有芯片内部都在反复使…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑