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判断两个元素是否相同的逻辑分两步走:
- 先比较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都一样的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那样直接的兄弟,常见方案就三个:
- 使用Collections.synchronizedSet包装:
Set<String> syncSet = Collections.synchronizedSet(new HashSet<>());- JUC包下的CopyOnWriteArraySet:
Set<String> cowSet = new CopyOnWriteArraySet<>();- ConcurrentSkipListSet:
Set<String> skipSet = new ConcurrentSkipListSet<>();三种方案的取舍:synchronizedSet简单但是锁粒度粗,并发高时会争抢激烈;CopyOnWriteArraySet适合读多写少的场景,写操作会复制整个数组,写频繁时不划算;ConcurrentSkipListSet是基于跳表的并发有序Set,支持排序,性能非常均衡,就是内存占用稍高。
我个人的经验是,高并发下如果只是为了去重,优先考虑CopyOnWriteArraySet,代码侵入小、读性能好;如果既要并发又要排序,用ConcurrentSkipListSet。这两个类在JUC包里都很成熟,别再自己加锁了。
4. 高频问题与排查技巧实录:面试题与运维坑
4.1 面试必问题:HashSet和TreeSet怎么选
面试官不会直接问“Set有哪些实现”,他很可能会换一个姿势:给你一个场景,让你选集合类型并说明理由。
常见的考察点有这些,我列成一张速查表方便记忆:
| 需求场景 | 推荐实现 | 核心理由 |
|---|---|---|
| 单纯去重,不关心顺序 | HashSet | O(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怎么调,底层怎么实现、数据怎么流动,这些才是让你真正和普通开发者拉开差距的地方。