Java 基础体系 · 第 58/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 有序集合:TreeMap、TreeSet、Comparator 和一致性

TreeMapTreeSet 都是 Java 标准库中的有序集合。它们与 HashMapHashSet 的根本区别,不是“内部换了一种数据结构”,而是元素或键的身份由排序关系决定

  • HashMapHashSet 通常通过 hashCodeequals 判断键或元素;
  • TreeMapTreeSet 通过自然顺序或 Comparator 判断键或元素;
  • 当比较结果为 0 时,有序集合会把两个对象视为同一个排序位置上的对象,即使它们的 equals 返回 false

因此,使用有序集合时,真正需要理解的是:

  1. 排序关系必须满足什么条件;
  2. TreeMapTreeSet 如何使用这个关系;
  3. ComparatorComparableequals 之间如何保持一致;
  4. 不一致时会出现什么可观察结果;
  5. 可变键、范围视图、并发和空值等边界如何影响行为。

一、TreeMap 和 TreeSet 的基本模型

1. TreeMap:按键排序的映射

TreeMap<K, V> 实现了 NavigableMap<K, V>。它保存键值映射,并按照键的自然顺序或指定比较器排序。

TreeMap<Integer, String> scores = new TreeMap<>();

scores.put(90, "Alice");
scores.put(70, "Bob");
scores.put(85, "Carol");

System.out.println(scores);
System.out.println(scores.firstKey());
System.out.println(scores.lastKey());

输出:

{70=Bob, 85=Carol, 90=Alice}
70
90

插入顺序是 90、70、85,但遍历和打印顺序由键的排序关系决定。

TreeMap 的常见操作包括:

操作 含义
put(k, v) 插入或更新键对应的值
get(k) 根据排序关系查找键
containsKey(k) 根据排序关系判断是否存在键
firstKey() 返回最小键
lastKey() 返回最大键
lowerKey(k) 返回严格小于 k 的最大键
floorKey(k) 返回小于或等于 k 的最大键
ceilingKey(k) 返回大于或等于 k 的最小键
higherKey(k) 返回严格大于 k 的最小键
pollFirstEntry() 删除并返回最小键值对
pollLastEntry() 删除并返回最大键值对

Java SE 规范文档规定,TreeMapgetputremovecontainsKey 等基本操作保证对数级时间复杂度。其常见实现基于红黑树,但代码不应依赖红黑树的具体内部布局;应依赖 TreeMap 的排序和 NavigableMap 契约。

2. TreeSet:按元素排序的集合

TreeSet<E> 实现了 NavigableSet<E>。它不保存值,只保存唯一元素,并按照自然顺序或指定比较器排列。

TreeSet<Integer> numbers = new TreeSet<>();

numbers.add(90);
numbers.add(70);
numbers.add(85);
numbers.add(70);

System.out.println(numbers);
System.out.println(numbers.first());
System.out.println(numbers.last());

输出:

[70, 85, 90]
70
90

第二次加入 70 没有增加元素,因为对整数而言:

Integer.compareTo(Integer)

在数值相等时返回 0

TreeSet 的结构关系可以理解为:

TreeSet<E>
    └── 内部使用 TreeMap<E, Object>
            ├── E 作为键
            └── 一个固定的占位值作为 value

这个关系解释了一个重要事实:

TreeSet 是否认为两个元素重复,实际上取决于 TreeMap 判断两个键是否位于同一个排序等价类。

因此,讨论 TreeSet 的去重问题,不能只看 equals,必须看比较结果。


二、自然顺序、Comparable 和 Comparator

1. 自然顺序

如果创建 TreeMapTreeSet 时没有提供比较器,它们会使用键或元素的自然顺序。

自然顺序通常由类实现的 Comparable<T> 定义:

public interface Comparable<T> {
    int compareTo(T other);
}

例如:

TreeSet<String> names = new TreeSet<>();

names.add("Bob");
names.add("Alice");
names.add("Carol");

System.out.println(names);

输出:

