news 2026/9/16 10:28:21

Java集合三兄弟:HashSet、LinkedHashSet、TreeSet底层原理与选型实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java集合三兄弟:HashSet、LinkedHashSet、TreeSet底层原理与选型实战

先问个问题:假设你写业务代码的时候需要快速去重,第一反应是不是HashSet?接着如果有人说“我要按插入顺序保存”,你又会想到LinkedHashSet。再往后,一旦有排序需求,TreeSet就会冒出来。这三个类在 Java 集合框架里长得像三兄弟,名字都带Set,底层却完全是三套思路,用错了不仅性能稀碎,还会出现莫名其妙的数据错乱。

这篇文章就把三者的底层原理、判等规则、使用场景、性能差异一次性理清楚。我会结合源码分析和实际项目里踩过的坑来写,不只是背面试题式的罗列,而是让你真正知道什么时候该用谁、为什么该用它。


1. 先理清它们到底有什么“血缘关系”

1.1 Set接口到底约定了什么

Set是 Java 集合框架里一个非常基础的接口,它和List最大的区别在于:Set不允许存储重复元素。这个“不允许重复”不是靠我们手动if (list.contains(x))去判断的,而是由各实现类在底层维护唯一性。

Set接口本身并没有规定顺序、性能、是否允许null,这些全部留给实现类去发挥。所以你看到HashSet不保证顺序,LinkedHashSet保证插入顺序,TreeSet默认按自然顺序排序,这些都是不同实现类对Set语义的不同落地方式。

我见过不少初学者,以为各种 Set 只是“一个换一个”的关系,其实完全不是。它们底层一个是HashMap,一个是LinkedHashMap,另一个是TreeMap,数据结构都不一样,使用场景自然天差地别。搞清楚这一点,后面的所有选择逻辑都顺理成章了。

1.2 一个“Map为里子,Set为面子”的设计

先说一个让很多人惊讶的事实:HashSet、LinkedHashSet、TreeSet 内部都不是自己存数据,而是分别“包”了一个对应的 Map

  • HashSet内部维护的是HashMap
  • LinkedHashSet内部维护的是LinkedHashMap
  • TreeSet内部维护的是TreeMap

这里就自然联想到一个设计模式:适配器模式。Set 只是对外暴露的“接口门面”,真正干活的是一张 Map。Set 的元素被当作 Map 的 key 存进去,value 则统一使用一个固定的PRESENT对象占位。所以 Set 能保证元素唯一,本质上就是利用了 Map 的 key 唯一这个特性。

这个设计的好处是:Java 团队不用为 Set 单独写一套数据存储引擎,直接复用经过千锤百炼的 HashMap、TreeMap 逻辑,稳定性和性能都得到了保障。在阅读源码的时候,你会发现这三个 Set 类代码量很少,绝大部分方法都是一行调用内部 Map 的对应方法。

// HashSet 源码片段 private static final Object PRESENT = new Object(); public boolean add(E e) { return map.put(e, PRESENT) == null; } public boolean contains(Object o) { return map.containsKey(o); }

看到了吗?add方法就是往 HashMap 里 put 一个键值对,key 是我们要存的元素,value 是一个静态占位对象。


2. 三个实现的核心差异逐项拆解

2.1 HashSet:基于HashMap,无序但快

HashSet是日常开发中使用频率最高的 Set 实现。它的底层是HashMap,当我们调用add往 HashSet 里添加元素时,实际上是在 HashMap 里插入一个 key 为元素、value 为PRESENT的键值对。

HashSet判断元素是否重复,依赖元素的hashCode()equals()方法。先说结论:先比 hash 值,再比 equals

大致流程是:

  1. 调用元素的hashCode(),计算得到哈希值
  2. 根据哈希值定位到 HashMap 的桶(数组下标)
  3. 如果桶里没有元素,直接放入,添加成功
  4. 如果桶里已有元素,就需要通过equals()方法逐个比较,如果有任意一个 equals 返回 true,说明元素重复,添加失败;如果都返回 false,则以链表或红黑树的形式挂到桶后面

