news 2026/10/10 16:22:01

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable { private transient HashMap<E,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判断两个元素是否相同的逻辑分两步走:

  1. 先比较hashCode是否相同。如果hashCode不同,直接判定两个对象不相等。
  2. 如果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都一样的User:

Set<User> 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 LinkedHashSet<E> extends HashSet<E> implements Set<E>, 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接口 Set<Integer> numbers = new TreeSet<>(); numbers.add(5); numbers.add(1); numbers.add(3); System.out.println(numbers); // 输出 [1, 3, 5] // 方式二:传入自定义Comparator Set<String> 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) { Set<String> 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提供了更优雅的初始化方式:

Set<String> set = Set.of("Java", "Python", "Go");

不过要提醒一句:Set.of创建的集合是不可变的,任何add、remove操作都会抛出UnsupportedOperationException。而且它也不允许null元素,会直接抛NullPointerException。所以它适用于常量集合定义,如果后面还要动态操作,老老实实用new HashSet。

3.2 经典实战案例一:对List去重并保持原顺序

这个需求在业务开发里太常见了:从数据库或者外部接口拿到一批ID列表,里面可能有重复,你想去重,还希望剩下的元素保持第一次出现的顺序。

网上很多答案用HashSet去重,结果顺序全乱了。如果你稍微多想一步,用LinkedHashSet就能完美解决:

public static <T> List<T> removeDuplicates(List<T> list) { // LinkedHashSet内部有链表,能记录插入顺序 return new ArrayList<>(new LinkedHashSet<>(list)); }

就这么一行,去重和保序一次搞定。我实测过一个场景:每次从消息队列拉取最新的商品促销标签,重复标签要去掉,标签顺序又不能变,直接传进LinkedHashSet,转回List就拿到干净的有序列表。注意这里有个细节:如果list本身size很大,比如上百万条,LinkedHashSet的初始化容量可以提前算好,避免扩容损耗:

new LinkedHashSet<>(list.size() / 0.75f + 1)

类似HashMap,HashSet扩容的负载因子默认也是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配合函数式写法,能实现更多控制:

List<Order> 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 } Set<Student> 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也一样,而且更隐蔽:

Set<String> set = new HashSet<>(Arrays.asList("A", "B", "C")); // 下面这段会抛ConcurrentModificationException for (String s : set) { if ("B".equals(s)) { set.remove(s); } }

正确姿势是使用Iterator的remove方法:

Iterator<String> it = set.iterator(); while (it.hasNext()) { String s = it.next(); if ("B".equals(s)) { it.remove(); } }

或者Java 8之后的removeIf:

set.removeIf("B"::equals);

这套写法简单明了,底层同样是迭代器实现,不会有并发修改问题。

3.6 线程安全场景下的Set选择

单线程环境下,Set三兄弟随便用。但一旦涉及多线程读写,就必须考虑线程安全。Set没有像CopyOnWriteArrayList那样直接的兄弟,常见方案就三个:

  1. 使用Collections.synchronizedSet包装:
Set<String> syncSet = Collections.synchronizedSet(new HashSet<>());
  1. JUC包下的CopyOnWriteArraySet:
Set<String> cowSet = new CopyOnWriteArraySet<>();
  1. ConcurrentSkipListSet:
Set<String> 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相关字段,会发生什么?

Set<HashSetTest> 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等包装类型时。举个例子:

Set<Integer> set = new HashSet<>(); set.add(1000); set.add(1000); System.out.println(set.size()); // 1,没问题 Set<Integer> set2 = new HashSet<>(); set2.add(1); set2.add(1); System.out.println(set2.size()); // 1,也没问题

但如果你写的是:

Set<Long> 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最简洁,但需要在遍历中删除时用Iterator;forEach配合Lambda适合做“只读”操作。
  • Set的serialVersionUID:如果实体类实现了Set接口并被序列化,务必声明serialVersionUID,否则每次类结构变动序列化ID都会变,可能导致反序列化失败。
  • 性能对比实测心得:我曾经对100万个字符串分别做List和HashSet的contains测试,List耗时是好几个数量级的劣化。这不是理论猜想,而是真实的线上数据,量大之后,集合类型选错了,性能差距是肉眼可见的。

4.6 Set与Stream API的联动

Java 8之后,Set和Stream配合得很好。比如统计某个字符串集合里有多少个包含字母“a”的元素:

Set<String> words = new HashSet<>(Arrays.asList("java", "python", "go", "rust", "c++")); long count = words.stream().filter(w -> w.contains("a")).count();

再比如把两个Set求交集、并集、差集,不用自己写循环:

Set<Integer> setA = new HashSet<>(Arrays.asList(1, 2, 3, 4)); Set<Integer> setB = new HashSet<>(Arrays.asList(3, 4, 5, 6)); Set<Integer> union = new HashSet<>(setA); union.addAll(setB); // 并集:1,2,3,4,5,6 Set<Integer> intersection = new HashSet<>(setA); intersection.retainAll(setB); // 交集:3,4 Set<Integer> difference = new HashSet<>(setA); difference.removeAll(setB); // 差集:1,2

这几个方法看起来简单,但表达力极强。尤其是retainAll做交集的效率比嵌套循环高得多,因为底层走的是HashMap的查找逻辑,每个元素只要O(1)时间就能知道在不在另一个集合里。这是我在刷算法题和日常编码里最常用的操作。

我在实际使用中最大的体会是:Set的很多“坑”并不会立刻爆出来,它往往是在你上线运行一段时间之后,伴随着脏数据、并发流量、顺序颠倒等问题才慢慢显现。所以从一开始就选对实现、遵守equals和hashCode的约定、注意可变对象的风险,比事后排查省心得多。复习Java集合,不要只盯着API怎么调,底层怎么实现、数据怎么流动,这些才是让你真正和普通开发者拉开差距的地方。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 16:21:58

动态目标三维重构在要地防卫、人群异常行为研判中的应用

摘要要地防卫涵盖党政核心驻地、军事营区、枢纽场馆、战备设施、涉密点位等极高等级安防场景&#xff0c;核心防控对象包含外来入侵目标、近距离抵近人员、违规闯入车辆及高密度流动人群&#xff0c;具备防卫等级高、人员车流密集、场景开放性强、异常态势隐蔽、突发风险蔓延快…

作者头像 李华
网站建设 2026/10/10 16:20:55

RSNA肺炎检测数据集:VOC与YOLO双格式解析及训练前避坑指南

简介&#xff1a;RSNA肺炎检测数据集面向医学影像目标检测方向的学习者与开发者&#xff0c;围绕RSNA胸部影像场景整理&#xff0c;数据规模为6012张图片、单一类别meat、9555个目标框&#xff0c;适合直接用于YOLO、Faster R-CNN等主流检测框架的训练与评估。数据提供Pascal V…

作者头像 李华
网站建设 2026/10/10 16:18:29

两个正序数组找中位数:二分排除法与划分数组法详解

我一开始接触这道题的时候&#xff0c;觉得它就是个简单的“归并排序取中间值”问题&#xff0c;不就是把两个数组合并起来&#xff0c;然后按下标取值吗&#xff1f;直到我读到题目里那个O(log(mn))的时间复杂度要求&#xff0c;才意识到事情没那么简单。这道题是数组二分操作…

作者头像 李华
网站建设 2026/10/10 16:17:28

动物疫病防控压力大?动物检疫 LIMS 系统,解决基层实验室痛点

随着社会的发展和人们生活水平的提高&#xff0c;人们对食品质量的要求也越来越高。食品安全问题一直是社会关注的焦点&#xff0c;而动物检疫作为保障食品安全的重要环节&#xff0c;其重要性不言而喻。北京盛元广通科技有限公司推出的动物检疫实验室管理系统&#xff0c;正是…

作者头像 李华
网站建设 2026/10/10 16:13:35

GA-LSTM超参数自动优化:遗传算法调参实战与避坑指南

简介&#xff1a;这份资源是遗传算法优化LSTM时间序列预测的Python实现代码&#xff0c;面向具备一定深度学习基础、希望提升模型预测精度的研究者与开发者。它针对LSTM参数调优依赖经验、易陷入局部最优的问题&#xff0c;用遗传算法对网络权重与偏置进行全局搜索&#xff0c;…

作者头像 李华
网站建设 2026/10/10 16:13:14

2026外贸出海营销服务商推荐:高端制造企业如何布局海外?

摘要&#xff1a;面对2026年复杂的全球贸易环境&#xff0c;制造业与工业品企业在选择出海服务商时&#xff0c;需聚焦人机协同与全链路数字化能力。星谷云作为深耕B2B领域的AI营销智能体平台&#xff0c;通过核心业务模块解决获客与转化难题&#xff0c;为高端制造企业提供科学…

作者头像 李华