[Alice, Bob, Carol]

String 实现了 Comparable<String>,所以 TreeSet<String> 可以直接比较字符串。

使用自然顺序时,集合中的对象通常必须彼此可比较。否则在运行时进行插入或查询时可能抛出 ClassCastException

例如,以下写法没有合理的统一自然顺序:

TreeSet<Object> set = new TreeSet<>();

set.add("text");
set.add(123);    // 可能在比较时抛出 ClassCastException

问题不在于泛型声明允许 Object,而在于字符串和整数之间没有共同的自然排序关系。

2. Comparator:从对象外部提供排序规则

Comparator<T> 表示一个独立于被比较对象的排序规则:

@FunctionalInterface
public interface Comparator<T> {
    int compare(T first, T second);
}

例如,按字符串长度排序:

Comparator<String> byLength =
        Comparator.comparingInt(String::length);

TreeSet<String> words = new TreeSet<>(byLength);

words.add("cat");
words.add("dog");
words.add("hello");

System.out.println(words);

输出可能是:

[cat, hello]

"cat""dog" 长度相同,因此:

byLength.compare("cat", "dog") == 0

TreeSet 会把它们视为同一个元素,只保留先插入的那个。

如果需要“先按长度,再按字典序”:

Comparator<String> byLengthThenLexicographically =
        Comparator.comparingInt(String::length)
                  .thenComparing(Comparator.naturalOrder());

TreeSet<String> words = new TreeSet<>(byLengthThenLexicographically);

words.add("dog");
words.add("cat");
words.add("hello");

System.out.println(words);

输出:

[cat, dog, hello]

现在 "cat""dog" 的长度虽然相同,但第二级比较器会继续比较内容,因此不会被视为同一个元素。

3. Comparator 的常见组合方式

Comparator 可以组成多级排序:

Comparator<Person> comparator =
        Comparator.comparing(Person::lastName)
                  .thenComparing(Person::firstName)
                  .thenComparingInt(Person::age);

也可以反转排序:

Comparator<Integer> descending =
        Comparator.reverseOrder();

或者只反转某一级:

Comparator<Person> comparator =
        Comparator.comparingInt(Person::age).reversed()
                  .thenComparing(Person::name);

这里的组合顺序很重要。reversed() 作用于调用它的整个比较器;如果只想反转年龄而不反转姓名,应先构造年龄比较器并对该部分调用 reversed()


三、Comparator 必须表达什么样的排序关系

Comparator 不只是返回一个负数、零或正数。对于 TreeMapTreeSet,比较器必须足以表达稳定的排序关系。

设:

c(x, y) = comparator.compare(x, y)

通常只关心结果的符号:

  • c(x, y) < 0x 排在 y 前面;
  • c(x, y) == 0xy 在排序上等价;
  • c(x, y) > 0x 排在 y 后面。

一个适合作为有序集合排序依据的比较关系,至少应满足以下性质。

1. 自反性对应的零结果

任意对象自身比较应为零:

c(x, x) = 0

如果一个比较器对同一个对象返回非零结果,集合无法稳定地定位这个对象。

2. 反对称性

对于两个对象,比较方向反转时结果符号应反转:

sign(c(x, y)) = -sign(c(y, x))

例如,如果 x 小于 y,那么 y 必须大于 x

3. 传递性

如果:

x < y
y < z

那么必须有:

x < z

否则树结构可能按照一条路径把对象放置,却无法得到一致的全序关系。

4. 排序等价关系的传递性

如果:

c(x, y) = 0
c(y, z) = 0

那么也必须有:

c(x, z) = 0

否则“相等的排序位置”本身不稳定。

5. 与比较结果符号相关的稳定性

如果两个对象与第三个对象比较时使用相同的方向,那么它们的相对关系也应保持一致。例如,若:

c(x, y) = 0

则对任意 z,通常应满足:

sign(c(x, z)) = sign(c(y, z))

以及:

sign(c(z, x)) = sign(c(z, y))