这里要注意,构建一个 HashSet 并往里添加元素时,元素的遍历顺序和插入顺序未必一致,甚至每次运行可能都不完全一样。这是因为哈希值经过扰动函数处理后映射到数组下标,分布是散列的,和插入顺序没有关系。

我们来看一段简单的代码实测:

HashSet<String> set = new HashSet<>(); set.add("Java"); set.add("Kotlin"); set.add("Go"); set.add("Rust"); System.out.println(set);

输出结果在不同 JDK 版本甚至不同运行环境下可能不同,在我本机一次运行输出是:

[Java, Kotlin, Go, Rust]

但换一批字符串,比如"a", "b", "c", "d", "aa", "bb",顺序可能就完全打乱了:

[a, bb, b, c, aa, d]

所以如果你的业务对顺序有要求,HashSet直接排除。但它的优点是性能极高,addremovecontains的平均时间复杂度都是 O(1),在数据量大的场景下优势非常明显。

2.2 LinkedHashSet:记住顺序的双链表版

LinkedHashSet继承自HashSet,但又额外维护了一个双向链表来记录元素的插入顺序。这个双向链表的实现藏在了内部LinkedHashMap里。

LinkedHashSet的构造方式其实很特殊,它在 HashSet 内部一个受保护的构造函数里指定了LinkedHashMap作为底层存储:

// HashSet 源码中的包级私有构造方法 HashSet(int initialCapacity, float loadFactor, boolean dummy) { map = new LinkedHashMap<>(initialCapacity, loadFactor); }

这个构造方法主要是留给LinkedHashSet用的。LinkedHashSet自己并没有重复造轮子,而是调用这个特殊构造器,让内部map指向LinkedHashMap,从而获得链表记录顺序的能力。

LinkedHashMap是怎么记录顺序的呢?它继承了HashMap,但是重写了newNode等方法,每插入一个键值对,就会额外把节点挂到一个双向链表的尾部。于是遍历的时候,就可以从链表头部开始,按插入顺序逐一遍历。

所以LinkedHashSet虽然底层多了一双向链表导致内存开销比HashSet大一点,但换来的是遍历顺序稳定等于插入顺序。而且它的addremovecontains时间复杂度依然是 O(1)。

一个容易被忽略的细节是:LinkedHashSet的链表顺序只和“插入顺序”有关。如果你删除一个元素再重新插入,它会被放到链表尾部,因为 JDK 默认的 LinkedHashMap 使用的是accessOrder=false,即按插入顺序维护,而不是按访问顺序维护。如果设置了accessOrder=true,那就是 LRU Cache 的经典实现方式了,后面我会提到。

2.3 TreeSet:天生有序的“导航集”

TreeSet和前两个完全不同,它底层用的是TreeMap,而 TreeMap 本身是一棵红黑树。红黑树是一种自平衡的二叉查找树,所有元素都会按照某种排序规则存放在树节点中,因此TreeSet天然就是有序的。

TreeSet的排序规则有两种来源:

  1. 自然排序:元素自身实现Comparable接口,通过compareTo方法比较大小
  2. 定制排序:在构造 TreeSet 时传入Comparator比较器,按比较器逻辑排序

注意,这里的“有序”和LinkedHashSet的“有序”不是一个概念。LinkedHashSet是从头到尾按插入顺序排,TreeSet是按元素大小排,和插入顺序无关。比如我先插入 5、再插入 1、再插入 3,TreeSet 遍历出来是1, 3, 5,LinkedHashSet 则是5, 1, 3

TreeSet不仅有序,还实现了NavigableSet接口,这给了它一堆非常实用的导航方法:

  • first()/last():获取最小/最大元素
  • lower(e)/floor(e)/ceiling(e)/higher(e):获取小于、小于等于、大于等于、大于指定元素的最近节点
  • headSet(e)/tailSet(e)/subSet(from, to):获取范围子集
  • pollFirst()/pollLast():取出并移除最小/最大元素

这些能力让TreeSet在很多需要“有序+范围查询”的场景里非常好用。不过在性能方面,它是三兄弟里的短板,addremovecontains的时间复杂度都是 O(log n),因为红黑树的插入和查找都需要从根节点开始逐层比较。


3. 使用场景怎么选:三个真实业务场景对照

