☰
Java Set集合从底层到选型:HashSet、LinkedHashSet、TreeSet全面解析
2026/10/9 6:53:32 网站建设 项目流程

聊到 Java 集合,Set总是最容易被问到、也最容易被讲糊的一块。ArrayList大家多少都熟,LinkedList背背书也能应付过去,但一碰到HashSet、LinkedHashSet、TreeSet三兄弟,很多人就会开始混乱:到底哪个有序?哪个能放null?去重该用哪个?面试官再追问一句“Set和List到底什么区别”,回答往往就变成了“List有序,Set无序”,然后被一句“TreeSet不是有序吗”直接噎住。我最早学的时候也是靠背,后来真正在项目里做过订单号去重、排行榜、操作记录保序这些需求,才意识到这些选择根本不是记忆题,而是由底层数据结构决定的。这篇文章就照着三个实现一层层拆开讲:底层是什么、什么时候选谁、有哪些坑,末尾再放几个我实际项目里直接用过的工具方法,适合准备面试、做集合选型、或者想重新梳理 Java 集合体系的同学。

1. 先把 Set 的行为模型立起来:它和 List 的根本区别

1.1 唯一性,才是 Set 的身份证

List和Set都是从Collection接口分化出来的,但两者的“性格”完全不同。List允许重复元素,并且每个元素都有下标,你可以靠indexOf找位置,也可以靠get(i)随机访问,它的核心是“有序列表”。Set则是一个数学意义上的集合,核心规则是“不允许重复”,它不承诺你能用下标去访问元素,也不保证元素之间的相对位置,所有的设计都围绕唯一性展开。

这个区别在 API 上最直观的体现就是add方法的返回值。List.add永远返回true,因为列表不关心你是不是重复;而Set.add如果加进去一个已经存在的元素,会返回false,并且不会改变集合内容。这个设计不是随意定的,它就是Set整个语义的入口。后面我们看到的几乎所有去重逻辑,都是靠这一层行为撑起来的。

还有一个很多人忽略的点:Set不是“一定乱序”,而是“不保证顺序”。不同实现有不同策略,HashSet不承诺、LinkedHashSet承诺插入顺序、TreeSet承诺按规则排序。所以面试时张口就说“Set 无序”是错的,准确说法应该是“Set 的语义不依赖顺序,顺序由具体实现决定”。

1.2 三个实现,三种秩序

HashSet底层是哈希表,所以它追求的是“快”,遍历顺序不固定,甚至同一个集合在扩容前后顺序都可能变化;LinkedHashSet在哈希表基础上加了一条双向链表来记录插入顺序,所以它既快又能保持“先来后到”;TreeSet底层是一棵红黑树,每次插入都按照比较器排序,所以遍历出来永远是排好序的。

三个实现的差异可以先用一张表看清楚:

实现底层结构遍历顺序能否放 null平均复杂度典型场景
HashSetHashMap不保证允许一个 nulladd/remove/contains 都是 O(1)去重、白名单、快速判存在
LinkedHashSetLinkedHashMap插入顺序允许一个 null绝大多数操作 O(1),有链表维护开销去重 + 保持原始顺序
TreeSetTreeMap(红黑树)按比较器/自然顺序一般不允许add/remove/contains 都是 O(log n)自动排序、范围查询、排行榜

这张表不是用来死记的,它是后面所有选型判断的总纲。什么时候用哪个,本质上就是看你要不要顺序、要哪种顺序、能接受多大的时间成本。

1.3 理解 add 的返回值:后续所有去重逻辑的起点

很多人写去重代码时习惯if (!set.contains(x)) set.add(x),其实这个写法是多余的。Set.add自己就会判断重复,并且返回 boolean:

Set<String> set = new HashSet<>(); System.out.println(set.add("apple")); // true System.out.println(set.add("banana")); // true System.out.println(set.add("apple")); // false,加不进去