这些条件的直觉是:排序关系应把对象划分为一组组稳定的等价类,并且这些等价类本身可以被线性排列。


四、TreeMap 和 TreeSet 如何使用比较结果

1. TreeSet 的“重复”定义

TreeSet.add(e) 不会先调用 equals 再调用比较器。它会沿着树进行比较:

  1. 将待插入元素 e 与当前节点元素比较;
  2. 如果 compare(e, current) < 0,向左子树查找;
  3. 如果 compare(e, current) > 0,向右子树查找;
  4. 如果 compare(e, current) == 0,认为已存在,不新增节点。

因此,TreeSet 的唯一性条件是:

compare(a, b) != 0

而不是:

!a.equals(b)

2. TreeMap 的键匹配定义

TreeMap.get(key)containsKey(key)put(key, value)remove(key) 也按照比较结果定位键。

当:

compare(existingKey, suppliedKey) == 0

时,TreeMap 会把它们当作同一个键位置。

这意味着,查询使用的对象不必是插入时的同一个对象,也不必与原键对象的 equals 返回 true

例如:

TreeMap<String, Integer> map =
        new TreeMap<>(String.CASE_INSENSITIVE_ORDER);

map.put("Java", 1);

System.out.println(map.containsKey("JAVA"));
System.out.println(map.get("JAVA"));

输出:

true
1

"Java".equals("JAVA")false,但不区分大小写的比较器返回零,所以 TreeMap 认为它们对应同一个键位置。

如果再次插入:

map.put("java", 2);

结果是更新该排序位置上的值,而不是增加第二个键:

System.out.println(map.size());
System.out.println(map.get("JAVA"));

输出:

1
2

逻辑上,原来的键值映射被更新了。实际实现中,已有节点的键对象通常不会因为更新值而替换,但应用程序不应依赖这个实现细节来建立业务语义。

3. 完整运行示例

下面的程序同时展示 TreeSetTreeMap 的比较行为:

import java.math.BigDecimal;
import java.util.TreeMap;
import java.util.TreeSet;

public class OrderedCollectionsDemo {
    public static void main(String[] args) {
        TreeSet<String> names =
                new TreeSet<>(String.CASE_INSENSITIVE_ORDER);

        names.add("Java");
        names.add("JAVA");

        System.out.println("names = " + names);
        System.out.println("names.size() = " + names.size());
        System.out.println("names.contains(\"java\") = "
                + names.contains("java"));

        TreeMap<String, Integer> counts =
                new TreeMap<>(String.CASE_INSENSITIVE_ORDER);

        counts.put("Java", 1);
        counts.put("JAVA", 2);

        System.out.println("counts = " + counts);
        System.out.println("counts.size() = " + counts.size());
        System.out.println("counts.get(\"java\") = "
                + counts.get("java"));

        TreeSet<BigDecimal> decimals = new TreeSet<>();

        decimals.add(new BigDecimal("1.0"));
        decimals.add(new BigDecimal("1.00"));

        System.out.println("decimals = " + decimals);
        System.out.println("decimals.size() = " + decimals.size());
    }
}

预期输出:

names = [Java]
names.size() = 1
names.contains("java") = true
counts = {Java=2}
counts.size() = 1
counts.get("java") = 2
decimals = [1.0]
decimals.size() = 1

最后一组结果不是 TreeSet 的特殊 bug。BigDecimal 的自然顺序把 1.01.00 视为数值相等:

new BigDecimal("1.0").compareTo(new BigDecimal("1.00")) == 0

但它们的 equals 还比较小数位信息:

new BigDecimal("1.0").equals(new BigDecimal("1.00")) == false

这是 Java 标准库中最典型的“自然顺序与 equals 不一致”案例。


五、Comparator 与 equals 的一致性

1. 一致性的形式化定义

若比较器与 equals 一致,通常要求对所有对象 xy

compare(x, y) == 0
当且仅当
x.equals(y) == true

也就是:

compare(x, y) == 0 ⇔ x.equals(y)