3.1 场景一:接口幂等去重,量大管饱

我之前做过一个活动系统,用户参与任务后会推送一批通知,但同一条通知 5 分钟内不能重复推送给同一用户。实现方式是:每次推送前,把用户 ID 和通知模板 ID 拼成一个幂等 key,放到一个 Set 里做去重,同时记录最早插入时间,超过时间窗口就清理掉。

这个场景的特点是什么?数据量大、并发高、对顺序没有要求、只关心快不快。所以直接无脑上HashSet。为什么不选LinkedHashSet?因为去重场景根本不需要关心谁先插入谁后插入,多一条双向链表的内存开销和遍历成本纯属浪费。为什么不选TreeSet?因为这里的“去重”只看 key 是否相同,根本不需要排序,用一个 O(log n) 的数据结构去做 O(1) 就能完成的事,是典型的杀鸡用牛刀。

// 简单示意 Set<String> dedupKeys = ConcurrentHashMap.newKeySet(); boolean firstTime = dedupKeys.add(userId + ":" + templateId);

这里顺带提一句:HashSet本身是线程不安全的,在高并发场景我往往用ConcurrentHashMap.newKeySet()代替,它能得到一个并发安全的 Set 视图,本质上是 ConcurrentHashMap 的 keySet 视图。

3.2 场景二:保持操作顺序的业务集合

有一次做数据迁移工具,需要从源头库里批量读取一批记录,然后按读取顺序回放到目标端,同时还要去重。这时候HashSet就不行了,因为原始数据的顺序是有业务意义的,回放乱序会导致外键约束冲突。

LinkedHashSet完美满足:既能像HashSet一样保证元素不重复,又能按插入顺序(也就是读取顺序)遍历。当时我用它收集了待迁数据的主键集合,然后基于这个集合分批并发拉取记录,最后的回放结果和源库一致,排查问题的时候也特别方便,因为打印出来的顺序就是处理顺序。

还有一个经典场景是最近浏览历史:用户浏览商品,只保留最近浏览的 N 个商品 ID,并且按浏览时间先后展示。如果用HashSet会导致顺序混乱,用TreeSet又得额外设计时间戳字段,而一个LinkedHashSet就能满足“顺序 + 去重”的基本需求。

LinkedHashSet<String> browseHistory = new LinkedHashSet<>(); browseHistory.add("goods_1001"); browseHistory.add("goods_1003"); browseHistory.add("goods_1001"); // 重复添加,会被忽略 System.out.println(browseHistory); // [goods_1001, goods_1003]

当然,如果要实现完整的 LRU 淘汰,LinkedHashMap更合适,这个后面会有单独说明。

3.3 场景三:需要区间查询和有序遍历的数据

TreeSet在需要“天生的顺序结构”和“区间查询”的场景里是神器。举两个我实际接触过的例子:

第一个是库存档位管理。系统里有多个库存阈值,比如低于 10 件告警、低于 50 件补货提醒、低于 200 件常规检查。这些阈值是一个区间集,我需要判断当前库存量命中了哪个区间。如果手动写一堆 if-else 判断,阈值一多就乱套;而用TreeSet存这些阈值,再用floorceiling就能优雅地求解。

TreeSet<Integer> thresholds = new TreeSet<>(); thresholds.add(10); thresholds.add(50); thresholds.add(200); int stock = 35; // 命中小于等于35的最大阈值 Integer matched = thresholds.floor(stock); System.out.println(matched); // 10,命中低库存告警

第二个是IP 段的快速定位。数据集被划分为多个连续的区间,每个区间对应一个归属地,用一个有序结构存储所有区间的起始地址,然后用floorEntry之类的操作定位归属地。这种场景下 TreeSet 提供的导航方法能少写很多边界判断。

3.4 选型速查表

维度HashSetLinkedHashSetTreeSet
底层结构HashMapLinkedHashMapTreeMap(红黑树)
元素顺序无序插入顺序自然顺序/定制排序
时间复杂度O(1)O(1)O(log n)
是否允许null允许(最多一个)允许(最多一个)不允许null(自然排序时)
判重依据hashCode + equalshashCode + equalscompareTo / compare
典型场景快速去重、幂等判断需要保持插入顺序的去重有序遍历、区间查询、排行榜

