HashSet、TreeSet、LinkedHashSet:底层都是"套壳"

「Java 进阶之路」系列 Day24

写在前面

这三个 Set 实现类经常被放在一起问选型,但很多人不知道的是------它们其实都不是"从零实现"的,底层都是套用了前面讲过的 Map 实现。这篇把这层"套壳"关系讲清楚,选型问题自然就迎刃而解了。


一、是什么:三者底层分别套的是哪个 Map

flowchart LR A[HashSet] --> B[内部持有一个HashMap] B --> C[add进来的元素作为key] B --> D[value统一是一个<br/>固定的Object占位常量]
  • HashSet :内部就是一个 HashMap,调用 add(e) 本质是执行 map.put(e, PRESENT)PRESENT是一个共享的固定占位对象),Day22 讲过的 HashMap 底层结构、扩容机制,HashSet 全盘照搬。
  • LinkedHashSet :继承自 HashSet,内部套的是 LinkedHashMap------在HashMap基础上多维护了一条双向链表,记录元素的插入顺序(或者访问顺序,取决于构造参数)。
  • TreeSet :内部套的是 TreeMap,底层是红黑树,元素按照自然顺序(实现Comparable)或者传入的Comparator规则自动排序。

一句话理解三者的选型逻辑HashSet 只关心去重、不关心顺序;LinkedHashSet 在去重的基础上还想要"记住插入顺序";TreeSet 在去重的基础上还想要"元素始终有序排列"。


二、为什么需要三种:顺序需求不同,天然对应不同底层结构

底层结构 是否有序 add/contains 时间复杂度
HashSet HashMap(数组+链表/红黑树) 无序(遍历顺序取决于哈希分布,不能依赖) O(1)
LinkedHashSet LinkedHashMap(HashMap+双向链表) 有序,按插入顺序排列 O(1),比HashSet多一点链表维护的开销
TreeSet TreeMap(红黑树) 有序,按排序规则自动排列 O(log n)

为什么TreeSet的操作是O(log n)而不是O(1):红黑树是一种自平衡二叉搜索树,插入、查找都需要沿着树的层级往下比较,操作耗时和树的高度成正比,树的高度是O(log n)级别,天然比数组+链表这种结构慢一些------这是"始终保持有序"必须付出的代价,天下没有免费的午餐。


三、怎么用:两个容易踩的坑

坑一:往 TreeSet 里放自定义对象,忘了告诉它怎么排序

java 复制代码
class Person {
    String name;
    int age;
    Person(String name, int age) { this.name = name; this.age = age; }
}

Set<Person> set = new TreeSet<>();
set.add(new Person("Tom", 20));   // 运行时抛出ClassCastException!

TreeSet 要维护有序性,每次插入都要知道"新元素和已有元素比谁大谁小",如果元素本身没有实现Comparable接口、构造TreeSet时也没传Comparator,它压根不知道该怎么比较,运行时直接抛出ClassCastException。正确做法是让Person实现Comparable<Person>,或者构造时传入一个Comparator<Person>

java 复制代码
Set<Person> set = new TreeSet<>(Comparator.comparingInt(p -> p.age));

坑二:TreeSet 判断"重复",靠的是 compareTo,不是 equals/hashCode

这是最容易被面试问倒的细节------HashSet判断两个元素是否重复,用的是Day20讲过的equals+hashCode那套机制;但TreeSet完全不看这两个方法,它认为"比较结果为0"的两个元素就是重复的

java 复制代码
class Person implements Comparable<Person> {
    String name;
    int age;
    Person(String name, int age) { this.name = name; this.age = age; }

    @Override
    public int compareTo(Person o) {
        return Integer.compare(this.age, o.age);   // 只按年龄比较
    }
    // 没有重写equals/hashCode,用的是Object默认的(比较引用地址)
}

Set<Person> set = new TreeSet<>();
set.add(new Person("Tom", 20));
set.add(new Person("Jerry", 20));   // 年龄一样,compareTo返回0

System.out.println(set.size());   // 1!Jerry被当成"重复元素"直接丢弃了,尽管equals认为他们不是同一个人