这里的“当且仅当”包含两个方向:

  1. compare(x, y) == 0 时,必须 x.equals(y)
  2. x.equals(y) 时,必须 compare(x, y) == 0

如果只满足第二个方向,不满足第一个方向,仍然可能出现一个 TreeSet 把两个不相等对象合并的问题。

2. 为什么 TreeMap 和 TreeSet 希望这种一致性

Set 的一般契约把集合中的重复关系建立在 equals 上;Map 的一般契约也把键的相等关系建立在 equals 上。

TreeSetTreeMap 为了支持排序,实际使用的是:

compare(...) == 0

因此,如果比较器与 equals 不一致,排序集合的行为就可能违背开发者对 SetMap 的通常理解。

这不是说 Java 禁止这种比较器。Comparator 文档明确允许比较器与 equals 不一致,但建议明确记录这一事实。TreeMapTreeSet 的文档也都指出:若想完全遵守 MapSet 的一般契约,排序应当与 equals 一致。

3. 反例:只按长度排序

Comparator<String> byLength =
        Comparator.comparingInt(String::length);

TreeSet<String> set = new TreeSet<>(byLength);

set.add("cat");
set.add("dog");
set.add("horse");

System.out.println(set);
System.out.println(set.contains("pig"));

输出:

[cat, horse]
true

原因是:

"cat".equals("dog") == false
byLength.compare("cat", "dog") == 0

所以第二个字符串没有进入集合;而 "pig" 虽然从未插入,但它长度为 3,查询时会与 "cat" 比较为零,因此 contains("pig") 返回 true

这说明 contains 的语义已经不是“集合中存在一个 equals 等于 pig 的字符串”,而是“集合中存在一个与 pig 排序等价的字符串”。

4. 反例:不区分大小写

TreeSet<String> set =
        new TreeSet<>(String.CASE_INSENSITIVE_ORDER);

set.add("admin");

System.out.println(set.contains("ADMIN"));
System.out.println(set.contains("Admin"));

输出:

true
true

这种行为可能是业务真正想要的,例如用户名、主机名或协议字段需要大小写不敏感。但此时应明确承认:该集合的身份规则是“大小写不敏感的比较关系”,不是字符串的 equals 关系。

5. 反例:只比较业务主键

假设有订单对象:

record Order(long id, String customer, long amount) {}

如果使用以下比较器:

Comparator<Order> byId =
        Comparator.comparingLong(Order::id);

那么两个 id 相同但其他字段不同的订单会被 TreeSet 视为同一个元素:

TreeSet<Order> orders = new TreeSet<>(byId);

orders.add(new Order(1001, "Alice", 500));
orders.add(new Order(1001, "Bob", 800));

System.out.println(orders);

输出只有一个订单。

这可能是正确的:如果集合的业务定义就是“每个订单号最多一个订单”,按 id 排序同时承担了唯一性约束。

但如果业务需要保留两个订单对象,则不能使用只按 id 比较的 TreeSet。应改用:

  • TreeMap<Long, Order>,明确表示 id -> 订单
  • 或者使用包含全部区分字段的比较器;
  • 或者使用 HashSet,让 equalshashCode 定义对象身份。

6. 用次级比较器消除非预期合并

如果第一排序字段可能重复,可以追加足以区分对象的字段:

record Person(String name, int age) {}

Comparator<Person> byNameThenAge =
        Comparator.comparing(Person::name)
                  .thenComparingInt(Person::age);

TreeSet<Person> people = new TreeSet<>(byNameThenAge);

people.add(new Person("Alice", 20));
people.add(new Person("Alice", 30));

System.out.println(people);

输出:

[Person[name=Alice, age=20], Person[name=Alice, age=30]]

但这仍然不自动保证与 equals 一致。因为两个 Person 可能姓名和年龄相同、但还有未参与比较的字段;或者 equals 的定义包含更多状态。

因此,比较器是否一致,必须根据类的完整 equals 定义检查,而不能只看排序结果“看起来合理”。


