qinghuan
← 回到知识探索

后端现场 / 可下载小项目

只是想按长度排序,TreeMap 为什么把不同名字合成了一条?

用 aa、bb、zz 对照写入、查询与删除:比较器的零值同时决定排序键身份,再用 BigDecimal 检查另一种相等规则。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先问比较结果为零究竟表示什么
  4. 第二次 put 改了值,get 和 remove 也沿用相同规则
  5. 长度相同时,再比较完整文字
  6. 仅仅排序一张列表,不会自动去重
  7. 2.0 与 2.00:自然顺序也可能与 equals 不同
  8. 运行完整程序,逐项核对行为
  9. 把“怎样排序”和“什么算同一个键”一起写进测试
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
排序等价
按这一套比较规则,两个对象处在同一排序位置。
次级比较
第一条比较规则分不出先后时,继续用第二条规则比较。
打开完整术语小抄 ↗

名字 aa 和 bb 明明不相等。你把 TreeMap 的比较器设为“只比较长度”,写入两次后却只剩一条;更意外的是,查询从未写入的 zz 也能得到结果。

问题出在一个看似只管展示顺序的规则:在 TreeMap 中,比较器返回零,也是在声明这两个键属于同一组。本页通过真实 Java 容器观察这个规则如何贯穿写入、查询与删除。

把例子带到自己的电脑上

下载完整练习包

需要 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • Main.java:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 Main.java
java -cp . Main

解压后,在含 Main.java 的目录打开终端。命令分别回车执行。真实TreeMap/HashMap/List,固定字符串与BigDecimal;每JDK83检查,每版复跑3次一致。未测可变键、并发或性能。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

只改比较规则,观察同样五步会留下哪些键

正在准备实验控件…

两种规则都依次写入 aa=10、bb=20、c=30,最后删除未直接写入的 zz。每一步额外查询 get("zz")。

第 0 步:空表

条目数
0
get(zz)
null(没有映射)
put 返回的旧值
尚未操作

当前条目:空表

aa、bb、zz 的长度都为二,因此在这张 TreeMap 中属于同一排序键。

回放真实 Java TreeMap 的固定实验记录;Microsoft OpenJDK 17.0.20.1 与 21.0.12 输出一致。页面不实现红黑树,只显示固定 ASCII 键的写入、查询和删除;保留下来的键文字是本次实测展示,不据此定义业务身份。

先问比较结果为零究竟表示什么

Comparator 返回负数、零或正数,分别表示排在前面、排序等价、排在后面。只按 String.length 比较时,aa、bb 和 zz 都落进长度为二的同一组。这里的长度是 Java 字符串的 UTF-16 单元数;实验只用 ASCII,不讨论文字显示宽度。

如果比较器交给 TreeMap,容器会用这个比较结果识别键。aa.equals(bb) 为 false,并不能迫使 TreeMap 额外留下一条记录。官方文档要求排序规则与 equals 一致,才符合一般 Map 的相等约定;本例特意违反这一点来展示后果。

第二次 put 改了值,get 和 remove 也沿用相同规则

先 put("aa",10),再 put("bb",20)。第二次调用返回旧值 10,size 仍为 1。这里返回旧值是一条有用的证据:本次行为是替换已有映射的值,而不是新增加一条。

随后 get("zz") 得到 20,containsKey("zz") 为 true;remove("zz") 又能移除这一条映射。若只盯着写入点,把这个现象叫作“偶然丢数据”,就无法解释为什么读取和删除也受影响。它们遵循的是同一套键比较规则。

程序不依靠遍历时究竟保留了哪一个原始字符串对象来解释业务身份,而是核对条目数、旧值与查询结果。若需要保留所有原始名称,必须先选择能表达这项要求的数据结构和比较规则。

长度相同时,再比较完整文字

lengthOnly.thenComparing(Comparator.naturalOrder()) 的意思是:先比较长度;只有长度相同,才让完整字符串顺序决定结果。于是 c 在 aa 与 bb 前面,aa 与 bb 又不再比较为零。

写入 bb、c、aa 后,键顺序是 [c, aa, bb],size 为 3;查询 zz 则不再命中。程序另外枚举六个固定字符串的所有有序配对,核对零比较与 equals 一致、交换左右后符号相反。这是对样本的检查,不是任意自定义比较器的正确性证明。

仅仅排序一张列表,不会自动去重

同一个 lengthOnly 用于 ArrayList.sort,输入 [bb, aa, c] 变成 [c, bb, aa],仍保留三项。List.sort 的稳定性让两个相同长度项保留原先相对次序。相同比较器交给不同操作,可能承担不同职责。

如果目标只是把全部记录按长度展示,可以对列表排序;若目标是“每个长度只留一份信息”,可以明确把长度作为分组键。先写清是否允许多条记录落在同一组,再选择容器,往往比事后补一个比较器更容易检查。

2.0 与 2.00:自然顺序也可能与 equals 不同

BigDecimal("2.0") 与 BigDecimal("2.00") 的数值相同,compareTo 返回 0;但小数位表示不同,equals 返回 false。实验把两者分别放进 HashMap 和采用自然顺序的 TreeMap,条目数分别为 2 和 1。

这说明问题不只出在自己手写的比较器。金额、版本、大小写不敏感名称等数据,都要明确采用哪一种相等约定。本例没有提供通用金额规范化方案,也没有把不同小数位表示一律判断成错误。

运行完整程序,逐项核对行为

保存为 Main.java 后编译运行。代码只操作固定内存数据,不需要 Maven、数据库或网络,检查失败会直接抛 AssertionError,不依赖 -ea。

