我平时在代码里处理“有序去重”这件事时,最省事的一个工具就是TreeSet。很多同事一看到它就直接当成“有顺序的 HashSet”来用,结果要么插入时报ClassCastException,要么发现自定义对象明明重写了equals却还是丢数据。这些坑追根溯源,全在它对“相等”的定义上,而这套定义又是直接从底层TreeMap继承过来的。所以我一直觉得,与其反复背用法,不如直接打开 JDK 源码,把TreeSet这层壳剥开看一眼,以后再碰到排序、区间查询、最值获取之类的场景,脑子里自然就有路线了。
TreeSet的作用一句话就能说清:它在内部维护一棵红黑树,所有元素按照自然顺序或你指定的比较器排列,读写操作的平均和最优复杂度都是O(log n)。相比HashSet,它能直接拿到有序遍历结果;相比手动Collections.sort,它把排序成本摊到了每次插入里,适合“边插入边有序”的场景。这篇文章适合两类读者:一类是刚接触集合源码、想弄明白TreeSet背后机制的新手,另一类是已经在项目里用过它、但遇到排序失败或去重异常后想找根因的开发者。我会从类结构、核心方法、排序规则、区间操作到踩坑实录,一层层拆开讲。
1. TreeSet 真实面纱:它其实是 TreeMap 的一层壳
1.1 类声明与底层结构
打开 JDK 源码,TreeSet的声明长这样:
public class TreeSet<E> extends AbstractSet<E> implements NavigableSet<E>, Cloneable, java.io.Serializable它继承了AbstractSet,实现了NavigableSet。重点在这个NavigableSet,它不是普通Set能比的,里面定义了lower、floor、ceiling、higher、subSet、headSet、tailSet这些有序集合专属的区间方法。所以TreeSet能做的不仅是“去重 + 排序”,还能做精准的“邻居查询”和“范围切片”,这是其他 Set 实现给不了的。
往下看成员变量,发现玄机:
private transient NavigableMap<E,Object> m; private static final Object PRESENT = new Object();TreeSet本身没有存储结构,它把所有元素作为 key 存进了一个NavigableMap。NavigableMap是接口,实际构造时传进来的实现几乎永远是TreeMap。value 固定是一个名叫PRESENT的共享占位对象,和HashSet里保存PRESENT的思路一模一样。换句话说,TreeSet就是披着 Set 外衣的TreeMap,所有元素管理、排序、比较、平衡都交给红黑树完成。
为什么要这样设计?答案很简单:复用。TreeMap已经实现了有序键值对的完整逻辑,包括红黑树的插入、删除、查找、旋转、平衡,还有子区间视图、降序视图等导航功能。TreeSet如果把这些逻辑再实现一遍,不仅代码量爆炸,维护成本也翻倍。JDK 里这种组合模式很常见,典型的就是HashSet内部组合HashMap,TreeSet内部组合TreeMap。理解了这层关系,你再看TreeSet的任何方法,都会觉得像在看TreeMap的门面。
1.2 构造函数全解:那六个构造器到底做了什么
TreeSet的构造器公开的加上包私有的,一共有六个,初看很多,拆开其实就三类:
public TreeSet() { this(new TreeMap<E, Object>()); } public TreeSet(Comparator<? super E> comparator) { this(new TreeMap<>(comparator)); } public TreeSet(Collection<? extends E> c) { this(); addAll(c); } public TreeSet(SortedSet<E> s) { this(s.comparator()); addAll(s); } TreeSet(NavigableMap<E,Object> m) { this.m = m; }第一类是无参构造器,直接 new 一个TreeMap,元素使用自然顺序排序。第二类是传入Comparator的构造器,同样交给底层TreeMap,明确指定比较规则。第三类是接收已有Collection或SortedSet的构造器,先建空树,再批量addAll。
真正有意思的是那个包私有的构造器TreeSet(NavigableMap<E,Object> m)。它不对外公开,而是给TreeMap的视图方法用的。你调用subSet、headSet、tailSet、descendingSet时,返回的TreeSet正是通过这个构造器创建的。它直接复用传进来的子图,而不是复制一份数据。这样返回的集合实际上是对原集合某个区间的“动态视图”,原集合增删元素,子集视图也会跟着变化。这是很多人没用对subSet的根源所在。
2. 常用方法源码级拆解:增删改查背后发生了什么
2.1 add 和 remove:一次插入的完整路径
先说add,源码就两行:
public boolean add(E e) { return m.put(e, PRESENT) == null; }它调用底层TreeMap.put(key, value),key 是你要插入的元素,value 是固定的PRESENT。TreeMap.put返回的是该 key 之前关联的旧 value:如果 key 原本不存在,返回 null,TreeSet.add返回 true,插入成功;如果 key 已经存在,返回旧 value(也就是 PRESENT),TreeSet.add返回 false,插入失败并把新 value 覆盖上去。
这里引出一个特别关键的点:TreeMap判断 key 是否存在,不是靠equals,而是靠比较器或Comparable的compareTo结果是否为 0。所以TreeSet去重规则是“比较结果相等即视为同一个元素”,哪怕两个对象的equals返回 false。反过来,如果两个对象equals返回 true 但compareTo永远不返回 0,TreeSet会认为它们是两个元素。这个细节不搞清楚,自定义对象丢数据是迟早的事。
TreeMap.put走的是标准的红黑树插入流程:先按照比较器或自然顺序从根节点向下查找合适位置,找到 key 相等的节点就直接替换 value;找不到就在叶子节点挂上新节点,然后返回来做颜色调整和旋转,保证整棵树依然满足红黑树的五个约束条件。整个过程涉及 O(log n) 次比较,如果比较器内部逻辑复杂,比如多层字段判断,性能开销会明显增加。
remove方法同样简洁:
public boolean remove(Object o) { return m.remove(o) == PRESENT; }逻辑和add对称:TreeMap.remove返回被删除节点关联的旧 value,如果 o 存在则返回 PRESENT,不存在返回 null。这里注意remove的参数类型是Object,不是泛型E,因为TreeMap的删除操作同样基于比较结果定位节点,不需要强制转换类型。
2.2 contains 与大小判断:二叉搜索还是遍历?
contains的内部实现是通过TreeMap.containsKey完成的:
public boolean contains(Object o) { return m.containsKey(o); }TreeMap.containsKey的定位路径和插入查找完全一致:从根节点开始,如果比较器不为 null 就调用比较器,否则将 key 强转为Comparable,一路比较下去。这是典型的二叉搜索路径,复杂度 O(log n)。和HashSet的 O(1) 相比虽然常数更大,但换来的是有序能力。如果你频繁用contains且数据量达到百万级,TreeSet的劣势会放大,这时候要权衡是否值得牺牲顺序性。
size和isEmpty就更直接了:
public int size() { return m.size(); }红黑树的节点数量由TreeMap内部维护,size()直接返回维护好的计数变量,不需要遍历树。所以别担心size()有性能问题,它和HashSet一样是常数时间。
2.3 迭代器的升序保证与遍历性能
TreeSet最实用的特性之一,就是迭代器输出的元素一定是有序的。这个顺序来自红黑树的中序遍历。代码层面,TreeSet的iterator()是这样的:
public Iterator<E> iterator() { return m.keySet().iterator(); }底层实际上是TreeMap的KeyIterator,它维护一个栈,按照“左根右”的顺序遍历。所以当你执行:
TreeSet<Integer> set = new TreeSet<>(Arrays.asList(9, 3, 5, 1, 7)); set.forEach(System.out::println);输出一定是1 3 5 7 9。这个特性在需要生成排序报表、有序下拉列表、排行榜等场景里非常实用,省去了每次取出来再Collections.sort的额外步骤。
遍历的时间复杂度是 O(n),和树高无关,因为中序遍历每个节点恰好访问一次。唯一要注意的是,迭代过程中不要通过add或remove修改集合,否则会触发快速失败机制,抛出ConcurrentModificationException。
3. 排序规则的两种实现:Comparable 与 Comparator
3.1 自然排序:对象自己决定顺序
如果创建TreeSet时不传入比较器,底层TreeMap会使用元素的自然顺序。自然顺序要求元素实现java.lang.Comparable接口,例如Integer、String、Long、BigDecimal这些常见类型都已经实现了。
源码里TreeMap.put在查找插入位置时,会执行这样的逻辑:
Comparator<? super K> cpr = comparator; if (cpr != null) { cmp = cpr.compare(key, t.key); } else { Comparable<? super K> k = (Comparable<? super K>) key; cmp = k.compareTo(t.key); }注意这里有个强转:如果没有比较器,TreeMap会把 key 强转为Comparable。如果你往TreeSet里塞了一个没实现Comparable的类对象,第一次add就在这个强转处抛ClassCastException。所以“自然排序”不是自动的,它有前提:元素类型必须实现Comparable,且compareTo的返回值语义要和equals保持一致,否则会出现TreeSet判定去重方式和我们预期不符的问题。
自然排序的优点是简洁,缺点也明显:类本身的排序逻辑被固定死了。比如一个User类,业务要求有时按 userId 排,有时按年龄排,有时按姓名拼音排。如果只靠Comparable,你只能写死一种排序规则,换个场景就得再建一个 TreeSet,没法动态替换。这时候就要用自定义比较器。
3.2 自定义比较器:把一个无序类塞进有序集合
Comparator是java.util包下的函数式接口,核心方法就是int compare(T o1, T o2)。TreeSet在创建时通过构造器把Comparator传给底层TreeMap,后续所有比较操作都走它。
一点经验:Comparator写的时候要尽量完整覆盖所有字段,避免出现“全部字段都相等”却返回非 0 的情况,否则TreeSet可能允许两个实际上完全一样的对象共存。更稳妥的做法是,先按主字段排序,主字段相同再按次字段排,把所有字段都用上,保证排序结果稳定且去重符合直觉。
TreeSet构造器接受比较器的另一个好处是匿名类和 lambda 都行,随手写:
TreeSet<String> set = new TreeSet<>((a, b) -> a.length() - b.length());这段代码按字符串长度排序。要注意的是,这种比较器返回 0 时TreeSet把这个字符串视为重复,导致相同长度的字符串只保留一个。这通常不是期望行为,所以自定义比较器时要特别小心“只按单一属性排序”带来的误去重。
3.3 决定去重的不是 equals,是 compareTo
这是整个TreeSet最容易踩坑的核心点。HashSet去重靠hashCode和equals,TreeSet去重靠的是compareTo或compare是否返回 0。
我用一个例子说明。假设有个Student类:
class Student { String name; int score; Student(String name, int score) { this.name = name; this.score = score; } public boolean equals(Object obj) { // 按 name 判断相等 } public int hashCode() { return name.hashCode(); } }如果Student实现了Comparable,compareTo只按score比较,那么两个名字不同但分数相同的Student会被TreeSet视为“重复”,后插入的被丢弃。这会让很多开发者一头雾水,明明equals返回 false,为什么还是插不进去?
原因就藏在TreeMap.put的流程里:当比较结果 cmp == 0 时,TreeMap认为 key 相等,直接替换 value 并返回旧 value,根本不会调用equals。因此TreeSet对“重复”的定义完全依赖比较器结果。解决办法是让compareTo的返回 0 条件和equals保持一致,比如compareTo里把所有参与equals判断的字段都纳入比较范围。反过来,如果你就是想按某个业务字段去重,那也没问题,但要明确这个规则是出自比较器,而不是承袭equals语义。
4. NavigableSet 接口:比 Collection 多一半子集武器库
4.1 查询方法 lower/floor/ceiling/higher
NavigableSet提供了一组用于“找邻居”的查询方法,这四个方法非常容易混淆,我每次用都要在脑子里过一遍定义:
| 方法 | 返回条件 | 无满足元素时 |
|---|---|---|
lower(e) | 严格小于 e 的最大元素 | null |
floor(e) | 小于等于 e 的最大元素 | null |
ceiling(e) | 大于等于 e 的最小元素 | null |
higher(e) | 严格大于 e 的最小元素 | null |
它们在源码里都是对TreeMap方法的直接转发,比如:
public E lower(E e) { return m.lowerKey(e); }这种“找邻居”的能力在场景里非常有用。比如做题库系统,输入当前分数,想找下一个更高分数的人,调higher一次搞定;想找上一名分数在十分位附近的记录,floor和lower是现成的。这些操作如果自己写循环遍历,至少要 O(n),红黑树里一次查询就是 O(log n)。
还有一对偷懒神器pollFirst()和pollLast(),分别取出并删除最小和最大元素。它们内部调TreeMap.pollFirstEntry,直接在树上做删除并返回 key。适合实现优先级队列,或从有序集合中按顺序消费数据。
4.2 子集视图 subSet/headSet/tailSet
这三个方法是SortedSet时代就有的,NavigableSet把它增强成带布尔参数的版本,用来控制区间边界是否包含。
public NavigableSet<E> subSet(E fromElement, boolean fromInclusive, E toElement, boolean toInclusive) { return new TreeSet<>(m.subMap(fromElement, fromInclusive, toElement, toInclusive)); }headSet和tailSet同理,源码分别转到了TreeMap.navigableKeySet或subMap视图。注意返回的是new TreeSet<>(视图 map),上面提过的那个包私有构造器在这里派上了用场。它不是复制数据,而是把视图 map 直接装进一个新的TreeSet外壳。所以原TreeSet和子集视图是“牵一发动全身”的关系:原集合新增元素落到区间内,子集视图遍历马上能看到。这个特性在做分页、按时间范围过滤、按分数段统计时很方便,但也会让一些开发者在修改原集合后意外发现子集内容变了,误以为出了 bug。
边界参数也要多留个心眼。subSet要求fromElement <= toElement,如果传反了会抛IllegalArgumentException。headSet(e, false)表示“严格小于 e”,tailSet(e, true)表示“大于等于 e”。这些细节直接决定区间切片是否符合预期。
4.3 逆序视图 descendingSet
降序版本并不是把元素复制一套然后反过来存储,而是返回一个映射关系反转的视图:
public NavigableSet<E> descendingSet() { return new TreeSet<>(m.descendingMap()); }TreeMap.descendingMap返回的是一个反向视图,对外遍历顺序从大到小,内部各操作通过逆序比较实现。这个视图同样和原集合共享数据,原集合改一个元素,降序视图立即可见。descendingSet的iterator()就是倒序迭代器,很适合“排行榜倒序展示”这类需求。
另外,descendingSet还支持所有 NavigableSet 方法,比如你可以在降序集合上继续调用subSet、headSet,顺序语义也会相应反转。初用时会觉得别扭,搞清方向后反而能写出更简洁的区间查询代码。
5. 实操演示:从创建到遍历的完整路径
5.1 使用默认自然排序完成增删查与遍历
我平时最常用的验证方式就是写一段最小的代码跑一遍,确认脑子里的源码推导和实际行为一致。
import java.util.TreeSet; public class TreeSetDemo { public static void main(String[] args) { TreeSet<Integer> numbers = new TreeSet<>(); numbers.add(10); numbers.add(8); numbers.add(15); numbers.add(8); numbers.add(12); numbers.add(20); System.out.println("集合内容:" + numbers); System.out.println("第一个元素:" + numbers.first()); System.out.println("最后一个元素:" + numbers.last()); System.out.println("大于10的最小元素:" + numbers.higher(10)); System.out.println("小于等于15的最大元素:" + numbers.floor(15)); System.out.println("删除最大元素:" + numbers.pollLast()); System.out.println("删除后集合:" + numbers); } }这段代码的输出可以预料:
集合内容:[8, 10, 12, 15, 20] 第一个元素:8 最后一个元素:20 大于10的最小元素:12 小于等于15的最大元素:15 删除最大元素:20 删除后集合:[8, 10, 12, 15]第二个 8 被去重了,这验证了TreeSet的去重是基于自然顺序的相等判断。first()、last()底层是取树的最左最右叶子,也是 O(log n)。
5.2 用自定义比较器封装一个降序整数集合
如果希望整数从大到小排序,没必要自己写比较器,Comparator.reverseOrder()一行搞定:
TreeSet<Integer> descNumbers = new TreeSet<>(Comparator.reverseOrder()); descNumbers.addAll(Arrays.asList(5, 1, 4, 2, 3)); System.out.println(descNumbers);输出:
[5, 4, 3, 2, 1]注意这里去重规则依然是“比较结果相等即重复”,所以不会因为 reverse 而出现重复元素共存。
5.3 自定义对象按多个字段排序
项目中真正的价值都在自定义对象上。假设一个简单的Customer类,有level和name两个属性:
class Customer { int level; String name; Customer(int level, String name) { this.level = level; this.name = name; } public String toString() { return "Customer{" + "level=" + level + ", name='" + name + '\'' + '}'; } }创建一个按等级升序、等级相同按姓名升序的比较器:
TreeSet<Customer> customers = new TreeSet<>( Comparator.comparingInt(Customer::getLevel) .thenComparing(Customer::getName) ); customers.add(new Customer(2, "张三")); customers.add(new Customer(1, "李四")); customers.add(new Customer(2, "王五")); customers.add(new Customer(1, "赵六")); System.out.println(customers);输出:
[Customer{level=1, name='赵六'}, Customer{level=1, name='李四'}, Customer{level=2, name='张三'}, Customer{level=2, name='王五'}]这个案例说明几个问题:Comparator.comparingInt(...).thenComparing(...)是链式写比较器的主流方式,语义清晰;TreeSet在多个字段组合比较下,既能保证排序稳定,也能让去重结果符合“所有参与比较的字段都相等才算重复”的直觉。
5.4 子集视图的动态特性演示
一个容易踩坑又很实用的例子是区间动态视图。我们构造一个 1 到 10 的集合,取出 3 到 7 的区间,然后往原集合里添加新元素,再看子集变化:
TreeSet<Integer> all = new TreeSet<>(); for (int i = 1; i <= 10; i++) { all.add(i); } NavigableSet<Integer> sub = all.subSet(3, true, 7, true); System.out.println("初始子集:" + sub); all.add(6); all.add(9); all.add(11); all.remove(4); System.out.println("原集合: " + all); System.out.println("修改后子集:" + sub);这段代码跑下来,子集会实时反映原集合中落在 [3, 7] 区间内的元素变化:新增 6 会立即出现在子集中,删除 4 会立即从子集消失,新增 9 和 11 则不会影响子集。这种视图行为如果没看过源码,第一直觉会以为sub是独立快照,进而怀疑遇到了并发问题。理解了包私有构造器复用 NavigableMap 的原理,就对这种现象习以为常了。
6. 常见问题与踩坑实录
6.1 null 元素和 ClassCastException
TreeSet默认不能装 null。原因不是 Set 实现刻意拒绝,而是底层TreeMap在比较时会强转Comparable或调用Comparator.compare,null 参与比较大概率抛NullPointerException。我见过有人往TreeSet里 add null 后试图 catchNullPointerException,但add方法本身并不做 null 检查,异常类型也不是IllegalArgumentException,这会让上层代码很难理解。与其依赖异常,不如在业务层自己控制,保证入参非空。
如果确实需要支持 null,可以通过自定义比较器给 null 分配一个特殊位置,比如让 null 永远排最前面:
TreeSet<String> set = new TreeSet<>((a, b) -> { if (a == null && b == null) return 0; if (a == null) return -1; if (b == null) return 1; return a.compareTo(b); });这种方法能用,但不推荐随意引入,因为它会让代码阅读成本变高,也让排序规则变得不那么直观。
6.2 修改元素导致顺序错乱
TreeSet的元素一旦放入,就不应该再被修改到“参与比较的字段”。红黑树的顺序是在插入时确定的,节点在树中的位置不会因为字段变化自动调整。如果你修改了一个元素,比如自定义比较器按score比较,而某个元素的score变了,树结构依然保持旧位置,遍历结果就不再有序,甚至可能导致contains、remove行为变得不可预测。
这种问题比HashSet的 hash 值变化更隐蔽,因为HashSet里修改会影响hashCode,但那个问题只要保证hashCode参与字段不变就不会出现。TreeSet则是所有排序字段都不宜在入树后变动。实在要改,规范做法是先remove再add,让节点重新进入红黑树。看起来多了一次删除和插入的开销,但换来的是集合状态可靠。
6.3 线程安全与并发修改
TreeSet和大多数集合一样,是非线程安全的。多线程环境下同时写,轻则数据错乱,重则抛出ConcurrentModificationException。JDK 官方推荐的做法是用Collections.synchronizedSortedSet(new TreeSet<>())包一层,但这种包装只解决单个方法调用的原子性,无法保证复合操作的安全性,例如“先 contains 再 add”中间仍可能有其他线程插入。
如果你需要真正的并发安全,又必须保持有序,可以考虑ConcurrentSkipListSet。它是基于跳表实现的有序并发集合,写入和查询复杂度同样是 O(log n),但通过乐观锁和无锁数据结构支持高并发。很多即时排行榜场景用这个替代TreeSet会更合适。源码层面克制一下,ConcurrentSkipListSet是另一个值得研究的数据结构,但那是后话。
6.4 性能对比:什么时候不要用 TreeSet
TreeSet不是万能的,它的 O(log n) 读写和红黑树旋转带来的常数因子,在大部分场景下慢于HashSet。我把三种常见 Set 的特性做了对比:
| 实现 | 读复杂度 | 写复杂度 | 有序性 | 适用场景 |
|---|---|---|---|---|
HashSet | O(1) | O(1) | 否 | 快速去重、成员判断 |
LinkedHashSet | O(1) | O(1) | 保持插入顺序 | 去重且需要按插入序遍历 |
TreeSet | O(log n) | O(log n) | 按比较器排序 | 有序集合、区间查询、最值查找 |
如果你的业务只是“去重”,不关心遍历顺序,优先选HashSet。如果既要去重又要保留插入顺序,选LinkedHashSet。只有当你明确需要按元素内容排序、频繁做范围遍历或找相邻元素时,才值得付出 O(log n) 的代价用TreeSet。
数据量很小(几十到几百条)时,差异肉眼看不出来;一旦到了十万级,TreeSet的contains查询会比HashSet慢好几个数量级,尤其在热路径上,这种性能损耗会被放大。所以“能有序又能去重”不等于“处处用它好”,选型还是要回到数据结构的本质。
我个人在实际项目里用TreeSet有一条经验:凡是最终要生成“有序列表”且数据会动态增删的场景,优先考虑它;凡是只在内存里临时去重、完了就转成 List 的场景,直接用HashSet。做区间统计时还会先估算一下子集视图会不会长时间持有,防止引用问题导致内存无法释放。源码看多了会发现,集合类的选型本质是读、写、有序、并发这四个维度的权衡,TreeSet在其中占了“有序”这一格,而那一格恰好是很多业务离不开的能力。