六、TreeMap 和 TreeSet 的范围视图

TreeMapTreeSet 的有序性不仅用于遍历,还用于构造范围视图。

1. TreeMap 的 subMap

TreeMap<Integer, String> map = new TreeMap<>();

map.put(10, "A");
map.put(20, "B");
map.put(30, "C");
map.put(40, "D");

var middle = map.subMap(20, true, 40, false);

System.out.println(middle);
middle.put(30, "C2");

System.out.println(map);

输出:

{20=B, 30=C}
{10=A, 20=B, 30=C2, 40=D}

subMap(20, true, 40, false) 表示:

20 <= key < 40

这个对象不是复制出来的新 TreeMap,而是原映射的受约束视图。对视图的修改会影响原映射;对原映射的修改也会反映到视图中。

尝试插入范围外的键会失败:

middle.put(50, "E");

通常会抛出 IllegalArgumentException,因为 50 不在该视图允许的键范围内。

2. TreeSet 的范围视图

TreeSet<Integer> set = new TreeSet<>();

set.add(10);
set.add(20);
set.add(30);
set.add(40);

var middle = set.subSet(20, true, 40, false);

System.out.println(middle);
middle.remove(30);

System.out.println(set);

输出:

[20, 30]
[10, 20, 40]

范围视图的边界由排序关系决定。如果使用自定义比较器,所谓“大于”“小于”也必须按该比较器理解,而不是按对象的自然顺序或数值直觉理解。

例如,降序集合中的范围含义与升序集合不同:

TreeSet<Integer> descending =
        new TreeSet<>(Comparator.reverseOrder());

descending.add(10);
descending.add(20);
descending.add(30);

System.out.println(descending);

输出:

[30, 20, 10]

对这种集合使用 lowerhigher、范围视图时,应始终以集合自身的 comparator 为准。


七、比较器为 null、元素为 null 和值为 null

1. 构造器参数为 null 的含义

以下构造方式使用自然顺序:

TreeSet<String> set = new TreeSet<>();
TreeMap<String, Integer> map = new TreeMap<>();

也可以显式传入 null comparator:

TreeSet<String> set =
        new TreeSet<>(null);

TreeMap<String, Integer> map =
        new TreeMap<>(null);

这里的 null 表示“不提供自定义比较器”,而不是“允许任意对象或任意空值”。

2. 自然顺序通常不接受 null 键

TreeSet<String> set = new TreeSet<>();
set.add(null);

由于自然顺序需要调用对象的比较逻辑,通常会抛出 NullPointerException

TreeMap 的 null 键也有同样问题:

TreeMap<String, Integer> map = new TreeMap<>();
map.put(null, 1);

3. Comparator 可以显式支持 null

如果业务确实需要将 null 排在最前面或最后面,可以使用:

Comparator<String> comparator =
        Comparator.nullsFirst(Comparator.naturalOrder());

TreeSet<String> set = new TreeSet<>(comparator);

set.add(null);
set.add("Java");
set.add("Kotlin");

System.out.println(set);

输出:

[null, Java, Kotlin]

这里不是 TreeSet 自动支持 null,而是传入的比较器定义了:

null < 非 null 值

同理,Comparator.nullsLast(...) 可以把 null 排在末尾。

4. TreeMap 的 value 可以为 null

键和 value 的规则不同。TreeMap 的 value 不参与排序,因此通常允许:

TreeMap<String, Integer> map = new TreeMap<>();
map.put("Java", null);

但键是否允许 null,仍由比较器决定。


八、可变对象作为键或元素的风险

有序集合依赖对象在树中的排序位置。如果对象加入集合后,其参与比较的字段发生变化,树不会自动重新排列节点。

import java.util.Comparator;
import java.util.TreeSet;

public class MutableKeyDemo {
    static final class User {
        private String name;

        User(String name) {
            this.name = name;
        }

        void rename(String newName) {
            this.name = newName;
        }

        String name() {
            return name;
        }

        @Override
        public String toString() {
            return name;
        }
    }