TomJerry是两个完全不同的人(equals按引用判断显然不相等),但因为compareTo只比较了age、两人年龄一样,TreeSet认为这是"重复元素",第二次add直接被无声丢弃。这条规则的官方说法是"compareTo应该和equals保持一致" (如果不一致,TreeSet/TreeMap的行为会和equals定义的相等语义产生偏差),实际写compareTo的时候一定要想清楚:这个排序规则是不是也隐含了"判断两个对象是否算作同一个"的语义,如果不是,就要在比较逻辑里加上足够多的字段来避免误判(比如年龄相同再比较姓名,姓名也相同再退化成用某个唯一id兜底)。


四、面试追问

Q1:HashSet、LinkedHashSet、TreeSet 底层分别是基于什么实现的?

HashSet内部持有一个HashMap,元素作为key存进去;LinkedHashSet继承自HashSet,内部换成了LinkedHashMap,多维护一条双向链表记录插入顺序;TreeSet内部持有一个TreeMap,底层是红黑树,按照排序规则自动排列元素。

Q2:为什么说 HashSet 是无序的,实际测试时看到的遍历顺序是随机的吗?

不是真正随机,遍历顺序完全由元素哈希值落在哪个桶、以及桶内链表/树的组织方式决定,同样的元素、同样的插入顺序,多次运行结果通常是一致的。但这个顺序不是"插入顺序",也不保证在不同JDK版本、不同哈希值分布下保持稳定,所以不能依赖这个顺序做任何业务逻辑,需要保证遍历顺序应该用LinkedHashSet

Q3:往 TreeSet 里添加没有实现 Comparable 的自定义对象,会发生什么?

如果构造TreeSet时也没有传入Comparator,运行时调用add会抛出ClassCastException。因为TreeSet需要在插入时确定新元素和已有元素的大小关系来维持有序性,如果元素既没实现Comparable、又没有外部传入的比较规则,它没有任何依据去做这个比较。

Q4:TreeSet 是怎么判断两个元素是否重复的?和 HashSet 一样吗?

不一样。HashSet依赖equalshashCode判断重复;TreeSet依赖compareTo(或者传入的Comparator)的比较结果,只要两个元素比较结果是0,就会被认为是重复元素,即使它们的equals判断并不相等,后添加的那个也会被无声丢弃。这也是为什么写compareTo时要注意让排序逻辑和"相等"的业务语义保持一致,避免出现这种和直觉不符的丢弃行为。

Q5:TreeSet 的操作为什么比 HashSet 慢,具体慢在哪个环节?

TreeSet底层是红黑树,插入、查找、删除都需要沿着树的高度逐层比较,时间复杂度是O(log n);HashSet底层是哈希表,正常情况下直接通过哈希值定位到桶,时间复杂度接近O(1)。TreeSet慢的代价换来的是"元素始终保持有序"这个HashSet不具备的能力,如果不需要排序,HashSet的性能通常更好。


下一篇预告

Day25 讲 Iteratorfail-fast 机制------为什么用普通for循环遍历集合时删除元素会抛异常,Iterator自己删除又为什么没事。

相关推荐
sigterM先生1 小时前
Ollama + Go 搭建 AI 告警分析管道
后端·ai编程
相顾若初见1 小时前
RuoYi后端jar镜像制作
java·jar
AI多Agent协作实战派1 小时前
AI多Agent协作系统实战(四十九):它说唤醒了agent,agent却还在睡觉
后端
用户7813667114451 小时前
对象存储(RGW)架构详解
后端
Python私教1 小时前
软件开发报价为什么能差 3 倍:需求范围、验收标准和变更成本怎么算
后端·python·架构
苏三说技术1 小时前
为什么越来越多人用gRPC?
后端
MacroZheng1 小时前
程序员看文档神器,装上它,看Spring官方文档就一目了然了!
java·人工智能·后端
想风1 小时前
Claude Code 实用技巧工作坊 —— Boris Cherny
前端·后端·github
长栎1 小时前
Java 17 的模式匹配,正在杀死访问者模式
后端