1. 先说结论:TreeSet 就是个“穿了马甲的 TreeMap”
很多同学看集合源码,习惯性地把 TreeSet 当成“又一种链表/哈希组合”,然后一看 TreeMap 的相关解析,就觉得 TreeSet 可以顺带翻翻就过。但实际上,TreeSet 这个名字本身就有误导性——它根本不是自己实现了一套树结构,而是内部直接“包”了一个 TreeMap。我第一次认真点开 TreeSet 的源码时,看到 add 方法里只有一行m.put(e, PRESENT),整个人有点恍惚,接着再看到一个NavigableMap字段,才意识到:JDK 里的 TreeSet 从构造器到底层操作,全部都是在复用 TreeMap 的机制。
public class TreeSet<E> extends AbstractSet<E> implements NavigableSet<E>, Cloneable, java.io.Serializable { private transient NavigableMap<E,Object> m; private static final Object PRESENT = new Object(); ... }只要看到NavigableMap<E, Object> m这一行,TreeSet 的“本质”就藏不住了:元素就是 TreeMap 的 key,value 全部塞同一个占位常量PRESENT。所以,你想彻底搞明白 TreeSet,必须先搞明白 TreeMap;而 TreeSet 本身的源码价值,更多在于“如何优雅地复用别人现成的骨架,把接口缝补成一套完整的集合外观”。
这篇内容不打算重复搬源码,而是按我实际读源码的思路带你过一遍:先看构造器和 TreeMap 是怎么对接的,再看增删查、范围视图、序列化克隆这些玩意在复用机制下如何工作,以及几个最容易出 bug 的场景。读完你会发现,TreeSet 没什么神秘的特异功能,但设计上有不少值得借鉴的巧思。
1.1 一个字段打天下的结构
TreeSet 内部真正的字段就两个:
private transient NavigableMap<E,Object> m; private static final Object PRESENT = new Object();m是一个NavigableMap,本质上指向 TreeMap 实例。TreeMap 实现了 NavigableMap 接口,而 TreeSet 实现的是NavigableSet<E>接口。两个接口之间正好是“地图”和“地图上的点集”的对应关系。PRESENT则是一个共享的占位 value,所有元素作为 key 插入,全部共用这一份 Object 实例,省内存也省比较逻辑。
你可能会问:为什么不直接把 TreeMap 内嵌,而是用一个接口类型?这就体现出源码设计里“面向接口编程”的意图:TreeSet 的很多方法其实是将各种调用转发给m的对应方法,接口声明让整个类的依赖面更窄,只依赖 NavigableMap 的能力,而不是死绑定具体实现类。之前有人讨论过 JDK 为什么要保留 TreeSet 而不是直接叫它 TreeMap 的 Set 视图,历史原因比较复杂,但对读源码的人而言,这个字段设计反而是最值得记的一句口诀:凡是读到 TreeSet 方法体里只有 m.xxx 的,那就是纯转发;凡是看到 m 之外还有额外逻辑的,那才是 TreeSet 自己的戏份。
1.2 构造器全部是在“包装” TreeMap
TreeSet 一共提供了好多个构造器,但剥掉外层壳,看本质:
TreeSet() { this(new TreeMap<E,Object>()); } TreeSet(Comparator<? super E> comparator) { this(new TreeMap<>(comparator)); } TreeSet(Collection<? extends E> c) { this(); addAll(c); } TreeSet(SortedSet<E> s) { this(s.comparator()); addAll(s); } TreeSet(NavigableMap<E,Object> m) { this.m = m; }最后一个包内私有构造器很有意思,它允许外部传入一个已经建好的 NavigableMap,而且不做任何拷贝。这个构造器主要是给headSet、tailSet、subSet这些范围视图使用的:视图不是新树,而是原树的“局部窗口”,底层直接绑定同一个 TreeMap 实例。后面讲视图的时候,这个构造器就是关键入口。
从四个 public 构造器可以看出,TreeSet 的初始化套路是:要么默认自然排序(元素必须实现 Comparable),要么显式传入 Comparator,要么把已有集合整体塞进来。加粗重点:第二个构造器里new TreeMap<>(comparator)这一步大多数同学会忽略,但以后所有元素的放置位置、判等规则,全由这个 Comparator 说了算。
2. add 与 remove:一行转发的背后,红黑树做了什么
2.1 add 只有一行代码,但树已经转了无数次
public boolean add(E e) { return m.put(e, PRESENT) == null; }这段代码,很多人看一眼觉得“就这?”,实际上信息量很大。
TreeMap.put(key, value)返回的是被替换的旧值,如果没有旧值(也就是这个 key 之前不存在),返回 null。TreeSet 的 add 判断“是否新增成功”,用的正是这个返回值。由于所有 value 都是同一个 PRESENT 占位对象,判断== null就够了。这一招在 HashSet 里也一模一样:HashSet 内部是 HashMap,add 也是map.put(e, PRESENT) == null。所以“Set 去重靠 Map 的 key 唯一性”这一设计模式,在 JDK 集合框架里是统一打法。
但 add 真的只有这一行么?不是,后续真正动作全部发生在m.put里:先走compare(或者对象的compareTo),从根节点开始找插入位置;如果找到相同 key,更新 value;否则一路走到底,挂上新节点。新节点挂上去后,如果红色节点违反了红黑树约束,就触发fixAfterInsertion,做左旋、右旋、变色,一路回溯到根节点,保证树仍然满足五条红黑树规则。
所以 TreeSet 的 add 复杂度是 O(log n),背后的旋转、重着色都是 TreeMap 的活,TreeSet 自己不用知道细节。读源码时,把“转发”和“底层真正干活的逻辑”分开看,就不会被干扰。
2.2 remove、contains、size 的底层语义
public boolean remove(Object o) { return m.remove(o) == PRESENT; } public boolean contains(Object o) { return m.containsKey(o); } public int size() { return m.size(); }注意 remove 的判断不是!= null,而是== PRESENT。因为 TreeMap.remove(key) 返回被移除的旧 value,如果这个 key 存在,旧 value 一定是 PRESENT;如果 key 不存在,返回 null。这里用== PRESENT严格区分“删除成功”和“没有这个 key”。如果将来有哪个同学复制代码改成!= null,在当前这种“value 恒为 PRESENT”的结构里倒也能跑,但语义上不够精确,而且会让人误以为 TreeMap 返回值真的能代表一切——不,它代表的是 value,只有在我们这套固定占位 value 的设计下,它的语义才恰好等于“key 是否存在”。
还有一个细节:contains(Object o)的参数是 Object,不是泛型 E。这意味着你可以传任意类型进来,TreeMap 在比较时会先尝试把 Object 强转成 Comparable(自然排序场景)或者交给 Comparator 调 compare。这点跟 HashMap 很像,不过区别在于:HashMap 的containsKey依赖 hashCode 和 equals,TreeMap 的containsKey依赖比较器的 compare 结果(或者自然排序的 compareTo)。
2.3 批量操作里藏着复杂度差异
TreeSet 继承自 AbstractSet,所以它的 addAll 逻辑默认是循环 add。对 TreeSet 来说,从 Collection 初始化时逐个 add,每次 O(log n),总体 O(m log n)。但如果传入的是一个 SortedSet,有一种更高效的做法:直接把有序序列“插入时连续追加到树的右下链”,甚至在某些情况下直接重建树。JDK 里 TreeMap 有一个buildFromSorted方法,可以从有序数据在 O(n) 时间内建树,但 TreeSet 的构造器并没有调用它——它选择了最简单的 addAll。
为什么源码作者不做“如果是 SortedSet 就 O(n) 建树”的优化?我个人的猜测是:addAll 链路经过AbstractCollection.addAll,它会先判断集合大小再考虑扩容(对树不存在扩容),而且需要保证 Comparator 一致、集合非空时不破坏原有结构,这些边界条件让“看起来很美”的优化变得很复杂。JDK 不是没有这种先例(比如 TreeMap 构造函数中有 SortedMap 参数时会走buildFromSorted),但 TreeSet 这里保守了。所以你自己写代码时,如果把一个大集合塞进 TreeSet,千万要有心理准备——它就是 O(m log n),别指望有魔法。
3. 排序规则的来源:自然排序与定制 Comparator 的取舍
3.1 单靠 Comparable 能走多远
不进构造器参数的自然排序(natural ordering),依靠的是元素自身实现Comparable接口。比如Integer、String都实现了 Comparable,所以new TreeSet<Integer>()直接 add 就能正常工作。此时 TreeSet 内部 TreeMap 的 comparator 字段为 null,底层用((Comparable)k).compareTo(t)走比较逻辑。
但是自然排序有两个隐含约束:第一,元素类型必须实现 Comparable,否则 add 时抛 ClassCastException;第二,不同子类的对象混在一起时,compareTo的类加载类型不兼容同样会抛 ClassCastException。埋伏点在于,这些异常不是 add 之前就能检测出来的,而是走到 TreeMap 比较逻辑的那一刻才暴露。你构造 TreeSet 时什么错都不会报,add 第二个、第三个元素时才开始炸,排查时要多留个心眼。
3.2 定制 Comparator 的“一票否决权”
多数业务场景里,元素要么不是 Comparable,要么想要的排序规则跟自然排序不一致。这时就需要显式传 Comparator:
TreeSet<Person> set = new TreeSet<>( (p1, p2) -> p1.age - p2.age );注意:一旦给了 Comparator,TreeMap 里就完全不再调用元素的 compareTo,所有比较都走 comparator。哪怕是同一个类、同一个对象的两个实例,只要 comparator 返回 0,就会被判定为“相等”。
可以这么理解:Comparator 在 TreeSet 里既是排序权,也是去重权。HashSet 去重靠 hashCode + equals;TreeSet 去重靠 comparator 的比较结果,而且这一规则是“一票否决”的——哪怕你的 equals 方法写的再精细,只要 comparator 认为两个对象相等,TreeSet 就只留一个。
3.3 最经典的翻车现场:compareTo 与 equals 不一致
这是我在实际代码 review 时反复提醒的场景。假设定义了一个类:
class Student implements Comparable<Student> { String name; int score; public int compareTo(Student other) { // 只比 name,不管 score return this.name.compareTo(other.name); } public boolean equals(Object obj) { if (this == obj) return true; if (obj instanceof Student) { Student s = (Student) obj; return name.equals(s.name) && score == s.score; } return false; } }比较器认为“同 name 就是同一个学生”,equals 却认为“同名同分才相等”。这样会出现很诡异的现象:用 TreeSet 存进去两个同名不同分的学生,实际上只有一个能进去;但如果你把其中一个从集合里 remove,用的 key 是“同名同分”,而 TreeSet 的 remove 走的是 compareTo,不是 equals,所以可能删不掉你直觉中的那条。这类 bug 极难从日志里看出来,因为 contains 和 remove 的结果完全由 compareTo 支配,equals 成了摆设。
经验法则:凡是作为 TreeSet 元素的自定义类,必须保证 comparator/compareTo 的“相等”语义与 equals 的语义一致,否则你迟早踩坑。这跟 HashMap 的 hashCode 与 equals 一致原则是同一思想:不同结构对“重复”的定义可能不同,但同一种结构内部必须自洽。
3.4 可变对象导致的“脏位置”问题
前面说过 TreeSet 的底层是红黑树,树形结构在插入后是靠比较保持平衡的。如果你把对象放进去之后,又修改了它参与比较的字段,问题就严重了——红黑树不会感知到这个节点“变脏”。
看这个例子:
TreeSet<StringBuilder> set = new TreeSet<>(); StringBuilder a = new StringBuilder("a"); StringBuilder b = new StringBuilder("b"); set.add(a); set.add(b); a.append("z"); // 现在 a 的内容是 "az" // set 内部结构仍然是 a 在左,b 在右 // 但按 compareTo 重新比较,content "az" > "b",位置已经错了如果字符串比较用在 StringBuilder 上并不会正常工作(StringBuilder 没有实现 Comparable),这里只是示意。实际业务里,典型例子是对象有 id 和 name,你按 id 排序,放进去后修改 name 没关系;但如果按 name 排序,放进去后修改 name,那树里这个节点的位置就是过期的。后续 contains、remove 可能会找不到它,遍历顺序也乱了。
这个坑的根源是:红黑树节点一旦定位,除非显式删除再插入,否则位置不会自动重新旋转调整。要解决只能在业务层约束:参与排序的字段,要么设置为不可变,要么在修改后先 remove 再 add。我的做法通常是在 setter 里加注释提醒,或者在代码设计上干脆把这些字段设计成 final / 不可变,从源头掐断。
4. 接口的完备度:为什么说 TreeSet 不只是“会排序的 Set”
4.1 NavigableSet 的“精确着陆”方法
TreeSet 实现的是NavigableSet<E>,这个接口定义了好几个不太起眼,但实际查询非常高效的方法:
// 返回严格小于 e 的最大元素(没有则 null) E lower(E e); // 返回小于等于 e 的最大元素 E floor(E e); // 返回大于等于 e 的最小元素 E ceiling(E e); // 返回严格大于 e 的最小元素 E higher(E e);这四个方法背后的实现都是 TreeMap 的二分查找逻辑,从根节点开始,一层层比较、维护候选值,最终找到满足条件的节点。复杂度同样是 O(log n)。在需要做“最近邻查询”时,这是碾压遍历 List 的查询方式。
举个实际场景:维护一批定时任务的执行时间,当前时间戳 t,要找到“下一个应该执行的任务”,用set.ceiling(t)就能直接拿到,不用遍历。如果还要看上一个任务是谁,用lower(t)。这些方法在 TreeSet 上就是天然适配的。
TreeSet<Integer> times = new TreeSet<>(); times.addAll(Arrays.asList(10, 25, 47, 63, 89)); Integer next = times.ceiling(50); // 返回 63 Integer prev = times.floor(50); // 返回 474.2 first / last 与 pollFirst / pollLast
作为 SortedSet,TreeSet 还提供快速拿到最小、最大元素的first()/last(),时间复杂度 O(log n)。pollFirst()/pollLast()则是取走并删除,返回被移除的元素,集合为空时返回 null。
public E pollFirst() { Map.Entry<E,?> e = m.pollFirstEntry(); return (e == null) ? null : e.getKey(); }这个场景适合做“优先级队列”的轻量替代:如果队列不大,而且需要有序消费(从小到大或从大到小),TreeSet 完全可以胜任。要注意的是,pollFirst 和 pollLast 返回 null 也意味着“集合为空”,如果你要 poll 的元素本身理论上可能为 null,那 TreeSet 根本不允许 null(自然排序下)或由 comparator 决定是否允许,所以没有二义性。
4.3 descendingSet:倒序视图竟然也是 O(1)
TreeSet 有一个很讨巧的方法:
public NavigableSet<E> descendingSet() { return new TreeSet<>(m.descendingMap()); }它返回的同样是一个 TreeSet,但底层绑定的是同一个 TreeMap 的 descendingMap,不是拷贝。因此原集合的改动会立刻反映到倒序视图上,反之亦然。这种视图模式的成本几乎为零——没有复制任何元素,只是换了遍历方向。如果你需要倒序遍历,又不希望新建一个大集合,这个方法就能派上用场。
5. 子集合视图中不得不防的边界与联动
5.1 subSet / headSet / tailSet 是“窗口”而非“拷贝”
public NavigableSet<E> subSet(E fromElement, boolean fromInclusive, E toElement, boolean toInclusive) { return new TreeSet<>(m.subMap(fromElement, fromInclusive, toElement, toInclusive)); } public NavigableSet<E> headSet(E toElement, boolean inclusive) { return new TreeSet<>(m.headMap(toElement, inclusive)); } public NavigableSet<E> tailSet(E fromElement, boolean inclusive) { return new TreeSet<>(m.tailMap(fromElement, inclusive)); }每个方法都是 new 一个 TreeSet,但传入的 NavigableMap 是原 TreeMap 的 SubMap 视图。这里走的就是前面说的包内私有构造器,底层共享同一棵树。所以对 subSet 做 add、remove,等于直接操作原集合;原集合的元素变化,subSet 也立刻看得到。
JDK 的 TreeMap.SubMap 还实现了基于区间的范围检查:插入新元素时,如果 key 越界(不在子区间内),会抛 IllegalArgumentException;原集合里移出一个元素时,所有关联的子视图也会同步看到删除。这种“窗口同步”是用视图类统一管理的,不是通过事件通知,而是通过在入参阶段就对 start/end 边界做校验,以及底层节点删除时所有视图共用同一棵树的自然联动。
5.2 半开区间的默认设置
JDK 的subSet(from, to)无参重载版本,默认是[from, to),左闭右开:
public NavigableSet<E> subSet(E fromElement, E toElement) { return subSet(fromElement, true, toElement, false); }我第一次用的时候没注意这一点,遍历 subSet 一直没看到 toElement,后来才反应过来是右开。如果你要实现的是闭区间,千万记得用四参版本并传true/true。类似的,headSet(to)是(负无穷, to)左开右开,tailSet(from)是[from, 正无穷)左闭右开。单参版本与四参版本的默认边界不同,使用时心里要有数。
5.3 视图里的“劳动”不等于“私有地盘”
由于视图共享底层树,对视图做批量操作时要注意 performance 和并发语义。比如subSet.clear(),这个操作会清空原集合里所有落在区间内的元素,而不是只清空视图的“本地数据”。写业务代码时,别以为视图是隔离出来的沙盒——它更像一个遥控器,所有指令都会作用到原树上。
TreeMap 的 SubMap 实现里,基于NavigableSubMap.EntryIterator遍历时会先走到第一个合法节点,然后沿着中序遍历一个个走,遇到越界节点就停止。这个迭代器还会维护一个expectedModCount,如果原集合在迭代过程中被修改(结构性修改),会抛 ConcurrentModificationException。视图的迭代器也一样,因此不要一边遍历一边往里塞元素(除非用迭代器自己的 remove)。
6. 迭代顺序、克隆与序列化的“隐藏作业”
6.1 中序遍历带来的稳定顺序
TreeSet 的iterator()走的是 TreeMap 的 keySet 迭代器,本质是红黑树的中序遍历:先左子树、再根、再右子树。因为树的结构是按比较器从小到大排列的,所以中序遍历结果正好是从小到大的顺序。
for (Integer i : set) { // 输出一定是升序 }这个顺序在并发修改时最需要小心:如果是别人线程改了树,你的迭代器会立刻失效抛出 ConcurrentModificationException,而不是偷偷给你一个“近似”的结果。相比一些并发集合采用的弱一致性迭代器,TreeSet 的 fail-fast 迭代器策略更严格,也更容易在第一时间暴露并发问题。
6.2 clone 是浅拷贝还是深拷贝
TreeSet 重写了 clone:
public Object clone() { TreeSet<E> clone; try { clone = (TreeSet<E>) super.clone(); } catch (CloneNotSupportedException e) { throw new InternalError(e); } clone.m = new TreeMap<>(m); return clone; }先调用super.clone()做浅拷贝,然后把m替换成一棵新的 TreeMap。注意:这棵树里拷贝的是 key 引用本身,不是 key 对象的深拷贝。所以 clone 得到的 TreeSet 和原集合在一棵树层面互相独立,但元素对象是同一批引用。如果你改动某个元素(前提是它可变且会影响业务逻辑),两个集合都会看到变化;但如果你在原集合里 add/remove 元素,克隆集合不会受影响。
这个“半深不浅”的复制策略是集合类的通用套路:结构上隔离,元素上共享。JDK 里大多数 Set/Map 的 clone 都遵循这个约定。写业务代码时如果连元素对象都要复制,那得自己再做一层深拷贝,集合的 clone 帮不了你。
6.3 序列化:TreeSet 其实只序列化“骨架”
TreeSet 实现了 Serializable,内部字段m是transient,所以序列化不是直接写 TreeMap 对象,而是自定义 writeObject:
private void writeObject(ObjectOutputStream s) throws IOException { s.defaultWriteObject(); s.writeInt(m.size()); for (E e : m.keySet()) s.writeObject(e); } private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException { s.defaultReadObject(); int size = s.readInt(); // 根据比较器与默认负载因子重建 TreeMap NavigableMap<E,Object> tm = new TreeMap<>(comparator()); for (int i = 0; i < size; i++) { E e = (E) s.readObject(); tm.put(e, PRESENT); } m = tm; }为什么这么做?关键是树结构本身是“平面数据”的衍生品——只要有了比较器和全部元素,任何一棵等价的红黑树都能重建。序列化时保存元素本身和重建策略,比保存每个节点的左右指针更稳健,也避免把内部结构细节(比如颜色、旋转历史)暴露给外部。反序列化后重新建树的过程就是逐个 add,所以序列化反序列化之后的树结构与原始树结构可能完全不同(节点染的颜色、树的形状都可能变),但只要比较器和元素不变,对外语义完全一致。
有一个容易忽略的坑:如果元素不是 Serializable,writeObject 抛 NotSerializableException;如果 comparator 也不可序列化,同样会炸。所以 TreeSet 的序列化可用范围,其实是“元素 + 比较器都可序列化”的子集。
6.4 顺序语义与维护成本:跟数据规模一块儿看
TreeSet 的优势不是 O(1),而是 O(log n) 的稳定性能和有序性。如果数据量大,比如几十万条,每次 add/remove 都要走 log n 次比较和可能的旋转,比 HashSet 的 O(1) 摊还成本要高不少。因此在选型时,需要想清楚“我到底要不要有序”?很多情况下,一个 HashMap + 事后一次 Collections.sort 可能比一直维护 TreeSet 更简单也更高效。我用过一句话来形容二者:“HashSet 是快,但不会按顺序给你讲;TreeSet 是慢一点,但任何时候问它都能按顺序讲给你。”
7. 与 HashSet、LinkedHashSet 的选型对比
三种 Set 的差异,可以浓缩成一张表:
| 维度 | TreeSet | HashSet | LinkedHashSet |
|---|---|---|---|
| 底层结构 | TreeMap(红黑树) | HashMap(数组+链表/红黑树) | LinkedHashMap |
| 顺序 | 按比较器顺序 | 无固定顺序 | 按插入顺序 |
| 去重依据 | comparator / compareTo | hashCode + equals | hashCode + equals |
| add/remove/contains | O(log n) | 平均 O(1) | 平均 O(1) |
| 范围查询 | 支持(subSet/ceiling等) | 不支持 | 不支持 |
| null 元素 | 自然排序不允许,定制 comparator 时看情况 | 允许一个 null | 允许一个 null |
| 额外内存 | 节点含左/右/父/颜色,开销较大 | 节点含 hash 和链表指针 | 节点含前后指针,开销更大 |
选型时我的习惯是这样:
- 只要需要“稳定排序”或者“范围查询/最近邻查询”,TreeSet 直接上,别犹豫。
- 只关心去重、不关心顺序,用 HashSet。
- 既要去重,又要保持插入顺序,例如维护“用户最近浏览的顺序去重列表”,用 LinkedHashSet。
- 如果数据量很大而且会频繁增删,TreeSet 的 log n 成本会积累起来,要提前做压测。
还有一个容易被忽略的点:TreeSet 的排序是“全局性”的,一旦插入,元素在整个集合中的位置只能通过删除重建来改变;LinkedHashSet 则保留了插入时的相对关系。如果业务里经常要调整顺序,用 TreeSet 往往意味着你要把旧对象 remove 再 add 新的对象(甚至要新建一个带新属性值的对象),代码上会显得笨拙。
8. 读源码后我实际总结的几条实操建议
源码读到最后,比逐行背更重要的,是提炼出几个“以后自己写集合代码也用得上”的经验。
第一,能用视图就尽量用视图,不要急着复制数据。views 的设计理念是整个 TreeSet/TreeMap 体系里最精彩的部分:不复制数据,只通过边界约束共享底层结构。业务里如果你要做“按时间范围看数据”,直接 tailSet(startTime) 就行,不要去遍历几次再筛一遍。
第二,存储对象时,参与排序的字段设计成不可变,这能省掉后面一堆怪问题的排查时间。如果实在要变,那就按“先删再插”的顺序操作:先 remove 旧对象,修改字段,再 add 新对象。我之前在项目里见过一个把 name 改了但没重新插入的案例,导致 contains 返回 false、但是 remove 又把另一个 name 相同的对象给删了,最后查了半天才定位到是“树里的节点位置过期了”。
第三,测试时要专门覆盖“比较器认为相等但 equals 不同”以及“equals 相同但比较器不同”两种情况。这两种情况里,TreeSet 的实际行为都可能反直觉。写单元测试的时候可以直接断言:放入两个“比较器等值”的对象后 size 是 1;再试下放入两个 equals 相同但比较器并不同视角的对象,看看 size 和 contains 的结果,保证自己对这个集合类的行为有精确把握,而不是想当然。
第四,不要盲目用 TreeSet 当“有序去重”的万能壳。如果数据十万级以上,而且只有初始化时需要排序,完全可以用 HashSet 收集,最后一次性new TreeSet<>(set)。初始化之后,如果需要频繁地按范围查,再考虑转成 TreeSet。时间成本差异在数据规模上来之后体会特别明显。
第五,如果你在用 TreeSet 存自定义对象,建议写一个比较器的静态工厂方法,把规则收敛成一个Comparator.comparing(...)链,既便于测试,也便于将来修改。直接让类实现 Comparable 会在后期变得很难维护,因为一个类只能有一个自然排序规则,而实际业务往往可能会有多种排序需求。
个人体会:TreeSet 的源码量其实不大,它的核心价值不在于“又实现了一种树”,而在于把一个功能完备的 TreeMap,包装成适合 Set 接口的形态,同时还把有序性、范围查询、双向遍历这些附加能力也一并透出。以后如果遇到跟“有序集合”相关的业务需求,你第一个想到的不应该只是“TreeSet 可以排序”,而是“它的排序是树结构赋予的,我能基于这个结构做到范围查询、最近邻、倒序遍历这些额外操作”。这才是读了源码后该有的收益。