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

「Java 进阶之路」系列 Day24

写在前面

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


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

#mermaid-svg-b603KgEydH7hrxLG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-b603KgEydH7hrxLG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-b603KgEydH7hrxLG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-b603KgEydH7hrxLG .error-icon{fill:#552222;}#mermaid-svg-b603KgEydH7hrxLG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-b603KgEydH7hrxLG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-b603KgEydH7hrxLG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-b603KgEydH7hrxLG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-b603KgEydH7hrxLG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-b603KgEydH7hrxLG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-b603KgEydH7hrxLG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-b603KgEydH7hrxLG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-b603KgEydH7hrxLG .marker.cross{stroke:#333333;}#mermaid-svg-b603KgEydH7hrxLG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-b603KgEydH7hrxLG p{margin:0;}#mermaid-svg-b603KgEydH7hrxLG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-b603KgEydH7hrxLG .cluster-label text{fill:#333;}#mermaid-svg-b603KgEydH7hrxLG .cluster-label span{color:#333;}#mermaid-svg-b603KgEydH7hrxLG .cluster-label span p{background-color:transparent;}#mermaid-svg-b603KgEydH7hrxLG .label text,#mermaid-svg-b603KgEydH7hrxLG span{fill:#333;color:#333;}#mermaid-svg-b603KgEydH7hrxLG .node rect,#mermaid-svg-b603KgEydH7hrxLG .node circle,#mermaid-svg-b603KgEydH7hrxLG .node ellipse,#mermaid-svg-b603KgEydH7hrxLG .node polygon,#mermaid-svg-b603KgEydH7hrxLG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-b603KgEydH7hrxLG .rough-node .label text,#mermaid-svg-b603KgEydH7hrxLG .node .label text,#mermaid-svg-b603KgEydH7hrxLG .image-shape .label,#mermaid-svg-b603KgEydH7hrxLG .icon-shape .label{text-anchor:middle;}#mermaid-svg-b603KgEydH7hrxLG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-b603KgEydH7hrxLG .rough-node .label,#mermaid-svg-b603KgEydH7hrxLG .node .label,#mermaid-svg-b603KgEydH7hrxLG .image-shape .label,#mermaid-svg-b603KgEydH7hrxLG .icon-shape .label{text-align:center;}#mermaid-svg-b603KgEydH7hrxLG .node.clickable{cursor:pointer;}#mermaid-svg-b603KgEydH7hrxLG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-b603KgEydH7hrxLG .arrowheadPath{fill:#333333;}#mermaid-svg-b603KgEydH7hrxLG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-b603KgEydH7hrxLG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-b603KgEydH7hrxLG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-b603KgEydH7hrxLG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-b603KgEydH7hrxLG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-b603KgEydH7hrxLG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-b603KgEydH7hrxLG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-b603KgEydH7hrxLG .cluster text{fill:#333;}#mermaid-svg-b603KgEydH7hrxLG .cluster span{color:#333;}#mermaid-svg-b603KgEydH7hrxLG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-b603KgEydH7hrxLG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-b603KgEydH7hrxLG rect.text{fill:none;stroke-width:0;}#mermaid-svg-b603KgEydH7hrxLG .icon-shape,#mermaid-svg-b603KgEydH7hrxLG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-b603KgEydH7hrxLG .icon-shape p,#mermaid-svg-b603KgEydH7hrxLG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-b603KgEydH7hrxLG .icon-shape .label rect,#mermaid-svg-b603KgEydH7hrxLG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-b603KgEydH7hrxLG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-b603KgEydH7hrxLG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-b603KgEydH7hrxLG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} HashSet
内部持有一个HashMap
add进来的元素作为key
value统一是一个固定的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自己删除又为什么没事。

相关推荐
xiaoqiMikko1 小时前
Spring 官方给 5.3 线开了三个修复版,Maven Central 上一个都没有
java·spring boot
南城以南溫暖如初1472 小时前
分享返现商城系统开发实战:技术架构与运营经验指南
java·spring boot·mysql·架构·vue·mybatis
vipxieliang2 小时前
Java切面编程(AOP)详解:从核心概念到实战应用
java·spring boot
.Hypocritical.2 小时前
SpringBoot 各版本 Profile 多环境配置完整对比(2.x/ 2.4+ / 3.x/4.x
java·spring boot·后端
麻花20132 小时前
Win10、win11清理C盘的bat文件内容
开发语言
露天赏雪2 小时前
掘金小册样章
java·开发语言·人工智能·ai编程
zlwool2 小时前
图纸管理选型:从变更频率到齐套率
java·前端·python·erp·设备erp·非标机械
一晌小贪欢3 小时前
python-第15天:Python 列表推导式
开发语言·python·excel·数据可视化·python办公
Java后端的Ai之路3 小时前
17、Python - 观察者模式
开发语言·人工智能·python·观察者模式·外观模式