    public static void main(String[] args) {
        Comparator<User> byName =
                Comparator.comparing(User::name);

        User user = new User("Bob");
        TreeSet<User> users = new TreeSet<>(byName);

        users.add(user);
        user.rename("Zoe");

        System.out.println(users);
        System.out.println(users.contains(user));
        System.out.println(users.remove(user));
    }
}

表面上可能输出:

[Zoe]
false
false

集合遍历直接打印节点中的对象,所以显示为 Zoe;但查找和删除会从树根开始,根据当前的 "Zoe" 重新选择路径。节点原来按照 "Bob" 的位置存放,因此新路径可能找不到它。

这种问题与 HashSet 中修改影响 hashCode 的问题类似,但机制不同:

  • 哈希集合依赖哈希桶位置;
  • 有序集合依赖比较路径和排序位置。

安全做法是:

  1. 参与比较的字段在放入集合后不可变;
  2. 或者修改前先移除对象,修改后重新加入;
  3. 更可靠地使用不可变值对象作为键。
users.remove(user);
user.rename("Zoe");
users.add(user);

这段代码成立的前提是:在字段修改前,remove 能够使用旧的比较值找到对象。


九、比较器状态和外部变量的风险

比较器不一定是纯函数。下面的比较器依赖外部可变状态:

final class SortMode {
    boolean ascending = true;
}

SortMode mode = new SortMode();

Comparator<Integer> comparator = (a, b) -> {
    int result = Integer.compare(a, b);
    return mode.ascending ? result : -result;
};

TreeSet<Integer> set = new TreeSet<>(comparator);
set.add(1);
set.add(2);

mode.ascending = false;

System.out.println(set.contains(1));

集合创建后改变比较方向,会使已存在的树不再符合当前比较器定义的顺序。之后的查找、插入、删除和范围操作都可能出现难以解释的结果。

比较器应尽量满足:

同一生命周期内,对相同的 x、y 返回稳定的相对关系

这不仅是性能建议,而是维护树不变量的必要条件。


十、比较器违反传递性时会发生什么

以下比较器不是按普通整数大小排序,而是构造一个循环关系:

Comparator<Integer> cyclic = (a, b) -> {
    if (a.equals(b)) {
        return 0;
    }

    if (a == 0 && b == 1) return -1;
    if (a == 1 && b == 2) return -1;
    if (a == 2 && b == 0) return -1;

    return 1;
};

它表达了:

0 < 1
1 < 2
2 < 0

于是:

0 < 1 且 1 < 2

但没有:

0 < 2

这违反传递性。将这种比较器交给 TreeSet 后,结果可能看似能插入和遍历,但不能再把集合当作可靠的有序集合使用。查找结果、范围操作和遍历顺序都失去了正常排序关系应有的意义。

比较器返回值的绝对数值也没有意义。不要写依赖溢出的代码:

return a - b;

例如:

Integer a = Integer.MAX_VALUE;
Integer b = -1;

System.out.println(a - b); // 发生整数溢出

应使用:

Integer.compare(a, b)

或:

Comparator.comparingInt(...)

对于 long 使用:

Long.compare(a, b)

这样才能保持正确的符号关系。


十一、TreeMap、TreeSet 与 HashMap、HashSet 的选择边界

有序集合与哈希集合不是简单的“性能不同版本”,它们表达的集合语义不同。

1. 需要排序、范围和邻近查询时

使用 TreeMapTreeSet 的典型原因是需要:

map.floorEntry(time)
map.ceilingEntry(time)
set.subSet(min, true, max, false)
set.first()
set.last()

这类操作依赖全局顺序,哈希表不能直接提供相同语义。

2. 只需要按键查找时

如果只需要:

get
put
remove
contains

且不需要顺序和范围操作,HashMapHashSet 通常更直接,因为它们以 equalshashCode 定义身份。

3. 排序与唯一性可能不是同一个维度

这是选择 TreeSet 时最容易忽略的问题。

例如,需求可能是:

按照金额排序,但订单号必须唯一

如果直接用金额比较器构造 TreeSet<Order>,相同金额的订单可能被合并。更准确的模型通常是:

TreeMap<Long, Order> ordersById;

如果还需要按金额排序,则可以维护另一个索引,或者使用专门的数据模型,而不是让一个比较器同时承担“显示排序”和“业务身份”的职责。


十二、并发行为和线程安全

TreeMapTreeSet 默认都不是线程安全的。

多个线程并发修改同一个实例时,即使单次操作看起来简单,也不能仅凭 getputadd 的单方法语义推导出复合操作安全。例如:

if (!map.containsKey(key)) {
    map.put(key, value);
}

这不是原子操作。两个线程可能同时通过 containsKey,然后都执行 put

如果需要并发有序映射,应考虑:

ConcurrentSkipListMap<K, V>

如果需要并发有序集合,应考虑:

ConcurrentSkipListSet<E>

它们采用跳表等并发有序结构,提供与排序相关的并发集合语义,但并不意味着任意多步业务流程自动成为原子操作。

通过:

Collections.synchronizedSortedMap(...)
Collections.synchronizedSortedSet(...)

进行包装时,单次方法调用可以通过同步保护,但遍历和复合操作仍需要按照包装器文档显式同步。例如,遍历同步包装后的集合时,不能忽略外部锁要求。

TreeMapTreeSet 的迭代器通常具有 fail-fast 行为:检测到结构性并发修改时可能抛出 ConcurrentModificationException。但这是错误检测机制,不是并发控制机制;规范不保证每一次并发修改都必然被检测到。


十三、视图、迭代器和结构性修改

keySet()navigableKeySet()descendingMap()subMap() 等方法返回的通常是原集合的视图,而不是独立副本。

例如:

TreeMap<Integer, String> map = new TreeMap<>();
map.put(1, "one");
map.put(2, "two");

var keys = map.navigableKeySet();
keys.remove(1);

System.out.println(map);

输出:

{2=two}

通过键视图删除键,实际上删除了原映射中的映射。

同时,不能把范围视图当成永久快照。原集合变化后,视图内容也会变化;视图自己的边界约束也会继续生效。

如果需要独立数据,应显式复制:

TreeMap<Integer, String> copy = new TreeMap<>(map);

复制时会复制映射关系和排序结果,但键和值对象本身仍然是浅复制。


十四、诊断有序集合异常的步骤

当出现“明明插入了却找不到”“集合大小不对”或“删除失败”时,可以按以下顺序检查。

1. 先打印比较器

System.out.println(set.comparator());
System.out.println(map.comparator());

返回 null 表示使用自然顺序,不表示没有排序。

2. 直接验证比较结果和 equals

System.out.println(a.equals(b));
System.out.println(comparator.compare(a, b));

重点检查:

equals == false,但 compare == 0

这通常解释了元素被合并、查询命中“另一个对象”的现象。

3. 检查参与排序的字段是否被修改

对同一个对象分别记录:

加入集合时的排序值
当前的排序值

如果二者不同,应优先怀疑可变键问题。

4. 检查比较器是否依赖外部状态

以下因素都可能改变排序关系:

  • 当前时间;
  • 随机数;
  • 可变配置;
  • 可变全局变量;
  • 非确定性的 I/O 结果;
  • 不稳定的对象字段。

比较器应避免这些依赖。

5. 检查比较器是否存在溢出或类型截断

避免:

(a, b) -> (int) (a.longValue() - b.longValue())

以及:

(a, b) -> a.age() - b.age()

应使用类型安全的比较方法:

Comparator.comparingLong(Item::timestamp)
Comparator.comparingInt(Item::age)

6. 检查是否误把 TreeSet 当成排序列表

TreeSet 只保留每个排序等价类中的一个元素。若需求是“允许重复,但按顺序排列”,应考虑:

  • List 加排序;
  • PriorityQueue
  • 允许重复键的结构;
  • TreeMap<Key, List<Value>>