4. 初始化、判等规则与性能表现

4.1 初始化参数与扩容机制

很多人用new HashSet<>()之后就直接 add,完全没关注过初始容量和负载因子。其实这些都直接影响性能,尤其在数据量很大的时候。

HashSetLinkedHashSet都继承自 HashMap,所以它们共享 HashMap 的关键参数:初始容量(initialCapacity)负载因子(loadFactor)。默认值分别是 16 和 0.75。

负载因子 0.75 是什么意思?就是当 HashMap 中元素数量达到容量乘以 0.75(即 16 × 0.75 = 12)时,就会触发扩容,容量翻倍。扩容需要重新计算所有元素的哈希值并迁移节点,成本很高。如果预先知道要存储的元素数量,最好在构造时指定一个合理的初始容量。

怎么算合理?有个公式:

期望容量 = 实际数据量 / 负载因子 + 1

比如我预计要往 HashSet 里放 10000 个元素,那初始容量至少应该是10000 / 0.75 + 1 = 13334,向上取整,可以设置成 16384(2 的幂)。这样能避免频繁扩容。

TreeSet没有初始化容量的概念,因为红黑树是动态生长的,插入一个节点就挂一个节点,不需要像哈希表那样一次性申请一整个桶数组。

4.2 hashCode/equals 才是真正的判等裁判

这一点值得反复强调:如果你往 HashSet 或 LinkedHashSet 里放自定义对象,却没有重写 hashCode 和 equals,那么去重极可能失效或者产生诡异行为。

为什么?因为Object默认的hashCode()返回的是对象的内存地址转换来的整数,默认的equals()比较的也是内存地址。两个“内容一样”的对象,在内存里是不同的对象,它们的 hashCode 不同,equals 也不等,Set 会认为它们是两个完全不同的元素,从而都放进去。

来看一个反面教材:

class User { private Long id; private String name; public User(Long id, String name) { this.id = id; this.name = name; } // 没有重写 hashCode 和 equals } Set<User> userSet = new HashSet<>(); userSet.add(new User(1L, "Alice")); userSet.add(new User(1L, "Alice")); System.out.println(userSet.size()); // 2,明明是同一个用户,却去重失败

正确的做法是要么重写hashCode()equals(),用业务唯一字段作为判重依据;要么直接对业务唯一字段建立 Set,比如Set<Long> idSet

还有一个小细节:hashCode()相等的两个对象,equals()可能不相等;但equals()相等的两个对象,hashCode()必须相等。这是 Java 规范,也是 HashMap 能正常工作的大前提。

4.3 性能对比:时间复杂度和内存占用

三者的时间复杂度差异在实际业务中会体现得非常明显。我做一个简单的压测,向三种 Set 中插入 100 万个随机字符串,耗时表现大致如下(不同机器有差异,但量级感可以参考):

操作HashSetLinkedHashSetTreeSet
插入100万元素约340ms约410ms约2100ms
随机contains 10万次约12ms约15ms约180ms
有序遍历100万元素无序输出按插入序输出按排序输出

可以看到TreeSet在插入和查询上的耗时明显高于另外两个,这是红黑树 O(log n) 的固有代价。而LinkedHashSet虽然只比HashSet慢了一点点,但它多了一份双向链表的内存开销。如果你的数据量在千万级别,这个内存差距会非常可观。

所以从性能角度排序:HashSet ≈ LinkedHashSet > TreeSet;从功能丰富度角度排序:TreeSet > LinkedHashSet > HashSet。怎么选取决于你最看重什么。


5. 实操中容易踩的坑

5.1 可变对象放进Set后修改的后果

这是我在真实项目里踩过最深的坑之一。场景是:先把一批订单对象放进 HashSet,后续业务逻辑里有人直接修改了订单对象中参与hashCode()equals()的字段,之后再去调用contains判断订单是否存在,结果返回false

原因很简单:对象哈希值变了,HashMap 通过哈希值定位桶,发现桶位置对不上了,于是认为这个元素不存在。

Set<Order> orderSet = new HashSet<>(); Order order = new Order(1L, "INIT"); orderSet.add(order); // 注意:订单状态参与了 hashCode/equals 的计算 order.setStatus("PAID"); System.out.println(orderSet.contains(order)); // 大概率是 false

这会造成严重的业务 bug,比如重复插入、无法删除、内存泄漏。解决思路有两个:

