Java 基础体系 · 第 58/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 有序集合:TreeMap、TreeSet、Comparator 和一致性
TreeMap 和 TreeSet 都是 Java 标准库中的有序集合。它们与 HashMap、HashSet 的根本区别,不是“内部换了一种数据结构”,而是元素或键的身份由排序关系决定:
HashMap、HashSet通常通过hashCode和equals判断键或元素;TreeMap、TreeSet通过自然顺序或Comparator判断键或元素;- 当比较结果为
0时,有序集合会把两个对象视为同一个排序位置上的对象,即使它们的equals返回false。
因此,使用有序集合时,真正需要理解的是:
- 排序关系必须满足什么条件;
TreeMap与TreeSet如何使用这个关系;Comparator、Comparable和equals之间如何保持一致;- 不一致时会出现什么可观察结果;
- 可变键、范围视图、并发和空值等边界如何影响行为。
一、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 规范文档规定,TreeMap 的 get、put、remove、containsKey 等基本操作保证对数级时间复杂度。其常见实现基于红黑树,但代码不应依赖红黑树的具体内部布局;应依赖 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. 自然顺序
如果创建 TreeMap 或 TreeSet 时没有提供比较器,它们会使用键或元素的自然顺序。
自然顺序通常由类实现的 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 不只是返回一个负数、零或正数。对于 TreeMap 和 TreeSet,比较器必须足以表达稳定的排序关系。
设:
c(x, y) = comparator.compare(x, y)
通常只关心结果的符号:
c(x, y) < 0:x排在y前面;c(x, y) == 0:x与y在排序上等价;c(x, y) > 0:x排在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 再调用比较器。它会沿着树进行比较:
- 将待插入元素
e与当前节点元素比较; - 如果
compare(e, current) < 0,向左子树查找; - 如果
compare(e, current) > 0,向右子树查找; - 如果
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. 完整运行示例
下面的程序同时展示 TreeSet 和 TreeMap 的比较行为:
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.0 和 1.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 一致,通常要求对所有对象 x、y:
compare(x, y) == 0
当且仅当
x.equals(y) == true
也就是:
compare(x, y) == 0 ⇔ x.equals(y)
这里的“当且仅当”包含两个方向:
compare(x, y) == 0时,必须x.equals(y);x.equals(y)时,必须compare(x, y) == 0。
如果只满足第二个方向,不满足第一个方向,仍然可能出现一个 TreeSet 把两个不相等对象合并的问题。
2. 为什么 TreeMap 和 TreeSet 希望这种一致性
Set 的一般契约把集合中的重复关系建立在 equals 上;Map 的一般契约也把键的相等关系建立在 equals 上。
而 TreeSet 与 TreeMap 为了支持排序,实际使用的是:
compare(...) == 0
因此,如果比较器与 equals 不一致,排序集合的行为就可能违背开发者对 Set 或 Map 的通常理解。
这不是说 Java 禁止这种比较器。Comparator 文档明确允许比较器与 equals 不一致,但建议明确记录这一事实。TreeMap 和 TreeSet 的文档也都指出:若想完全遵守 Map 或 Set 的一般契约,排序应当与 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,让equals和hashCode定义对象身份。
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 的范围视图
TreeMap 和 TreeSet 的有序性不仅用于遍历,还用于构造范围视图。
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]
对这种集合使用 lower、higher、范围视图时,应始终以集合自身的 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 的问题类似,但机制不同:
- 哈希集合依赖哈希桶位置;
- 有序集合依赖比较路径和排序位置。
安全做法是:
- 参与比较的字段在放入集合后不可变;
- 或者修改前先移除对象,修改后重新加入;
- 更可靠地使用不可变值对象作为键。
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. 需要排序、范围和邻近查询时
使用 TreeMap 或 TreeSet 的典型原因是需要:
map.floorEntry(time)
map.ceilingEntry(time)
set.subSet(min, true, max, false)
set.first()
set.last()
这类操作依赖全局顺序,哈希表不能直接提供相同语义。
2. 只需要按键查找时
如果只需要:
get
put
remove
contains
且不需要顺序和范围操作,HashMap 或 HashSet 通常更直接,因为它们以 equals 和 hashCode 定义身份。
3. 排序与唯一性可能不是同一个维度
这是选择 TreeSet 时最容易忽略的问题。
例如,需求可能是:
按照金额排序,但订单号必须唯一
如果直接用金额比较器构造 TreeSet<Order>,相同金额的订单可能被合并。更准确的模型通常是:
TreeMap<Long, Order> ordersById;
如果还需要按金额排序,则可以维护另一个索引,或者使用专门的数据模型,而不是让一个比较器同时承担“显示排序”和“业务身份”的职责。
十二、并发行为和线程安全
TreeMap 和 TreeSet 默认都不是线程安全的。
多个线程并发修改同一个实例时,即使单次操作看起来简单,也不能仅凭 get、put 或 add 的单方法语义推导出复合操作安全。例如:
if (!map.containsKey(key)) {
map.put(key, value);
}
这不是原子操作。两个线程可能同时通过 containsKey,然后都执行 put。
如果需要并发有序映射,应考虑:
ConcurrentSkipListMap<K, V>
如果需要并发有序集合,应考虑:
ConcurrentSkipListSet<E>
它们采用跳表等并发有序结构,提供与排序相关的并发集合语义,但并不意味着任意多步业务流程自动成为原子操作。
通过:
Collections.synchronizedSortedMap(...)
Collections.synchronizedSortedSet(...)
进行包装时,单次方法调用可以通过同步保护,但遍历和复合操作仍需要按照包装器文档显式同步。例如,遍历同步包装后的集合时,不能忽略外部锁要求。
TreeMap 和 TreeSet 的迭代器通常具有 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 相等时,若比较器没有返回零,也会破坏有序集合对唯一性的预期。
对于值对象,最清晰的设计通常是:
equals、hashCode和排序都基于同一组不可变字段;- 如果排序故意只使用部分字段,应明确接受排序等价类大于
equals等价类; - 不要把“排序字段”自动当成“对象身份”,除非业务确实如此定义。
十六、一个一致性较好的值对象示例
下面的 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 自动根据全部组件生成 equals 和 hashCode,而 compareTo 也使用同样的三个组件,因此这三套规则保持一致。
如果后来增加一个 buildMetadata 字段,但 equals 包含该字段、compareTo 不包含该字段,那么两个对象可能:
equals == false
compareTo == 0
此时 TreeSet 仍会合并它们。字段增加后,必须重新检查一致性,而不能认为旧的比较器仍然正确。
十七、Java 25 LTS 范围内的结论
在 Java 25 LTS 中,TreeMap、TreeSet 和 Comparator 的核心契约仍然建立在以下关系上:
TreeMap / TreeSet 的键身份
= 自然顺序或 Comparator 返回 0 的关系
而不是:
键身份 = equals 返回 true 的关系
如果排序关系与 equals 一致,则:
compare(a, b) == 0 ⇔ a.equals(b)
有序集合通常最符合 Map 和 Set 的直觉语义。
如果排序关系有意与 equals 不一致,集合仍然可以使用,但必须把这种行为当成设计的一部分:
- 按长度的字符串集合;
- 大小写不敏感的名称集合;
- 按业务主键去重的对象集合;
- 忽略精度或格式差异的数值集合。
最终应根据需要选择数据结构:
- 需要全局顺序、范围查询和邻近键:
TreeMap、TreeSet; - 只需要基于
equals的快速查找:HashMap、HashSet; - 需要并发有序访问:
ConcurrentSkipListMap、ConcurrentSkipListSet; - 需要允许排序相同的重复元素:不要直接使用以该字段为唯一排序键的
TreeSet。
理解 compare == 0 的真正含义,是正确使用 Java 有序集合的核心。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java HashMap 深入:哈希、桶、树化、扩容、迭代和键约束
- 下一篇:Java Queue 与 Deque:优先队列、双端队列、阻塞语义和选型
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论