TreeSet 的目标是有序且唯一,而不是有序但允许所有重复对象。


十五、equals、hashCode 和排序关系的三套规则

一个对象放入集合时,可能同时涉及三套规则:

1. equals

定义对象在业务或值语义上是否相等:

a.equals(b)

2. hashCode

为哈希集合提供桶定位。若:

a.equals(b) == true

则必须:

a.hashCode() == b.hashCode()

3. compareTo 或 Comparator

为有序集合提供相对顺序:

a.compareTo(b)
comparator.compare(a, b)

有序集合最容易出现的问题是:开发者只实现了其中一套规则,却把另一套规则的结论带入进来。

例如:

compare(a, b) == 0

只表示二者在该排序关系下等价,不自动表示:

a.equals(b)

反过来也一样:两个对象 equals 相等时,若比较器没有返回零,也会破坏有序集合对唯一性的预期。

对于值对象,最清晰的设计通常是:

  1. equalshashCode 和排序都基于同一组不可变字段;
  2. 如果排序故意只使用部分字段,应明确接受排序等价类大于 equals 等价类;
  3. 不要把“排序字段”自动当成“对象身份”,除非业务确实如此定义。

十六、一个一致性较好的值对象示例

下面的 Version 以主版本、次版本和修订版本共同定义身份与顺序:

import java.util.TreeSet;

public class VersionDemo {
    record Version(int major, int minor, int patch)
            implements Comparable<Version> {

        @Override
        public int compareTo(Version other) {
            int result = Integer.compare(major, other.major);
            if (result != 0) {
                return result;
            }

            result = Integer.compare(minor, other.minor);
            if (result != 0) {
                return result;
            }

            return Integer.compare(patch, other.patch);
        }
    }

    public static void main(String[] args) {
        TreeSet<Version> versions = new TreeSet<>();

        versions.add(new Version(1, 0, 0));
        versions.add(new Version(1, 0, 1));
        versions.add(new Version(1, 0, 0));

        System.out.println(versions);
        System.out.println(
                new Version(1, 0, 0).equals(new Version(1, 0, 0)));
        System.out.println(
                new Version(1, 0, 0)
                        .compareTo(new Version(1, 0, 0)));
    }
}

输出:

[Version[major=1, minor=0, patch=0], Version[major=1, minor=0, patch=1]]
true
0

record 自动根据全部组件生成 equalshashCode,而 compareTo 也使用同样的三个组件,因此这三套规则保持一致。

如果后来增加一个 buildMetadata 字段,但 equals 包含该字段、compareTo 不包含该字段,那么两个对象可能:

equals == false
compareTo == 0

此时 TreeSet 仍会合并它们。字段增加后,必须重新检查一致性,而不能认为旧的比较器仍然正确。


十七、Java 25 LTS 范围内的结论

在 Java 25 LTS 中,TreeMapTreeSetComparator 的核心契约仍然建立在以下关系上:

TreeMap / TreeSet 的键身份
    = 自然顺序或 Comparator 返回 0 的关系

而不是:

键身份 = equals 返回 true 的关系

如果排序关系与 equals 一致,则:

compare(a, b) == 0 ⇔ a.equals(b)

有序集合通常最符合 MapSet 的直觉语义。

如果排序关系有意与 equals 不一致,集合仍然可以使用,但必须把这种行为当成设计的一部分:

  • 按长度的字符串集合;
  • 大小写不敏感的名称集合;
  • 按业务主键去重的对象集合;
  • 忽略精度或格式差异的数值集合。

最终应根据需要选择数据结构:

  • 需要全局顺序、范围查询和邻近键:TreeMapTreeSet
  • 只需要基于 equals 的快速查找:HashMapHashSet
  • 需要并发有序访问:ConcurrentSkipListMapConcurrentSkipListSet
  • 需要允许排序相同的重复元素:不要直接使用以该字段为唯一排序键的 TreeSet

理解 compare == 0 的真正含义,是正确使用 Java 有序集合的核心。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。