1. 什么时候会想用TreeSet:有序性需求对比三兄弟
先聊一个实际场景。某次做排行榜功能,从接口拿回来一批用户得分记录,需要按分数从高到低输出,而且分数相同的时候按用户ID排序。常规做法很自然:放进ArrayList,然后Collections.sort()传一个比较器。但问题来了——这个榜单的数据源是一个不断有新增记录的实时数据流,每来一条新数据就插入榜单,老榜单还得一直保持有序。如果每次都重新sort(),数据量小还好,数据量一旦上了十万甚至百万级别,sort()的损耗就很扎眼。
这时候我换成了TreeSet。每来一条数据直接add()进去,集合内部自动完成插入排序,取数的时候直接遍历就是有序结果,不需要反复全量排序。这个体验差异是实实在在的:ArrayList + sort()是"先扔进篮子、要用了再整筐重新码齐",而TreeSet是"每放一个都刚好落在该在的位置"。
在Java集合家族里,Set接口的常见实现主要有三个:HashSet、LinkedHashSet、TreeSet。三兄弟的定位差异非常明显:
| 实现类 | 底层结构 | 是否有序 | 迭代顺序 | 时间复杂度(增删查) |
|---|---|---|---|---|
| HashSet | 哈希表 | 无序 | 不保证 | O(1) |
| LinkedHashSet | 哈希表 + 双向链表 | 有序 | 插入顺序 | O(1) |
| TreeSet | 红黑树(TreeMap) | 有序 | 自然/比较器顺序 | O(log n) |
LinkedHashSet虽然有序,但它的"有序"指的是插入顺序,不是业务上的排序。比如插入顺序是(5, 3, 8, 1),遍历出来还是(5, 3, 8, 1)。TreeSet则不同,它会按照你定义的排序规则——自然顺序或者自定义比较器——重排所有元素,哪怕中途插入了更大的数,遍历结果永远是排序后的状态。这一点是TreeSet的核心价值,也是它和另外两兄弟最本质的分水岭。
这篇文章适合谁看?如果你对TreeSet的理解只停留在"一个会自动排序的Set"这个层面,那你可以通过本文把它的实现原理、排序机制、定位方法、边界情况全部弄清楚。如果你已经用过TreeSet但踩过坑,比如排序失效、null异常、视图转换问题,这文章也能帮你把坑底看清楚。全程用实际代码说话,不整虚的。
2. 排序规则从哪来:Comparable与Comparator的完整使用姿势
2.1 自然排序:元素自己说话
TreeSet要求放入的所有元素必须"可以比较"。判断能不能比较的方式很简单:要么元素实现了Comparable接口,要么在创建TreeSet时传入一个Comparator比较器。如果两者都没有,程序会在第一次添加元素时抛出ClassCastException,抱怨"xxx cannot be cast to java.lang.Comparable"。
自然排序就是元素类自己实现Comparable接口,在compareTo方法里定义"我比另一个元素大还是小"。这是最常见也最省事的做法。
public class Player implements Comparable<Player> { private String name; private int score; public Player(String name, int score) { this.name = name; this.score = score; } public String getName() { return name; } public int getScore() { return score; } @Override public int compareTo(Player other) { // 分数高的排在前面:当前分数比对方小,返回正整数表示"我更大" int scoreCompare = Integer.compare(other.score, this.score); if (scoreCompare != 0) { return scoreCompare; } // 分数相同则按名字字典序升序 return this.name.compareTo(other.name); } @Override public String toString() { return name + "=" + score; } }注意一个细节:Integer.compare(other.score, this.score)这个写法,是把比较逻辑倒过来,从而实现"分数降序"。直接用this.score - other.score虽然也能算出正负值,但存在整数溢出的隐患,所以用Integer.compare更稳妥。
把Player放进TreeSet里试试:
TreeSet<Player> treeSet = new TreeSet<>(); treeSet.add(new Player("Alice", 88)); treeSet.add(new Player("Bob", 95)); treeSet.add(new Player("Charlie", 88)); System.out.println(treeSet); // 输出:[Bob=95, Alice=88, Charlie=88]看输出结果,Bob=95排第一位,Alice和Charlie分数相同,按名字的字典序排序,Alice在前Charlie在后。这个顺序不是插入顺序,而是完全由compareTo定义的业务顺序。这就是TreeSet的"自动排序"含义——它在插入时就用红黑树把每个元素放到了正确的位置。
2.2 自定义比较器:把规则从元素里抽出来
有时候你不想动元素的源码,或者同一个类在不同场景需要不同的排序规则,这时候就该用Comparator。比较器可以把排序规则独立出来,随用随传。
创建带比较器的TreeSet有两种姿势,一种是用构造器传比较器,另一种是用Comparator.comparing链式构造。先看传统写法:
TreeSet<String> lengthSet = new TreeSet<>((a, b) -> Integer.compare(a.length(), b.length()));这个例子很有意思:往一个按字符串长度排序的TreeSet里添加"banana"(6个字符)和"apple"(5个字符)时,banana会在apple前面,因为长度更长。但你如果继续添加"cherry"(6个字符),会发生什么?banana和cherry长度一样,所以compareTo返回0,TreeSet会认为"这两个元素相等",于是cherry直接被丢弃,add返回false。
这就是TreeSet一个极其容易被忽略的机制:去重逻辑完全由比较器决定,而不是equals方法。如果你只按长度比较,那所有长度相同的字符串都被视为同一个元素。后面讲等值判断不一致问题时会细说这一点,这里先记住这个结论。
链式写法更现代也更清晰:
TreeSet<Player> playerSet = new TreeSet<>( Comparator.comparingInt(Player::getScore) .reversed() .thenComparing(Player::getName) );comparingInt按分数升序,reversed()反转成降序,thenComparing在分数相同时再按名字升序。这套链式API和直接在实体类里写compareTo效果一致,但好处是不侵入类本身的代码,想换排序规则换个比较器即可。
再提供一个更贴近真实业务的例子:一个订单集合,需要按下单时间升序排列,时间相同按金额降序。
TreeSet<Order> orderSet = new TreeSet<>( Comparator.comparing(Order::getCreateTime) .thenComparing(Order::getAmount).reversed() );要注意reversed()的位置作用域——如果直接跟在thenComparing(Order::getAmount)后面,它只会反转金额这一个比较逻辑;如果写在整条链的末尾,则会影响整个比较器。上面的写法是把整个链反转,导致创建时间也会变成降序,这跟预期的"时间升序+金额降序"不一致。正确的写法有两种:要么在getCreateTime那一步就先排好升序,然后在最后一个步骤单独反转金额;要么用Comparator.comparing(Order::getCreateTime).thenComparing(Order::getAmount, Comparator.reverseOrder())。这种链式API的细节特别容易出错,写的时候一定要留个心眼,最好给比较器单独起个变量名,加上注释说明语义。
2.3 compareTo与equals的不一致问题
TreeSet判断两个元素是否"相同"的标准是compareTo(或比较器)返回0,而不是equals()返回true。这个设计与其他Set实现完全不同,也是很多线上问题出现的根源。
举一个典型的反例。定义一个类,equals逻辑是"两个对象如果id相同就相等",而compareTo逻辑是"按创建时间排序":
TreeSet<Record> recordSet = new TreeSet<>((r1, r2) -> r1.getCreateTime().compareTo(r2.getCreateTime())); recordSet.add(new Record(1, "2024-01-01 10:00:00")); recordSet.add(new Record(2, "2024-01-01 10:00:00")); System.out.println(recordSet.size()); // 输出 1两条记录id不同、内容不同,唯独创建时间相同。equals()判断它们不相等,但TreeSet按compareTo认为它们是同一个元素,所以第二条直接被吞掉了。如果业务上"id相同才算同一条记录",这就会造成数据丢失。解决办法很简单:写比较器的时候,把id作为次要排序条件补进去——先按时间排,时间相同再按id排,保证compareTo返回0的情况与equals结果一致。这也是Java官方文档明确建议的原则:compareTo应与equals保持一致,否则集合行为会变得诡异。
3. TreeSet的源码内幕:TreeMap外壳与红黑树的自平衡
3.1 外壳:TreeSet其实就是TreeMap的阉割版
看源码会发现一个反直觉的事实:TreeSet内部根本没有自己维护数据结构的代码,它所有操作都委托给了一个TreeMap实例。
// TreeSet源码中的关键字段 private static final Object PRESENT = new Object(); private transient NavigableMap<E,Object> m; public TreeSet() { this(new TreeMap<E,Object>()); }add方法的实现更直接:
public boolean add(E e) { return m.put(e, PRESENT) == null; }也就是说,TreeSet是把元素当作TreeMap的key存进去,value统一用一个固定的空对象PRESENT。TreeMap的key天然去重、天然有序,所以TreeSet什么都不用多做,只需要把TreeMap的对外接口裁剪成Set语义就行。contains、remove、size这些方法,底层全是对TreeMap的对应操作。
这也是为什么TreeSet的性能特征和TreeMap完全一致:增删改查都是O(log n)。理解这一点,很多"为什么TreeSet不能存null"之类的疑问就能直接从源码里找到答案——不是TreeSet的问题,是TreeMap的排序压根绕不开null的比较逻辑。
3.2 内衬:红黑树如何保证对数级性能
TreeSet底层的TreeMap用红黑树组织所有key。红黑树是平衡二叉搜索树的一种实现,核心目标是防止二叉搜索树退化成链表。
二叉搜索树的原理是:左子树的所有节点小于根节点,右子树的所有节点大于根节点。按这个规则插入有序数据时,比如依次插入1、2、3、4、5,树会变成一条只有右孩子的链,查找复杂度从O(log n)恶化到O(n)。红黑树通过在节点上增加颜色约束,强制树保持平衡,让高度始终控制在O(log n),从而保证查找、插入、删除都维持对数级性能。
红黑树有五条核心约束:
- 每个节点非红即黑。
- 根节点必须是黑色。
- 所有叶子节点(空节点)视为黑色。
- 红色节点的两个子节点必须是黑色——不能出现两个红色节点相连。
- 从任一节点到其每个叶子节点的路径上,黑色节点数量必须相同。
红黑树并不追求绝对平衡,而是通过"最长路径不超过最短路径的两倍"这个近似平衡,换取插入删除时的旋转次数可控。每次插入后如果某个约束被破坏,树会通过变色和旋转来修复。TreeSet每次add背后的完整链路是:先按二叉搜索树规则找到插入位置,然后染色为红色,再沿着路径向上检查并修复可能被破坏的红黑性质,期间可能涉及左旋、右旋等操作。
这些源码细节普通使用层面用不到,但它能帮助你理解TreeSet的性能边界。红黑树的自平衡让所有操作都稳定在O(log n),不会因为插入顺序不同而退化。这是TreeSet区分于"排序后手动插入"这类方案的最大底气。
3.3 O(log n)到底快多少:一个可感知的对比
O(log n)和O(1)的差距在大数据量下并不夸张,拉不开数量级的距离,但和O(n)之间的差距就非常明显了。假设集合里有100万元素:
TreeSet.contains():最多比较约20次,也就是树的高度。20次字符串或整数的compareTo,耗时微秒级。HashSet.contains():哈希定位一次,理论上更快,但需要考虑哈希计算和可能存在的哈希冲突。ArrayList.contains():最坏需要遍历全部100万个元素,平均50万次equals调用,耗时是TreeSet的几万倍。
所以如果你的场景里集合很大,且同时需要"去重+有序+高效查询",TreeSet几乎是没有替代品的。小的数据量直接排序、大的数据量用TreeSet,这个结论虽然糙但基本靠谱。数据量在几百以内时性能差异感知不到,随便哪种方案都不影响业务;但一旦进入大集合、频繁插入删除的领域,选型是否能提前切换到TreeSet就直接决定程序会不会卡顿。
4. 实用的导航方法:ceiling/floor/subSet这些被低估的API
TreeSet声明实现NavigableSet接口,这意味着它不仅仅是"有序集合",还自带一套非常实用的"导航"方法。这些方法在日常开发里不太被提起,实际用起来却很顺手。
4.1 上下界查询:ceiling、floor、higher、lower
四个方法两两对应,注意区分"大于等于"和"严格大于":
TreeSet<Integer> scores = new TreeSet<>(); scores.add(65); scores.add(72); scores.add(85); scores.add(90); // 返回大于等于85的最小元素 System.out.println(scores.ceiling(85)); // 85 // 返回大于85的最小元素 System.out.println(scores.higher(85)); // 90 // 返回小于等于72的最大元素 System.out.println(scores.floor(72)); // 72 // 返回小于72的最大元素 System.out.println(scores.lower(72)); // 65 // 没有符合条件时返回null System.out.println(scores.higher(90)); // null这四个方法在解决"找最近匹配值"这类问题时非常好用。比如做限流系统时,要找到距离当前时间最近的一次调用记录;或者做价格区间匹配时,找到不超过预算的最高配置。实现起来只需要一行,接下来遍历或判断即可。
4.2 区间视图:subSet、headSet、tailSet与边界语义
subSet、headSet、tailSet这三个方法返回的是原集合的视图,不是拷贝。视图背后直接引用原集合,原集合增删元素,视图内容会自动同步。这里最容易踩的坑来自边界值语义:
TreeSet<Integer> numbers = new TreeSet<>(); numbers.add(1); numbers.add(3); numbers.add(5); numbers.add(7); // 返回[3, 7)区间,含3不含7 NavigableSet<Integer> range = numbers.subSet(3, true, 7, false); System.out.println(range); // [3, 5] // 在原集合中添加一个落入区间范围的6 numbers.add(6); // 视图同步变化 System.out.println(range); // [3, 5, 6]两个建议:
subSet默认是含头不含尾([from, to)),用的时候最好显式传四个参数,把两端的边界规则写清楚,避免默认为左闭右开导致误解。四个参数的API是从Java 6开始提供的,不要再用老掉牙的subSet(from, to)写法了。- 视图上的修改会写回原集合,因此对视图调用
clear()相当于清空原集合中对应区间的所有元素。如果你只是想"看一下",千万别顺手调用了修改操作。
4.3 其他实用API:first、last、pollFirst、pollLast、descendingSet
这几个方法属于"看着不起眼,用起来真香"的类型:
TreeSet<Long> timestamps = new TreeSet<>(); timestamps.add(1700000000001L); timestamps.add(1700000000050L); timestamps.add(1700000000100L); // 最小/最大元素 System.out.println(timestamps.first()); // 1700000000001 System.out.println(timestamps.last()); // 1700000000100 // 取出并删除最小元素 Long earliest = timestamps.pollFirst(); System.out.println(earliest); // 1700000000001 System.out.println(timestamps.size()); // 2 // 倒序遍历视图 NavigableSet<Long> desc = timestamps.descendingSet(); for (Long t : desc) { System.out.println(t); }pollFirst/pollLast在实现"优先处理最早/最晚到达的任务"这种队列式逻辑时特别顺手,一步完成"取出+删除",不用先查再删还担心竞态。descendingSet返回的也是一个视图,遍历时会按降序输出,相当于零成本得到一个反向有序集合。和Collections.reverse不同,这个视图保持原集合的实时同步,在需要同时拥有升序和降序遍历能力的场景下很好用。
5. 实际开发中踩过的坑:可变对象、null与误判排序
5.1 最阴险的坑:放入TreeSet后修改对象的排序字段
这是TreeSet所有坑里最经典、也最容易被忽视的一个。之前做"按最后活跃时间排序的在线用户池",我用TreeSet维护用户在线名单,规则是最后活跃时间越晚的排前面。用户每次发心跳就更新活跃时间字段,我以为TreeSet会自动调整顺序,结果上线后查询在线用户列表时发现顺序完全错乱,树结构直接变得不再有序。
原因要从TreeSet的存储机制说起。红黑树节点之间的顺序关系是在插入时通过一次比较确定的,节点插入后它在树中的位置就固定了。如果你修改了对象中参与排序的字段,树结构完全感知不到这种变化,它依然按照旧的比较结果维持树形结构,导致整棵树的二叉搜索树性质被破坏。之后再执行contains、remove、ceiling这类依赖树结构的操作时,结果全部不可预期。
解决方案就一句话:放进TreeSet的元素必须是不可变的,或者至少不能修改其参与排序的任何字段。如果一定要修改,正确做法是先从集合中移除该对象,修改完再重新add回去。下面的代码演示了修复后的流程:
TreeSet<Player> activePlayers = new TreeSet<>( Comparator.comparingLong(Player::getLastActiveTime).reversed() ); Player p = findPlayer("Alice"); // 先从集合移除 activePlayers.remove(p); // 修改活跃时间 p.setLastActiveTime(System.currentTimeMillis()); // 重新添加,让红黑树按新值重新定位 activePlayers.add(p);在维护这种可变状态数据的场景里,我建议不要过度依赖TreeSet做到"持久有序",而是把它当成一个"实时快照",数据变化频繁时直接重构成新集合,比移动单个对象更安全。
5.2 null到底能不能放进去:看构造器和比较器脸色
TreeSet是否允许null不是一句简单的"不行"能概括的。结论分两种情况:
- 使用自然排序(无参构造器)时:不可以。因为底层插入时会调用元素的
compareTo方法,null没有compareTo可调,直接抛NullPointerException。 - 使用自定义Comparator时:取决于比较器怎么处理null。如果比较器本身做了null的特殊判断,比如把null当成最小元素,那就可以容纳null。
TreeSet<String> nullFriendlySet = new TreeSet<>((a, b) -> { if (a == null && b == null) { return 0; } if (a == null) { return -1; // null比任何非null都小 } if (b == null) { return 1; } return a.compareTo(b); }); nullFriendlySet.add("banana"); nullFriendlySet.add(null); nullFriendlySet.add("apple"); System.out.println(nullFriendlySet); // [null, apple, banana]但说实话,让集合里存在null通常是个糟糕的设计。后续遍历、序列化、和其他集合互转时都容易产生空指针问题。我的建议是入参阶段就拦截掉null,别给TreeSet和你的业务代码添麻烦。
5.3 去重依据辨析:id相同还是内容相同
再强调一遍,TreeSet的去重判断完全看比较器的结果,和equals/hashCode没有半毛钱关系。正因为这样,两个逻辑上"不应视为相同"的对象,只要排序位置相同(compareTo返回0),就会被TreeSet视为重复而丢弃。
如果你希望"字段A不同也算重复",那你其实用错了集合类型;如果你希望"字段A不同就不该重复",就必须在比较器里把所有区分性字段都参与比较。一个稳妥的标准:
TreeSet<Device> deviceSet = new TreeSet<>((d1, d2) -> { // 设备ID相同即认为同一条记录,其余字段不影响去重 int idCompare = d1.getId().compareTo(d2.getId()); if (idCompare != 0) { return idCompare; } // 但为了维持"compareTo与equals一致",不能只返回0就让树结构挂掉 // 还需要加入次要排序字段,保证同一id下的不同版本也能有序排列 int timeCompare = d1.getUpdateTime().compareTo(d2.getUpdateTime()); if (timeCompare != 0) { return timeCompare; } return 0; });这种写法兼顾了业务上的"主键去重"和TreeSet内部的"严格排序一致性"。如果只在id层面返回0,就会丢失其他数据的不同版本,很容易造成信息覆盖。在复杂业务场景中,优先使用"主键相同即视为重复,但排序时保留所有字段参与比较"这样的设计,TreeSet才能保持既正确又有序。 ## 5.4 并行修改异常:遍历时的remove操作
TreeSet继承自AbstractCollection,它的迭代器是fail-fast的——在用迭代器遍历的过程中,如果集合结构发生了修改(尤其是add和remove),会直接抛出ConcurrentModificationException。这个异常你大概率在ArrayList上见过,但TreeSet的坑在于它的迭代器遍历顺序是有序的,更容易让人产生"遍历过程中修改好像没问题"的错觉。
TreeSet<Integer> set = new TreeSet<>(); set.add(1); set.add(2); set.add(3); // 这种写法会抛 ConcurrentModificationException for (Integer num : set) { if (num == 2) { set.remove(2); // 结构修改,迭代器检测到变化 } }正确写法:
Iterator<Integer> iterator = set.iterator(); while (iterator.hasNext()) { Integer num = iterator.next(); if (num == 2) { iterator.remove(); // 通过迭代器的remove,安全 } }5.5 与HashSet/LinkedHashSet的序列化兼容问题
如果你有一个对象里的字段是TreeSet类型,用ObjectOutputStream序列化了,升级代码时又把这部分逻辑改成了HashSet,反序列化时就会因为字段类型不匹配而失败。这种问题通常发生在序列化版本管理不够严格的系统中。另一个更隐蔽的问题是:TreeSet在序列化和反序列化之后,如果比较器没有实现Serializable接口,反序列化时会报NotSerializableException。解决办法是自定义比较器时,尽量让比较器也实现Serializable。
TreeSet<Integer> demoSet = new TreeSet<>( (Serializable & Comparator<Integer>) (a, b) -> Integer.compare(a, b) );这种写法略微冷门,但知道总比踩到强。
6. 工程选型建议:TreeSet、PriorityQueue与HashSet该选谁
6.1 各有分工的集合角色
很多读者会纠结:需要有序集合的时候,到底选TreeSet还是PriorityQueue?毕竟两者都能"维持有序"。我的经验是看你要的是一棵树还是一根管子。
TreeSet的核心特征是完整的集合语义:去重、随机访问、支持contains判断、支持区间查询、支持视图操作。它是一棵能"横着看"的树——任何时候你都能看到全貌,还能灵活地切出任意子区间。
PriorityQueue的核心特征是只给你"顶端"——它只能保证队头元素是全局最大/最小,内部其余元素满足堆性质但不严格有序。你不能直接遍历得到完整有序序列,也没有contains方法可以高效判断元素是否存在(要O(n)遍历)。它是一根管子,只负责把最紧急的事情顶到最前面。
如果你的需求是"每次取一个全局最大/最小,反复插入并取走",选PriorityQueue。如果你的需求是"永远保持一个有序去重集合,且随时需要查询、遍历、取任意区间",选TreeSet。两者不是竞争关系,而是服务于不同需求的两种工具。
6.2 决策速查表
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 需要快速按key查找,不要求有序 | HashSet | O(1),开销最小 |
| 按插入顺序遍历,不要求业务排序 | LinkedHashSet | O(1) + 保序 |
| 数据只进不出,最后统一排序 | ArrayList + sort | 一次性排序性价比最高 |
| 频繁插入后需要实时有序结果 | TreeSet | O(log n)插入且始终有序 |
| 每次都只取最大/最小,用完即弃 | PriorityQueue | 堆结构,只关心顶端 |
| 区间查询、导航方法、子集视图 | TreeSet | NavigableSet独有能力 |
| 排行榜:拉取全量数据且变动频繁 | TreeSet | 增量排序优于全量重排 |
需要注意一个常见的过度设计:数据量只有几十条、排序也不频繁,这种场景用TreeSet纯粹是杀鸡用牛刀,ArrayList + Collections.sort会更直接且更易读。选型参考的是实际需求复杂度,不是为了让代码看起来"高端"。
6.3 一个小测试:观察TreeSet的插入顺序
动手试一个反直觉的小例子,看看TreeSet的排序逻辑到底怎么运作的:
TreeSet<Integer> intSet = new TreeSet<>(); intSet.add(9); intSet.add(1); intSet.add(8); intSet.add(2); intSet.add(7); System.out.println(intSet); // 输出 [1, 2, 7, 8, 9]每次add之后你都可以立刻打印集合,观察它是如何一步步把新元素放到正确位置的。如果循环多添加几百个随机数,再连续遍历几次,你会发现每次遍历结果完全一致、始终有序,而HashSet的遍历顺序可能每次都不同。这种直观对比能帮助建立对TreeSet底层结构的直觉——一个每次都能按规则排列好的树,和一个只关心能不能找到的哈希桶,到底有怎样的行为差异。
6.4 代码之外的另一个建议:优先使用Comparator
最后说一个编码习惯层面的建议。从易维护性角度,我强烈建议即使在实体类实现了Comparable的情况下,创建TreeSet时也显式传Comparator。理由有三条:一是比较器表达了"此刻这个集合需要什么顺序"的业务语义;二是同一个集合类型可以在不同地方以不同规则复用;三是想调整排序规则时,只需要改一处比较器,不用修改元素类本身。
一个实体类可能有多种合法排序需求,比如用户列表有按注册时间排的、按余额降序排的、按昵称字典序排的。如果User类只写死一个compareTo,你就会被这个唯一的默认方案绑定。而通过Comparator.comparing(...)链式构造多个比较器,创建不同的TreeSet实例分别维护不同的排序视图,代码清晰且灵活。举例来说:
// 同一组用户数据,维护两个TreeSet视图 TreeSet<User> byName = new TreeSet<>(Comparator.comparing(User::getName)); TreeSet<User> byBalance = new TreeSet<>( Comparator.comparingLong(User::getBalance).reversed() );两个集合存的是同一个对象的引用,数据共享,但各自按不同的业务规则组织索引。当然内存开销会翻倍,这和所有以空间换时间的优化一样,需要自己权衡。但这样写出来的代码,读起来是真正"一眼就知道在按什么顺序组织数据",比靠类上的compareTo实现隐式排序可靠得多。