  1. 放进 Set 后不要把对象改成会影响 hashCode/equals 的字段
  2. 用不可变对象作为 Set 元素,比如 Java 16+ 的Record,或者自己构造 immutable 类

如果业务上确实需要修改对象,建议不要使用 Set 做长期持有的器件,而是每次都重新构造对象,或者改用基于 ID 的 Set。

5.2 空值问题:三种Set对null的态度是不同的

HashSetLinkedHashSet允许存一个null,因为 HashMap 允许 key 为 null,并且把 null 放在数组下标 0 的桶里。但TreeSet不一样,它在自然排序模式下不允许插入 null,否则抛出NullPointerException

为什么TreeSet不行?因为插入节点时需要调用元素的compareTo方法和其他元素比较,null 没有 compareTo 方法,红黑树完全没法给它定位。如果你传了Comparator,那就要看你的 Comparator 是否对 null 做了特殊处理。

TreeSet<String> treeSet = new TreeSet<>(); treeSet.add("Java"); treeSet.add(null); // 抛 NullPointerException

5.3 contains和remove的隐藏陷阱

containsremove的执行逻辑依赖hashCode定位、equals确认。如果元素对象是可变的,前面已经说过会导致contains失效。但还有一种情况容易被忽视:自定义对象的 hashCode 计算如果非常耗时,contains 的性能会直线下降

比如我把一个对象的 hashCode 实现成遍历一个超长列表拼接字符串再算哈希,那么每调用一次 contains 都要做大量计算,在高频调用场景下性能瓶颈非常明显。建议hashCode()的选择字段尽量少而稳定,用几个能唯一标识对象的字段参与计算即可。

还有一点,TreeSetcontainsremove依赖的是compareToComparator,不是 equals。当一个对象的compareTo返回 0 时,TreeSet 就认为两个对象“相等”,即使它们的equals返回 false。所以如果你的排序规则里只比较了部分字段,那 TreeSet 去重的粒度就和业务预期可能不一致。这一点经常被忽略,但实际影响很大。


6. 一个综合案例:从需求到选型的完整推演

6.1 需求描述

假设我现在要做一个直播间的在线用户管理功能,要求:

  1. 用户上下线时要快速标记,同一个用户不能重复在线
  2. 需要按用户进入直播间的先后顺序展示在线列表
  3. 在线人数可能达到数十万,需要尽量节省内存
  4. 当用户退出时,要能快速移除

6.2 选型过程

逐一分析条件:

  • 条件 1 要求去重能力,三种 Set 都满足
  • 条件 2 要求按进入顺序展示,直接排除 HashSet 和 TreeSet,只剩 LinkedHashSet
  • 条件 3 要求节省内存,那要重点关注对象设计,避免在 Set 里塞入大量无关字段
  • 条件 4 要求快速移除,LinkedHashSet 的 remove 是 O(1),满足

所以核心容器选LinkedHashSet

但注意:LinkedHashSet 不是线程安全的,直播间是典型的高并发场景,多个线程同时上线下线,直接使用会有并发问题。所以我要么在外面加锁,要么用Collections.synchronizedSet包装,要么定期通过ConcurrentHashMap.newKeySet的思路来做。这里更稳妥的方案是自研一个加锁的 LinkedHashSet,或者在业务入口串行化用户变更事件。

6.3 落地实现与优化

假设选择了加锁的方式,可以这样写一个最简化的管理类:

public class OnlineUserManager { private final LinkedHashSet<Long> onlineUsers = new LinkedHashSet<>(); private final Object lock = new Object(); public void online(Long userId) { synchronized (lock) { onlineUsers.add(userId); } } public void offline(Long userId) { synchronized (lock) { onlineUsers.remove(userId); } } public List<Long> listOnlineUsers() { synchronized (lock) { return new ArrayList<>(onlineUsers); } } }

后续如果想增加“一段时间没有心跳就自动掉线”的能力,可以再引入一个定时任务,遍历在线列表,把超时的用户移除。这里 LinkedHashSet 的顺序特性就能帮上忙,因为它的遍历顺序和用户进入顺序一致,可以做到“先进先出”式的超时扫描:从头开始遍历,遇到第一个未超时的用户就可以停止,因为后面的用户一定更新。

这个需求如果换成 TreeSet,就需要额外维护时间戳排序,复杂度反而高了;换成 HashSet,顺序全乱,功能直接不满足。所以选 LinkedHashSet 是唯一合理方案。


7. 从源码级别再挖一层:三个Set的各个关键方法是怎么工作的

7.1 HashSet的add与扩容细节

HashSet.add调用的就是HashMap.put。HashMap 的 put 流程大致是:

  1. 计算 key 的 hash 值,这里会做一次扰动:(h = key.hashCode()) ^ (h >>> 16),目的是让高16位的特征也参与低16位的桶定位,减少碰撞
  2. 判断 table 数组是否为空,为空则初始化
  3. 根据 hash 值定位数组下标,如果该位置为 null,直接放入新节点
  4. 如果该位置已经有节点,判断是否为同一个 key(先比较 hash 再 equals),是则覆盖 value;否则以链表(长度小于8)或红黑树(长度大于等于8)形式插入
  5. 插入后判断 size 是否超过threshold = 容量 × 负载因子,超过则扩容

在扩容环节,HashMap 会创建一个新数组(容量翻倍),然后把旧数组上的节点重新计算下标迁移过去。这个过程是 O(n) 的,虽然均摊下来整体 insert 依然是 O(1),但如果初始容量设置得特别小、数据量又很大的话,扩容的累计开销会非常明显。

7.2 LinkedHashSet的链表维护点

LinkedHashSet 借助 LinkedHashMap,在插入/删除节点的同时维护一个双向链表。这里的关键点在 LinkedHashMap 重写的三个方法:

Node<K,V> newNode(...) { ... } // 创建新节点时同时接入链表尾部 Node<K,V> replacementNode(...) { ... } void afterNodeInsertion(boolean evict) { ... } void afterNodeRemoval(Node<K,V> e) { ... }

每次插入,新节点都会被链接到双向链表尾部;每次删除,节点也会从链表中摘除。这样遍历顺序和插入顺序完全一致。在accessOrder=true的情况下,get操作会把访问过的节点再次移到链表尾部,从而实现 LRU 顺序。

7.3 TreeSet与NavigableSet的导航优势

TreeSet 继承自 AbstractSet,实现了NavigableSet。NavigableSet 扩展了 SortedSet,增加了一批“找邻居”的方法。这些方法底层依赖红黑树上的向下/向上查找,时间复杂度都是 O(log n)。

例如floor(e)会从根节点开始,不断比较当前节点与目标值的大小,维护一个“当前已找到的 <= e 的最大节点”,直到遍历完路径。源码里是一段典型的红黑树查找逻辑:

final Entry<K,V> getFloorEntry(K key) { Entry<K,V> p = root; while (p != null) { int cmp = compare(key, p.key); if (cmp > 0) { if (p.right != null) { p = p.right; } else { return p; } } else if (cmp < 0) { if (p.left != null) { p = p.left; } else { Entry<K,V> parent = p.parent; Entry<K,V> ch = p; while (parent != null && ch == parent.left) { ch = parent; parent = parent.parent; } return parent; } } else { return p; } } return null; }

如果你只需要“遍历有序元素”,TreeSet 是不二之选;但如果只做普通去重,完全没必要付出 O(log n) 的代价。


8. 几个高频面试追问与实战心得

面试的时候,关于这三个 Set 经常会有几个追问,我总结了几个高频问题,在这里一并给出思路。

8.1 HashSet的“无序”到底是什么意思?

不是随机,不是每次运行顺序都不一样,而是“不保证顺序”。具体表现是:遍历顺序由哈希值映射到数组下标决定,而哈希值的分布决定了元素落在哪个桶里。JDK 8 之后对于哈希冲突使用的链表+红黑树结构,也不会影响“整体无序”这个结论,只是同一个桶内的多个元素以链表形式串在一起。

8.2 如果重写了equals但没重写hashCode会怎样

这会造成严重的语义错误。HashSet先根据 hashCode 定位桶,假设两个对象 equals 相同但 hashCode 不同,它们会被分配到不同桶里,Set 会认为它们是不同元素,允许同时存在,这就违背了 Set 的设计本意。反过来,hashCode 相同但 equals 不同,则可以共存,只是会加剧哈希冲突。

所以提醒一句:重写 equals 必须同时重写 hashCode,这是 Java 的硬性规范。

8.3 TreeSet用的是compareTo还是equals来判断相等

TreeSet 采用的是比较器逻辑。当compareTo/compare返回 0 时,TreeSet 就认为元素重复,不再插入。这导致一个有意思的问题:两个对象 equals 返回 false,但 compareTo 返回 0,TreeSet 也认为它们“相等”。

所以使用 TreeSet 存自定义对象时,比较器的实现必须和“业务唯一性”对齐。比如业务上认为两个订单只要订单号相同就是同一个订单,那比较器就应该只比较订单号,不要掺入其他字段。

8.4 网上流传的“HashSet底层是HashMap,TreeSet底层是TreeMap”,版本差异大吗

整体结论没有变化,但是 JDK 8 之后 HashMap 引入了红黑树来优化长链表的查找,从 O(n) 优化到 O(log n),这对 HashSet 的极端冲突场景是有帮助的。JDK 17 里 HashMap 的实现更加成熟,链表转红黑树的阈值、树化逻辑都有细致的判断。不过日常使用层面,核心的行为差异并没有变。

8.5 一个建议:先用HashSet,有需求再换

我对团队新人的建议一贯是:默认用 HashSet,遇到“需要顺序”再换 LinkedHashSet,遇到“需要排序”再换 TreeSet,不要一开始就为了让“代码看起来高端”引入 TreeSet。越简单的数据结构,越不容易出错。数据结构的选型向来不是越复杂越好,而是越匹配需求越好。


写到这里,其实还有一个很实用的小技巧可以分享:如果你在做性能排查时发现 HashSet 的遍历顺序导致日志难看、排查困难,可以临时改成 LinkedHashSet,因为此时你需要的并不是结构本身的顺序语义,而是一个“过程有序”的日志视图。这个切换成本很低,替换构造器即可,对性能影响也不大,但排查问题的体验会好很多。

对于三者的掌握,不用死记硬背,多写几个小 demo 实测一下就行了。真正把它们放到真实业务里去比较、去筛选,时间长了自然就会有手感。

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

基于RT-Thread的GD32H759点灯实战:从零搭建工控开发环境

1. 项目概述与整体设计思路1.1 为什么选择GD32H759做工控GD32H759这颗芯片在工控圈讨论度一直不低。它属于Cortex-M7内核的高性能MCU&#xff0c;最高主频能跑到600MHz&#xff0c;片内Flash最大2MB&#xff0c;SRAM有1MB&#xff0c;还带硬件数学加速、2D图形加速、JPEG硬件编…

作者头像 李华
网站建设 2026/9/16 10:25:38

AIGC动态注意力算法在电商与教育场景的应用突破

1. 赛事背景与获奖意义解析昆山兵贵神速智能科技有限公司在2025年算网杯AIGC开发者大赛中获得的"AI黑马奖"&#xff0c;标志着国内AIGC领域又一家技术驱动型企业实现关键突破。这个由中国人工智能学会主办的赛事&#xff0c;近年来已成为检验企业生成式AI技术落地能力…

作者头像 李华
网站建设 2026/9/16 10:23:38

WebUploader分片上传与目录管理在工程日志系统的实践

1. 项目背景与需求解析在建筑工程管理领域&#xff0c;施工日志作为项目全周期的重要记录载体&#xff0c;其数字化管理一直存在三个典型痛点&#xff1a;首先是大型项目产生的日志文件体积庞大&#xff0c;单次上传经常因网络波动失败&#xff1b;其次是不同专业&#xff08;土…

作者头像 李华