2026-10-24 在 Windows 上分别使用 Microsoft OpenJDK 17.0.20.1 与 21.0.12,以 --release 17 编译。每版运行三次,六份 stdout 统一换行后完全相同,每次通过 83 项检查。

展开收起代码 · 小实验java · 59 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class Main {
    private static int checks;
    private static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
        checks++;
    }
    public static void main(String[] args) {
        Comparator<String> lengthOnly = Comparator.comparingInt(String::length);
        TreeMap<String, Integer> byLength = new TreeMap<>(lengthOnly);
        check(byLength.put("aa", 10) == null, "first key");
        Integer previous = byLength.put("bb", 20);
        check(previous == 10 && byLength.size() == 1, "zero comparison replaces value");
        check(!"aa".equals("bb") && lengthOnly.compare("aa", "bb") == 0, "two equalities differ");
        check(byLength.get("zz") == 20 && byLength.containsKey("zz"), "lookup uses same comparator");
        System.out.println("length-only: size=" + byLength.size() + " previous=" + previous
                + " get(zz)=" + byLength.get("zz") + " contains(zz)=" + byLength.containsKey("zz"));
        check(byLength.remove("zz") == 20 && byLength.isEmpty(), "remove uses same comparator");
        System.out.println("remove(zz): empty=" + byLength.isEmpty());

        Comparator<String> lengthThenText = lengthOnly.thenComparing(Comparator.naturalOrder());
        TreeMap<String, Integer> distinct = new TreeMap<>(lengthThenText);
        for (String key : List.of("bb", "c", "aa")) distinct.put(key, key.length());
        check(new ArrayList<>(distinct.keySet()).equals(List.of("c", "aa", "bb")), "tie-break order");
        check(!distinct.containsKey("zz") && distinct.size() == 3, "full text distinguishes keys");
        System.out.println("length-then-text: keys=" + distinct.keySet() + " contains(zz)=" + distinct.containsKey("zz"));

        List<String> words = new ArrayList<>(List.of("bb", "aa", "c"));
        words.sort(lengthOnly);
        check(words.equals(List.of("c", "bb", "aa")), "list sort retains tied items");
        System.out.println("list-sort: " + words + "; size=" + words.size());

        BigDecimal first = new BigDecimal("2.0"), second = new BigDecimal("2.00");
        Map<BigDecimal, String> hash = new HashMap<>();
        Map<BigDecimal, String> tree = new TreeMap<>();
        hash.put(first, "first"); hash.put(second, "second");
        tree.put(first, "first"); tree.put(second, "second");
        check(!first.equals(second) && first.compareTo(second) == 0, "decimal equality");
        check(hash.size() == 2 && tree.size() == 1, "different map key rules");
        check(tree.get(new BigDecimal("2.000")).equals("second"), "decimal lookup");
        System.out.println("decimal: equals=" + first.equals(second) + " compareTo=" + first.compareTo(second)
                + " hash-size=" + hash.size() + " tree-size=" + tree.size());

        List<String> corpus = List.of("", "a", "b", "aa", "bb", "aaa");
        for (String a : corpus) for (String b : corpus) {
            check((lengthThenText.compare(a, b) == 0) == a.equals(b), "equality consistency");
            check(Integer.signum(lengthThenText.compare(a, b)) == -Integer.signum(lengthThenText.compare(b, a)),
                    "comparison sign symmetry");
        }
        System.out.println("PASS " + checks + " checks; fixed keys, no concurrency or performance claim");
    }
}

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-24 使用 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12 复跑这份源码并核对完整输出。真实TreeMap/HashMap/List,固定字符串与BigDecimal;每JDK83检查,每版复跑3次一致。未测可变键、并发或性能。

运行后对照结果
length-only: size=1 previous=10 get(zz)=20 contains(zz)=true
remove(zz): empty=true
length-then-text: keys=[c, aa, bb] contains(zz)=false
list-sort: [c, bb, aa]; size=3
decimal: equals=false compareTo=0 hash-size=2 tree-size=1
PASS 83 checks; fixed keys, no concurrency or performance claim

把“怎样排序”和“什么算同一个键”一起写进测试

给排序规则增加测试时,除了检查前后顺序,还要放入两个业务上不同、首要排序字段却相同的对象,检查是否应同时保留。随后检查等价候选的 get、containsKey 与 remove,避免只验证列表输出。

本例使用不可变字符串和 BigDecimal,在单线程下运行。没有验证可变键、比较器读取外部状态、并发修改、范围视图或性能;这些条件变化后需要单独设计实验。

记住这三点

  • TreeMap 使用比较器或自然顺序识别键,比较为零会共享一个映射位置。
  • thenComparing 可在首要字段相同时继续区分完整键。
  • 排序列表、维护有序映射、按业务规则分组是不同任务。

别把例子用过头

  • 以为 equals 不同就一定能在任何 Map 中同时存在。
  • 只检查插入顺序,不检查查询和删除的等价键。
  • 把只适合显示的粗粒度排序直接用作存储键规则。

自己试一小步

把三个商品名称按长度展示,但要求不同名称都能分别更新库存,你会怎样验证容器选择?

  1. 选择至少两个长度相同、文字不同的名称,先写出预期条目数。
  2. 分别运行只按长度的 TreeMap、带完整文字次级比较的 TreeMap,以及列表排序。
  3. 对一个未写入但长度相同的名称执行查询和删除,记录旧值、条目数与实际命中情况。
  4. 自行增加空字符串和完全重复名称,写断言后保留完整输出。

做到这些,就算完成

  • 说明零比较是否真的代表同一个业务键。
  • 区分新插入和替换旧值。
  • 练习自行作答,不把一种容器当成所有需求的答案。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。