这个行为背后依赖的是元素的hashCode和equals。对HashSet来说,先通过hashCode定位到桶,再用equals在桶里比对;对TreeSet来说,则是通过Comparator或者Comparable.compareTo直接比较,返回 0 就认为是同一个元素。搞懂这条链路,很多诡异问题都能迎刃而解。

2. HashSet:最常用的去重容器,底层其实是个 HashMap

2.1 包装不是秘密:HashSet 就是穿了马甲的 HashMap

HashSet的源码其实很直白:它内部维护了一个HashMap,往HashSet里add的时候,其实是把元素当作 key 放进HashMap,而 value 统一用一个内部常量PRESENT占位。源码大概长这样:

private static final Object PRESENT = new Object(); public boolean add(E e) { return map.put(e, PRESENT) == null; } public boolean remove(Object o) { return map.remove(o) == PRESENT; }

map.put(e, PRESENT) == null的意思是:如果 key 之前不存在,put返回 null,说明这次 add 成功;如果 key 已经存在,put返回旧 value,旧 value 就是PRESENT,不是 null,所以 add 返回 false。这段代码把“去重逻辑”直接复用了HashMap的键值唯一性,所以说穿马甲并不夸张。

既然底层是哈希表,那就离不开两个参数:初始容量和负载因子。默认初始容量是 16,负载因子是 0.75,意思是当元素个数超过16 * 0.75 = 12时,哈希表就会扩容,容量变成原来的两倍,所有元素重新散列,也就是 rehash。扩容本身是耗时的,所以如果你提前能预估数据量,就应该在创建的时候把容量设置好,减少扩容次数。

2.2 HashSet 的快,建立在什么代价上

HashSet的contains为什么快?因为它是先算hashCode,直接跳到对应的桶,桶里顶多几个元素,再用equals精确定位。这和ArrayList的线性扫描完全不同,后者要一个个比过去,数据量一上来差距非常明显。

但这份快也有代价。哈希表本身是一张数组加链表/红黑树的结构,每个节点除了存元素本身,还要存哈希值、next 指针等信息,内存占用比ArrayList高。而且如果你的元素hashCode写得烂,大量元素撞到同一个桶里,链表会变长,查找效率会从 O(1) 退化到 O(n),严重时甚至会失去意义。所以使用HashSet的前提之一,就是元素对象的hashCode和equals必须正确实现。

2.3 实战:什么时候无脑选它,容量怎么给

实际项目里,最常见的HashSet用法就是去重和快速判存在。比如用户提交一批订单号,要过滤掉重复的;或者系统启动的时候加载一批封禁用户 ID 到内存,后面每个请求都要判断当前用户是否在名单里。这种场景不需要顺序,也不要求排序,HashSet就是最优选择。

有一个细节值得单独拿出来讲:构造HashSet时传进去的参数是“初始容量”,不是“预期元素个数”。很多人写new HashSet<>(1000)以为就能直接存 1000 个不扩容,但默认负载因子是 0.75,容量 1000 时阈值只有 750,存到 751 个就会触发扩容。想不扩容,初始容量应该按预期元素数 / 负载因子 + 1来算,也就是:

int expectedSize = 1000; Set<String> idSet = new HashSet<>((int) (expectedSize / 0.75f) + 1);

这个写法看着麻烦,但在批量导入、百万级去重场景里,能省下不少扩容和 rehash 的时间。注意:new HashSet<>(expectedSize)在预期元素较多时也会扩容,别被构造器签名骗了。

2.4 可变对象放进 HashSet,后果比想象中严重

这是非常隐蔽的一个坑。如果放入HashSet的对象是可变的,而且你后来修改了参与hashCode计算的字段,这个对象就会“迷失”在集合里:它的哈希值变了,但它在哈希表中的位置还是按旧哈希值算出来的,于是contains找不到它,remove也删不掉它,相当于对象泄漏在集合里了。

举个典型例子:

class User { String name; // 构造函数、getter、setter 省略 @Override public int hashCode() { return name.hashCode(); } @Override public boolean equals(Object o) { // 按 name 判断相等 } } User u = new User("张三"); Set<User> userSet = new HashSet<>(); userSet.add(u); u.setName("李四"); // 修改了参与 hashCode 的字段 System.out.println(userSet.contains(u)); // false,元素“丢了”

我自己踩过类似的坑后,给自己定了一条规矩:凡是要放进HashSet、HashMapkey 位置的对象,都尽量设计成不可变对象;如果必须可变,那就先remove再修改、修改完重新add,不要让它待在集合里被改。

3. LinkedHashSet:既要唯一又要顺序,它是 HashSet 的温和升级

3.1 它究竟是怎么“记住”顺序的

LinkedHashSet是HashSet的子类,它在内部使用了一个LinkedHashMap。LinkedHashMap和普通HashMap最大的区别是:每个节点上额外维护了before和after两个指针,把所有节点串成一条双向链表,从而能记录元素插入的先后顺序。

这个设计的好处是:它保留了HashSet的去重能力和大部分 O(1) 性能,同时让遍历顺序稳定下来。每次迭代都是沿着链表走,输出顺序就是你插入元素的顺序。注意这里的“插入顺序”有个细节:如果往集合里重复添加一个已经存在的元素,它不会改变这个元素在链表中的位置,也就是说集合的遍历顺序完全由“首次插入”的时间决定。

LinkedHashSet<String> set = new LinkedHashSet<>(); set.add("A"); set.add("B"); set.add("A"); // 加不进去,也不会改变 A 的位置 for (String s : set) { System.out.print(s); // 输出 AB,而不是 AAB }

这一点特别适合回答面试题“如何去重且保持原来的顺序”。如果用HashSet去重,顺序是不保证的;如果用LinkedHashSet,去重之后顺序还是和原始数据一致,省了你手动排序的功夫。

3.2 最典型的落地场景:列表去重保序

项目里最常见的需求就是“用户传了一串 ID,可能重复,我要把重复的去掉,但是顺序不能变”。比如前端传了sku1, sku2, sku1, sku3, sku2,后端希望最终得到sku1, sku2, sku3,顺序跟用户提交的一致。用LinkedHashSet一行就解决了:

List<String> raw = Arrays.asList("sku1", "sku2", "sku1", "sku3", "sku2"); LinkedHashSet<String> orderedUnique = new LinkedHashSet<>(raw); List<String> result = new ArrayList<>(orderedUnique); // result = [sku1, sku2, sku3]

这种场景在订单去重、工单去重、用户最近浏览记录里很常见。还有一个扩展思路:如果你想给LinkedHashMap做访问顺序排序(LRU 缓存),可以用LinkedHashMap的accessOrder参数,但LinkedHashSet本身不支持,它永远按插入顺序。如果你需要“最近访问过的唯一元素集合”,那不能直接用标准LinkedHashSet,得自己封装或换用其他结构。

3.3 内存开销与性能边界

天下没有免费的午餐。LinkedHashSet比HashSet多出来的,就是每个节点上那两条链表指针。数据量小的时候无所谓,但在百万级元素场景下,这部分额外开销会很明显。如果你只需要去重、不需要顺序,就不要为了“可能有用”去用LinkedHashSet;如果顺序是业务必需,那这点内存换稳定性是值得的。

另外,LinkedHashSet虽然遍历顺序稳定,但它的删除操作也需要同时维护双向链表,所以比纯HashSet多了一点常数级开销。不过这里说的性能差异在绝大多数业务场景里都可以忽略,真正该关心的还是“业务上到底要不要顺序”。

4. TreeSet:自带排序和范围查询,但别把它当普通 Set 用

4.1 红黑树不是玄学,是一棵“自动有序”的树

TreeSet底层是一个TreeMap,也就是一棵红黑树。红黑树是一种自平衡的二叉查找树,插入、删除、查找的时间复杂度都在 O(log n)。它和哈希表最大的区别是:元素一进去就会根据比较规则找到自己的位置,整个树始终保持有序状态,所以你从头遍历的时候,天然就是排好序的。

TreeSet不用hashCode和equals来判断重复,它靠的是Comparator或者元素自身实现的Comparable。比较结果返回 0,就认为两个元素是“同一个”,后一个就加不进去。这一点是无数人踩坑的地方:如果你有一个自定义类没有实现Comparable,又没有给TreeSet指定Comparator,那么add的时候会直接抛ClassCastException。这点和HashSet完全不同,HashMap可以通过 equals 判断,TreeSet 必须在比较层面把元素排好序。

null在TreeSet里也是个敏感话题。默认情况下,按自然排序的TreeSet不能放null,因为它要调用compareTo,而null.compareTo肯定空指针。就算你自定义了一个能容忍 null 的Comparator,也不建议这么用,这不是设计本意,业务上很容易埋雷。

4.2 TreeSet 的隐藏能力:范围查询

很多人只知道TreeSet能排序,忽略了它还有一套非常实用的范围查询方法。NavigableSet接口提供了一组可以直接获取“比某个值小一点”“比某个值大一点”“某个区间内所有元素”的方法,这在排行榜、区间筛选、时间线处理里非常有用。

TreeSet<Integer> scores = new TreeSet<>(); scores.addAll(Arrays.asList(88, 95, 60, 72, 100, 45)); System.out.println(scores.first()); // 45 System.out.println(scores.last()); // 100 System.out.println(scores.lower(60)); // 45,严格小于 60 System.out.println(scores.floor(88)); // 88,小于等于 88 System.out.println(scores.ceiling(90)); // 95,大于等于 90 System.out.println(scores.higher(100)); // null,严格大于 100 Set<Integer> pass = scores.subSet(60, true, 100, true); // pass = [60, 72, 88, 95, 100]

其中lower、floor、ceiling、higher这几个方法,本质上是二叉树上的一次查找,效率是 O(log n),比先把整个集合遍历一遍再判断快得多。subSet还可以写成开区间、闭区间,做“及格线 60 到满分 100”这类业务判断时非常直观。

4.3 自定义 Comparator:方便,但也最容易埋雷

自定义排序时,TreeSet的写法很灵活,比如按学生分数排序:

TreeSet<Student> byScore = new TreeSet<>( (a, b) -> Integer.compare(a.score, b.score) );

这样写确实能按分数从低到高遍历,但有个致命问题:如果两个学生分数相同,compareTo返回 0,TreeSet就会认为它们是同一个学生,后一个直接加不进去。业务上如果只是“分数排名并且每人唯一”还能接受,但如果学生数量多、分数段少,你会莫名其妙地丢数据。

我的习惯是:不要在TreeSet的Comparator里只写一个业务排序字段。必须再补一个“唯一业务主键”作为兜底比较,比如学生对象先比分数,分数相同再比学号:

TreeSet<Student> byScore = new TreeSet<>((a, b) -> { int scoreCompare = Integer.compare(b.score, a.score); if (scoreCompare != 0) return scoreCompare; return a.id.compareTo(b.id); });

这样既保证了排序规则,又不会因为分数相同就误判成同一个元素。记住一句话:TreeSet里的“相等”和equals里的“相等”是两套规则,Comparator 返回 0 时,元素就没机会进入集合了。

4.4 什么时候用 TreeSet 更顺手

如果业务上需要“一边去重一边排序”,比如后台要维护一个自动排序的在线用户列表,用户下线就删除,在线就加入,并且希望列表永远按在线时长排序,这时TreeSet就很合适。还有一个典型场景是“区间命中”,例如一批预约时间段已经排好序,让你判断某个时间点在不在某个区间内,借助ceiling/floor能快速定位。

但要提醒一句:如果不需要去重,只是想把一个List排序,那直接用Collections.sort或者Stream.sorted就行了,没必要为了排序而引入TreeSet。如果数据里允许重复,TreeSet更是天生不适合,因为它天然会把重复元素吃掉。

5. Set 和 List 怎么选:一张选型清单比背概念实用

5.1 先问自己三个问题

选List还是Set,不需要背规则,只要顺着业务问自己三个问题:

第一,允不允许重复?如果商品列表里同一个商品可以出现多次,那一定是List;如果业务语义上就不允许重复,比如用户 ID、订单号、券码,那一定是Set。

第二,要不要下标访问?要不要频繁通过get(i)拿某个位置的元素?如果列表像数组一样使用,选List,最好选ArrayList。Set没有get(index)方法,想按位置拿数据必须转成数组或者List,这是结构决定的,不是 API 没做。

第三,除了唯一性,还要不要顺序语义?只要唯一、顺序无所谓,上HashSet;既要唯一又要保持插入顺序,上LinkedHashSet;既要唯一又要自动排序,上TreeSet。如果既要重复又要排序,那Set就出局了,直接用List加排序。

5.2 别小看 contains 的性能差距

有一个非常典型的性能优化场景:系统里有 10 万个历史订单,每个请求都要判断“这个订单号是不是处理过”。如果你用一个ArrayList去存历史订单号,那每次contains都是从头到尾一次线性扫描,最坏情况要比较 10 万次,百万并发下这显然是个灾难。正确做法是初始化时就用HashSet:

Set<String> processedOrderIds = new HashSet<>(historyOrderIdList); if (processedOrderIds.contains(orderId)) { // 已处理 }

这里有个很容易被忽视的好处:把一个List构造进HashSet的时候,重复元素会被自动过滤掉,而且过滤过程只需要 O(n)。所以高频contains判断、大规模去重、黑白名单检查,都应该首选Set而不是List。

5.3 面试别再说“List 有序,Set 无序”

这句话错在了粒度上。List确实是有序的,但它保证的是“插入顺序”和“下标访问”,不是“排序顺序”。而Set这边,HashSet是无序的,LinkedHashSet是有序的,TreeSet也是有序的,所以不能一概而论。

更准确的说法是:List的顺序是“序列顺序”,元素之间有前驱后继关系,可以通过下标精确定位;Set的顺序是“实现相关”,HashSet不承诺、LinkedHashSet按插入顺序、TreeSet按比较规则排序。面试时把这句话说清楚,比背一长条特例要有说服力得多。

6. 高频面试题与实战踩坑记录

6.1 这几道题几乎是必考

先看一道最经典的:HashSet为什么查询那么快?回答思路应该是,它底层是HashMap,元素作为 key 存储,先通过hashCode定位桶,再用equals在桶内比对,平均时间复杂度 O(1)。如果对方追问“哈希冲突怎么办”,可以补一句:JDK 8 以后,当单桶链表长度超过 8、且哈希表容量大于等于 64 时,链表会转成红黑树,把最坏情况从 O(n) 降到 O(log n),但实际业务数据很难触发这个状态。

第二道常考:List去重并且保持顺序怎么做?正确姿势是用LinkedHashSet:

List<String> list = Arrays.asList("a", "b", "a", "c"); List<String> unique = new ArrayList<>(new LinkedHashSet<>(list));

第三道:TreeSet放自定义对象要注意什么?要回答两个点:要么对象实现Comparable,要么给TreeSet传Comparator;同时Comparator返回 0 会被判定为重复元素,业务字段相同时一定要补唯一字段兜底。

第四道:HashSet线程安全吗?不安全。多线程环境下可以用ConcurrentHashMap.newKeySet()得到一个线程安全的并发 Set,或者用Collections.synchronizedSet(new HashSet<>())包一层,但并发迭代时仍要注意外部同步。如果需要并发下的有序 Set,可以考虑ConcurrentSkipListSet。

第五道:HashSet为什么允许一个 null,TreeSet为什么不允许?因为HashMap允许 key 为 null,null 会固定放在第一个桶,所以HashSet最多放一个 null;TreeSet默认按自然排序比较元素,拿 null 去 compareTo 会直接空指针。

6.2 我真实踩过的坑,整理成一张排查表

现象可能原因解决思路
放进去的对象,contains 突然返回 false对象进入集合后,参与 hashCode 的字段被改了把字段改成不可变;或 remove 后修改再重新 add
HashSet 构造后存不了预期数量,频繁扩容把期望容量当成初始容量用了初始容量按 expectedSize / 0.75 + 1 设置
TreeSet 丢数据Comparator 只比较了业务排序字段,多个对象比较结果为 0在 Comparator 里追加唯一字段比较
往 TreeSet add null 抛空指针自然排序无法比较 null加判空逻辑;不要让 null 进入 TreeSet
遍历 Set 的顺序和预期不一致用了 HashSet,却希望它保持插入顺序换 LinkedHashSet;需要排序用 TreeSet

这张表我每次给项目做集合选型时都会在心里过一遍,尤其是“可变对象放进 HashSet”和“Comparator 导致丢数据”这两条,都是属于线上才会暴露、日志还特别难定位的问题。

7. 我常用的 Set 工具方法,可以直接抄进项目

7.1 去重保序,一个方法搞定

因为项目里经常要处理“外部传进来的 ID 列表可能带重复,但处理顺序不能乱”,我封装了一个很小的静态方法:

public static <T> List<T> distinctPreserveOrder(Collection<T> source) { if (source == null || source.isEmpty()) { return Collections.emptyList(); } return new ArrayList<>(new LinkedHashSet<>(source)); }

这个方法背后就是LinkedHashSet,既做了去重,又保留了第一次出现的顺序。在接口入参清洗、批次任务 ID 过滤中非常实用。

7.2 集合运算别把原数据改坏

Set提供了retainAll、addAll、removeAll这些批量操作,但它们会直接修改调用者。如果你后面还要用原始集合,最好先复制一份:

Set<String> base = new HashSet<>(Arrays.asList("a", "b", "c")); Set<String> other = new HashSet<>(Arrays.asList("b", "c", "d")); Set<String> union = new HashSet<>(base); union.addAll(other); // [a, b, c, d] Set<String> intersection = new HashSet<>(base); intersection.retainAll(other); // [b, c] Set<String> difference = new HashSet<>(base); difference.removeAll(other); // [a]

这里最容易犯的错误是直接base.retainAll(other),结果把 base 改掉了,后面想用它做差集时只能干瞪眼。先 new 一份再操作,成本不高,但能避免很多逻辑混乱。

7.3 并发场景和不可变场景的补充选择

如果多线程需要维护一个唯一的在线用户集合,我不会用普通HashSet加手动锁,而是直接用ConcurrentHashMap.newKeySet(),它返回的是一个线程安全的Set实现,底层复用ConcurrentHashMap的分段锁机制,并发读写的表现比Collections.synchronizedSet更稳定。如果还需要并发下的有序去重,可以用ConcurrentSkipListSet,它底层是跳表,功能接近TreeSet,但支持并发。

另外 JDK 9 以后引入了不可变集合Set.of(...),适合写死的小型常量集合,但它有两个限制:不能为 null,元素重复会抛IllegalArgumentException。使用的时候心里有数就行,别拿它去做大数据量动态去重。

最后分享一个我自己多年养成的习惯:看到集合相关的代码,我不先看业务逻辑,而是先判断这个数据结构承担的是什么职责。只去重,HashSet;去重且保序,LinkedHashSet;去重且排序,TreeSet;要下标、允许多值,List。把这个判断内化成肌肉记忆之后,写出来的代码基本不会在集合选型上翻车,面试被问到这一块的时候,也自然能从一个例子讲到另一个例子,而不是干巴巴背概念。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询