news 2026/9/23 10:57:34

TreeMap源码级拆解:红黑树如何保证有序键值对

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
TreeMap源码级拆解:红黑树如何保证有序键值对

聊到 Java 里的集合框架,HashMap 的出镜率实在太高了,面试八股背了一套又一套。但真到了需要有序键值对的场景,TreeMap 才是那个真正干活的工具。前几天我帮同事排查一个排行榜功能,他每次插入完都要对整个 List 做一次Collections.sort,数据量一大就肉眼可见地卡。我让他换成 TreeMap,插入天然有序,遍历顺序就是排行榜顺序。他补了一句:那底层到底怎么保证有序的?红黑树究竟做了什么?于是就有了这篇 TreeMap 源码级拆解。

这篇文章适合两类人看:一类是准备面试、需要把“红黑树”讲清楚的求职者;另一类是每天都在写业务代码,但想真正搞懂集合原理、提升代码质量和排查问题能力的开发者。我会从红黑树的核心机制讲起,完整拆解 JDK 8 中 TreeMap 的插入、查找、删除、遍历四条主链路,最后再讲讲我实际使用时踩过的坑。整个过程不追求把每个方法逐行背出来,而是要让你读完能自己去看源码、能画出树的变化过程。

1. 为什么单独把 TreeMap 拎出来讲一遍

1.1 我先聊聊自己真正用上 TreeMap 的场景

很多人对 TreeMap 的印象停留在“有序 Map”,但面对 HashMap 和 LinkedHashMap 的时候,又会犹豫到底该用哪个。我最早真正意识到 TreeMap 价值,是在做一个区间查询的需求。

当时有个用户积分体系,积分数值在 0 到 10000 之间,需要根据积分区间映射到不同等级。最直观的写法是一串 if-else 判断,但区间段位多了之后,代码又丑又慢。后来我改用 TreeMap:

TreeMap<Integer, String> levelMap = new TreeMap<>(); levelMap.put(0, "青铜"); levelMap.put(1000, "白银"); levelMap.put(3000, "黄金"); levelMap.put(6000, "铂金"); levelMap.put(9000, "钻石"); // 用户积分 4500 落在什么段位? String level = levelMap.floorEntry(4500).getValue();

写完后我自己都愣了一下,一个floorEntry就把二分查找给做了,复杂度 O(log n)。这其实是 TreeMap 里getFloorEntry这类导航方法的能力,不只是“有序遍历”这么简单。

后来我又在需要自动排序的定时任务调度场景里用了 TreeMap 按时间戳存任务,每次取最早的任务就是firstKey()。这类需求用 HashMap 根本做不了,用外部排序又要维护额外的排序状态,TreeMap 就是最顺手的答案。

1.2 TreeMap 与 HashMap、LinkedHashMap 的核心差异

要真正理解 TreeMap 的定位,最适合的方式是先做一张对比表。我用 Java 8 版本的 JDK 作为参考依据,这也是目前大多数生产环境还在用的版本。

维度TreeMapHashMapLinkedHashMap
底层结构红黑树数组 + 链表/红黑树数组 + 链表/红黑树 + 双向链表
是否有序按键的自然顺序或比较器排序无序按插入顺序或访问顺序
核心操作复杂度O(log n)O(1) 平均O(1) 平均
是否允许 null key不允许允许(hash 为 0 的桶)允许
是否允许 null value允许允许允许
典型场景区间查询、排行榜、有序遍历、自动排序快速存取缓存淘汰(LRU)、需要保持插入顺序

这张表有一个值得展开的点:为什么 TreeMap 不允许 null key,却允许 null value?

原因在于 TreeMap 的排序机制。无论走自然排序还是自定义 Comparator,插入时都要拿 key 去和其他 key 做比较,而null无法参与任何比较运算。即使你写了一个允许 null 的 Comparator,需要非常小心地处理各种比较边界,源码作者为了不把复杂度抛给调用方,直接在入口处限制死了。我后面在“踩坑”部分会具体解释这个约束。

1.3 从选型角度回答“什么时候该用 TreeMap”

如果你正在纠结要不要用 TreeMap,我建议按下面这个思路来判断:

  • 如果你需要按键有序遍历,且这个顺序需要动态维护,首选 TreeMap;
  • 如果你只需要插入顺序,LinkedHashMap 更轻量;
  • 如果你有区间查询(如“找大于某个 key 的最小键”),TreeMap 的ceilingEntryfloorEntrysubMap几乎是量身定制;
  • 如果你的数据量很小(比如几十条),排序成本可以忽略,用不用 TreeMap 都行;
  • 如果你的场景是并发写多读多,别忘了 TreeMap 不是线程安全的,这时候要么加锁,要么换ConcurrentSkipListMap——跳表在并发环境下往往表现更好,这一点我会在第 5 部分详细说。

简单说,TreeMap 的价值不是“比 HashMap 快”,而是在排序这个维度上,它把复杂度从“每次排序 O(n log n)”降到了“维护有序 O(log n)”,同时还能做范围导航。

2. 红黑树核心机制:五个性质如何约束 TreeMap 的行为

2.1 红黑树五性质,先背下来再理解

TreeMap 的底层是红黑树,这是一棵自平衡的二叉搜索树。二叉搜索树本身在极端情况下会退化成链表,插入顺序恰好是递增序列时,查找复杂度会从 O(log n) 退化到 O(n)。红黑树通过“染色 + 旋转”两条手段,保证树始终是近似平衡的。

红黑树有五个性质,我先把标准定义写出来:

  1. 每个节点要么是红色,要么是黑色;
  2. 根节点是黑色;
  3. 每个叶子节点(NIL 节点)是黑色;
  4. 不能有两个连续的红色节点(即红色节点的父节点和子节点必须都是黑色);
  5. 从任一节点到其每个叶子节点的所有路径,都包含相同数目的黑色节点。

第 5 条有人叫“黑高相等”,这是整棵树平衡性的根基。红黑树不是严格意义上的平衡树(不像 AVL 要求左右子树高度差不超过 1),它只要求黑色高度一致,红色节点可以打破高度差,但受到了“不能连续红”的约束,所以整体高度上限被压住了。

2.2 “最长路径不超过最短路径两倍”的推导

红黑树有一条经典结论:最长路径不会超过最短路径的两倍。很多文章直接写出了这条结论,但没解释为什么。这里我用大白话推一遍。

因为性质 5,从根到任意叶子,黑色节点数相同,假设这个黑色节点数为 h。那么:

  • 最短路径,就是全部由黑色节点组成的路径,长度就是 h;
  • 最长路径,由于不能出现连续红色节点,红色节点只能穿插在黑色节点之间,所以一条路径上红色节点的最大数量也就是 h 个(在每个黑节点之间最多插一个红)。

因此最长路径最多是“交替红黑红黑”的状态,长度不超过 2h,也就是最短路径的两倍。两倍的高度差意味着查找路径长度还是 O(log n) 的量级,这就是红黑树在“不追求绝对平衡”的前提下,仍然能保证性能的核心原因。

相比 AVL 树的绝对平衡,红黑树的旋转次数明显更少,因为在插入和删除时,红黑树允许一定程度的“不平衡”,只在违反五条性质时才做修复。天然适合写入频繁的业务场景。

2.3 TreeMap 源码里红黑树的定义方式

打开 JDK 8 的 TreeMap 源码,前面有一大段注释,明确写了“This is a red-black tree implementation”。核心字段就这几个:

private final Comparator<? super K> comparator; private transient Entry<K,V> root; private transient int size = 0; private transient int modCount = 0;

modCount这个字段很关键,它是所有 Java 集合的“并发修改计数器”。后面讲迭代器的时候会专门展开。comparator为 null 时,TreeMap 走自然排序,也就是要求 key 实现Comparable接口。

真正的树节点是内部静态类Entry

static final class Entry<K,V> implements Map.Entry<K,V> { K key; V value; Entry<K,V> left; Entry<K,V> right; Entry<K,V> parent; boolean color = BLACK; Entry(K key, V value, Entry<K,V> parent) { this.key = key; this.value = value; this.parent = parent; } }

注意这里颜色用的是boolean类型,初始默认是黑色。为什么不直接用枚举?因为枚举对象在 HotSpot 里是一个完整的 Java 对象,内存开销远大于一个 boolean 字段。TreeMap 节点数量大时,这个差异会被放大。源码里所有对颜色的操作都抽到了colorOfsetColor这样的方法里,比如:

private static <K,V> boolean colorOf(Entry<K,V> p) { return (p == null ? BLACK : p.color); }

设置成 static 方法处理 null 节点,是因为红黑树里的所有叶子节点在逻辑上都是“黑色 NIL 节点”,代码里用 null 代替 NIL,因此colorOf(null)必须返回黑色。

2.4 为什么不选 AVL 树或跳跃表

既然红黑树既不是绝对平衡,实现又复杂,为什么不直接选 AVL 树,或者干脆像 ConcurrentSkipListMap 一样用跳表?这是我读源码时自己问过的问题。

先说 AVL 树。AVL 要求任意节点的左右子树高度差不超过 1,所以查找效率确实比红黑树更稳定。但它为了维护这种严格平衡,每次插入和删除都可能引发多轮旋转。对 TreeMap 这种 Map 实现来说,写操作(put/remove)和读操作(get)都很多,红黑树在“写多时减少旋转、读多时略多几次比较”之间拿捏得更均衡。

再说跳表。跳表实现简单、并发友好(ConcurrentSkipListMap 就是例子),查找复杂度同样是 O(log n)。但它每个节点要维护多个层级的指针数组,平均每个节点额外占用约 1.33 个指针(按标准跳表的概率分布),而红黑树每个节点只有 left、right、parent 三个指针加一个 boolean,内存密度上红黑树更占优。还有更关键的一点:JDK 里 TreeMap 是从 Java 1.2 就存在的集合,红黑树算法成熟到不能再成熟,替换成跳表的迁移成本收益不划算。所以你看 ConcurrentSkipListMap 是后来加的,没有动 TreeMap 的底层,两条路线并行存在。

3. put() 全链路拆解:插入、父节点查找与旋转修复

3.1 完整插入流程分几步

我建议你直接在 IDE 里打开 TreeMap 源码,跟着下面的顺序看。put完整流程可以拆成三步:

  1. 如果 root 为 null,说明整棵树是空的,直接把新节点当根节点,方法结束;
  2. 从 root 开始做“二叉搜索树插入”,根据比较结果向左或者向右走,直到找到 null 位置,新节点挂在父节点的 left 或 right 上;
  3. 执行fixAfterInsertion,对红黑树第五条性质和“不能连续红”的性质进行修复,最后强制把根节点染黑。

第一步有一个面试里常考的细节,根节点初始化时调用了compare(key, key)。为什么要拿 key 和自己比一次?注释写得很清楚:type (and possibly null) check。这是一次类型安全检查,如果 key 本身是 null,或者 key 没有实现 Comparable 且没有 Comparator,在这里就会直接暴露问题,而不是等到后续真正比较时才报错。

第二步中,JDK 对“有无 Comparator”做了两条并行路径:

Comparator<? super K> cpr = comparator; if (cpr != null) { do { parent = t; cmp = cpr.compare(key, t.key); if (cmp < 0) t = t.left; else if (cmp > 0) t = t.right; else return t.setValue(value); } while (t != null); } else { if (key == null) throw new NullPointerException(); Comparable<? super K> k = (Comparable<? super K>) key; do { parent = t; cmp = k.compareTo(t.key); if (cmp < 0) t = t.left; else if (cmp > 0) t = t.right; else return t.setValue(value); } while (t != null); }

注意看if (cpr != null)这个分支里没有显式的 null key 判断,而是通过cpr.compare(key, key)在根节点逻辑中完成检查。若自定义 Comparator 没对 null 做保护,同样会抛空指针。所以“TreeMap 不允许 null key”这个结论在两种模式下都成立,只是抛出异常的时机和手法有差异。

还有一个细节:当比较结果相等时,TreeMap 直接return t.setValue(value),用新 value 覆盖旧 value,而不会新建节点。这也意味着 TreeMap 的 key 天然具有唯一性,和 HashMap 的语义一致,判定唯一性的标准是 Comparator/Comparable 的比较结果,而不是 equals 方法。这点很多人会踩坑:如果你自定义的比较器里只比较了 partId,那么 partId 相同但其他字段不同的两个对象会被视为同一个 key,后插入的会覆盖先前的 value。

找到插入位置后,核心逻辑如下:

Entry<K,V> e = new Entry<>(key, value, parent); if (cmp < 0) parent.left = e; else parent.right = e; fixAfterInsertion(e); size++; modCount++;

注意modCount++在这里出现了,不管新增还是覆盖,只要有结构性修改,计数器就会增加。

3.2 fixAfterInsertion 的三种场景与源码逻辑

新插入的节点默认是红色。为什么不默认黑色?因为把它染红,性质 5(黑高相等)不会被破坏,你不需要处理整棵树的黑高问题。后续要处理的,就是性质 4(不能连续红)。

fixAfterInsertion的源码值得完整看一遍:

private void fixAfterInsertion(Entry<K,V> x) { x.color = RED; while (x != null && x != root && x.parent.color == RED) { if (parentOf(x) == leftOf(parentOf(parentOf(x)))) { Entry<K,V> y = rightOf(parentOf(parentOf(x))); if (colorOf(y) == RED) { setColor(parentOf(x), BLACK); setColor(y, BLACK); setColor(parentOf(parentOf(x)), RED); x = parentOf(parentOf(x)); } else { if (x == rightOf(parentOf(x))) { x = parentOf(x); rotateLeft(x); } setColor(parentOf(x), BLACK); setColor(parentOf(parentOf(x)), RED); rotateRight(parentOf(parentOf(x))); } } else { Entry<K,V> y = leftOf(parentOf(parentOf(x))); if (colorOf(y) == RED) { setColor(parentOf(x), BLACK); setColor(y, BLACK); setColor(parentOf(parentOf(x)), RED); x = parentOf(parentOf(x)); } else { if (x == leftOf(parentOf(x))) { x = parentOf(x); rotateRight(x); } setColor(parentOf(x), BLACK); setColor(parentOf(parentOf(x)), RED); rotateLeft(parentOf(parentOf(x))); } } } root.color = BLACK; }

我把核心逻辑简化成三种情况,理解这三种情况比背源码重要:

Case 1:叔叔节点是红色。此时父节点和叔叔节点都是红色,祖父节点必须是黑色(否则违反性质 4)。解决方案是把父节点和叔叔节点都染黑,祖父节点染红,然后把“当前节点”上移为祖父节点,继续循环。这一步的本质是把红色上抛,不碰任何旋转。

Case 2:叔叔节点是黑色,且当前节点是“内侧”插入。比如父节点在祖父左边,当前节点却插到了父节点的右边。这时候要先围绕父节点做一次旋转,让当前节点变成“外侧”节点,进入 Case 3。有人称这个是“先转进来,再转出去”。

Case 3:叔叔节点是黑色,且当前节点是“外侧”插入。此时方案很统一:把父节点染黑,祖父节点染红,然后围绕祖父节点做一次旋转。注意旋转完成后,祖父节点变成了原来父节点的位置,而父节点变黑,整条路径黑色节点数保持不变。

这里的核心思路是:能变色解决就不旋转,必须旋转时尽量从祖父节点旋。变色解决不了的时候,就是当前节点、父节点、祖父节点形成了“之”字形,需要先变成“直线型”,再做一次大旋转。

3.3 旋转操作源码,左旋右旋其实是一对镜像

旋转是红黑树最基础的动作。左旋和右旋互为镜像,我以左旋为例拆一下:

private void rotateLeft(Entry<K,V> p) { if (p != null) { Entry<K,V> r = p.right; p.right = r.left; if (r.left != null) r.left.parent = p; r.parent = p.parent; if (p.parent == null) root = r; else if (p.parent.left == p) p.parent.left = r; else p.parent.right = r; r.left = p; p.parent = r; } }

整个过程其实做三件事:

  1. 把 p 的右孩子 r 提上来作为“新的子树根”;
  2. 把 r 原来的左孩子挂到 p 的右孩子位置;
  3. 处理 p 的父节点与 r 之间的父子关系。

右旋就是把 left 和 right 对调后的完全镜像操作。我在最开始学红黑树的时候,总觉得旋转很抽象,后来总结成一句口诀:旋转后,被提升的节点继承原父节点的父节点,原来的父节点变成被提升节点的子节点。你只要记住谁升谁降,指针关系就不会画错。

这里还有一个面试容易问到的点:为什么左旋右旋不会破坏“二叉搜索树顺序”?因为旋转发生在局部,选中的节点始终满足“左小右大”。右旋时,p 的右子树里所有节点都大于 p,而 r 左子树里的节点都介于 p 和 r 之间,挂到 p 的右边依然满足顺序。这就是旋转只调整“结构”不动“顺序”的数学基础。

4. 从 get()、remove() 到迭代器:TreeMap 的其他核心操作

4.1 getEntry:二分查找在树上的映射

TreeMap 的get方法底层是getEntry

final Entry<K,V> getEntry(Object key) { if (comparator != null) return getEntryUsingComparator(key); if (key == null) throw new NullPointerException(); Comparable<? super K> k = (Comparable<? super K>) key; Entry<K,V> p = root; while (p != null) { int cmp = k.compareTo(p.key); if (cmp < 0) p = p.left; else if (cmp > 0) p = p.right; else return p; } return null; }

这就是在二叉搜索树上做二分查找,每次比较决定向左还是向右,走到 null 说明 key 不存在。流程不复杂,但有一个有意思的点:containsKeyget底层复用同一套逻辑。比如containsKey直接调用getEntry(key) != nullremove的第一步也是通过getEntry找目标节点。所以这几个操作的时间复杂度都是 O(log n),而不是像 HashMap 那样平均 O(1)。

另外,TreeMap 还提供了一系列导航方法,它们也依赖这套查找逻辑,比如:

  • ceilingEntry(key):返回大于等于 key 的最小节点;
  • floorEntry(key):返回小于等于 key 的最大节点;
  • higherEntry(key):严格大于 key 的最小节点;
  • lowerEntry(key):严格小于 key 的最大节点。

这些方法是 TreeMap 相比其他 Map 最大的差异化能力。实现逻辑是在二叉搜索树查找的基础上加入对“当前比较结果”的记录,找不到完全相等的 key 时,返回路径上最近的合适节点。比如getCeilingEntry,遍历过程记录最后一个“大于目标 key”的节点,一旦走到空就返回这个记录。玩法很朴素,但非常实用——我上文提区间段位时用的floorEntry就是这个。

4.2 deleteEntry 与 fixAfterDeletion:删除是红黑树里最绕的部分

删除比插入难,难点在于删掉一个黑色节点后,它所在路径的黑高少了一个,可能违反性质 5。

TreeMap 的deleteEntry分三种情况:

  1. 被删节点没有左孩子也没有右孩子,直接删除;
  2. 被删节点只有一个孩子,用这个孩子顶替它的位置;
  3. 被删节点有两个孩子,这时候不能直接删,要找它的后继节点(中序遍历的下一个节点),用后继节点的 key 和 value 覆盖当前节点,然后转而去删除后继节点。因为后继节点一定没有左孩子,可以递归套回情况 1 或 2。

这个“找后继”的动作封装在successor方法里,我后面讲迭代器时会再见到它。

deleteEntry的源码核心如下:

if (p.left != null && p.right != null) { Entry<K,V> s = successor(p); p.key = s.key; p.value = s.value; p = s; } Entry<K,V> replacement = (p.left != null ? p.left : p.right); if (replacement != null) { replacement.parent = p.parent; ... if (p.color == BLACK) fixAfterDeletion(replacement); } else if (p.parent == null) { root = null; } else { if (p.color == BLACK) fixAfterDeletion(p); ... }

注意replacement为 null 时(被删节点是叶子),如果 p 是黑色,需要先对 p 做fixAfterDeletion,再把 p 从树上断开。这里的顺序很关键:修复必须在删除之前做,因为删除后 p 就不在树上了,你无法再围绕它调整颜色和旋转。

fixAfterDeletion的完整源码比插入修复长很多,本质是处理“double black”问题。删掉一个黑色节点后,替代它的节点相当于多背负了一个黑色,这时有四种情况:

  • 兄弟节点是红色;
  • 兄弟节点是黑色,且兄弟的两个孩子都是黑色;
  • 兄弟节点是黑色,且兄弟的左孩子是红色、右孩子是黑色;
  • 兄弟节点是黑色,且兄弟的右孩子是红色。

每种情况对应一套“染色 + 旋转”的组合。我个人读这段源码时的一个经验是:不要直接硬啃所有分支,先在纸上画一个满足红黑树性质的例子,依次用这四种情况套一遍,每套一步就把树重新画出来。我大概花了两个小时才把整条链路的几何变换彻底搞明白,但搞明白之后再看源码,就是一个个“哦原来这里在对应那张图”的感觉。

4.3 迭代器与 successor():从有序遍历看中序的意义

TreeMap 的迭代器输出的 key 一定是升序的(或按 Comparator 排序)。这个顺序是怎么做到的?因为迭代器底层走的是中序遍历

红黑树是二叉搜索树,中序遍历恰好是先左子树、根节点、右子树,输出自然有序。TreeMap 迭代器的nextEntry方法里调用了一个核心函数,就是上一节提到的successor

static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) { if (t == null) return null; else if (t.right != null) { Entry<K,V> p = t.right; while (p.left != null) p = p.left; return p; } else { Entry<K,V> p = t.parent; Entry<K,V> ch = t; while (p != null && ch == p.right) { ch = p; p = p.parent; } return p; } }

successor的逻辑很清晰:

  • 如果当前节点有右子树,下一个节点就是右子树里最左边的节点;
  • 如果没有右子树,就一直向上找,直到找到“自己不在父节点右子树”的那一层,父节点就是后继。

这个函数既是迭代器的核心,也是删除操作中“找后继”的依赖。TreeMap 的firstEntry方法会从根一路走到最左节点,得到整棵树最小的 key;lastEntry则对应最右节点。这些在实现subMapheadMaptailMap的区间遍历时也会被反复使用。

还有一点很多人忽略了:迭代器遍历 TreeMap 的时间复杂度不是 O(n)。因为每次从后继节点往上回溯时,最坏情况可能走 O(log n) 步,整棵树遍历完是 O(n log n) 吗?其实是 O(n)。这个结论来自中序遍历的时间复杂度——每个节点最多被访问常数次。只不过代码层面确实需要一些向上回溯的指针移动,比数组遍历的常数项更大。所以如果你只是要遍历一个有序 Map,TreeMap 是合理的;但如果数据量极大且遍历极频繁,也要评估一下是否值得。

4.4 modCount 与 fail-fast:TreeMap 的线程安全问题

TreeMap 不是线程安全的,这一点在类注释里没有明确写出,但源码里的modCount机制处处都在昭示这一点。

每次结构性修改(put 新增节点、remove 节点)都会执行modCount++,而迭代器构造时会保存当前的modCount快照:

private class EntryIterator extends PrivateEntryIterator<Map.Entry<K,V>> { EntryIterator(Entry<K,V> first) { super(first); } } PrivateEntryIterator(Entry<K,V> first) { modCount = TreeMap.this.modCount; ... }

每次next()前都会检查当前modCount是否和快照一致:

final Entry<K,V> nextEntry() { Entry<K,V> e = next; if (e == null) throw new NoSuchElementException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); ... }

这个设计叫fail-fast——一旦检测到并发修改,立刻抛异常,而不是继续遍历产生不可预期的数据。注意这里针对的是“同一线程修改 + 遍历”或者“多线程同时修改”的情况。单线程下迭代过程中自己往里 put,也会触发这个异常,因为迭代器持有的快照没有更新。

如果确实需要线程安全的 TreeMap,有两个方向:

  • Collections.synchronizedSortedMap(new TreeMap<>()),包装后所有方法都是同步的;
  • ConcurrentSkipListMap,它是线程安全的并发有序 Map,底层是基于跳表的 CAS 操作,读多写多场景下普遍比加锁的 TreeMap 性能好。

5. 源码阅读与实战中容易踩的坑

5.1 自定义 Comparator 的两个典型错误

TreeMap 允许通过构造函数传入自定义 Comparator,但很多人只记住了怎么传,没意识到 Comparator 的一致性直接影响 TreeMap 的正确性。

第一个典型错误是拿“会变化的字段”做比较。比如你按照对象的某个可变属性来排序,属性一变,树结构就乱了。TreeMap 不会感知到 key 内部的变化,它只在你 put、get、remove 时调用 Comparator,一旦树结构本身已经不符合“左小右大”的性质,查找结果就是错的。这是红黑树最隐蔽的坑,比线程安全更隐蔽。

第二个典型错误是 Comparator 实现没有保持自反性。JDK 文档里明确要求 Comparator 和 equals 保持一致,但很多人只重写了 Comparator,没重写 equals;或者写 Comparator 时,只判断了部分字段。前面在讲 put 时提到过,比较结果为 0 就意味着 key 相同,后插入的 value 会覆盖先前的 value。如果你只按 id 比较,但业务里同一个 id 有两条不同的数据,第二条就会莫名其妙把第一条覆盖掉。

我的建议是:自定义 key 时优先把它设计成不可变对象,同时保证 equals、hashCode、compareTo 三者结论一致。这个要求对 TreeMap 来说尤其重要,因为它把“相等”的全部判断都交给了比较器,根本不会去调用 equals。

5.2 null 值处理:key 不能为 null 的真正原因

很多人在面试时会背“TreeMap 不允许 null key,因为要排序”,但如果你只回答到这一步,追问一下就露馅了。

更深一层的原因是:Java 的基础类型里,null 没有自然顺序,如果有 Comparator,null 也不一定有顺序(取决于你 Comparator 的实现)。TreeMap 的设计哲学是“顺序必须确定”,所以它把 null key 直接拒之门外。在无 Comparator 模式下,源码明确写了if (key == null) throw new NullPointerException();在有 Comparator 模式下,通过compare(key, key)做检查,如果比较器没有对 null 做容错,同样会抛异常。

而 null value 是允许的,因为 value 不参与任何比较和排序,存储一个 null value 不影响树的结构。所以你可以写treeMap.put(1, null),但不能写treeMap.put(null, "x")

5.3 subMap/headMap/tailMap 视图的区间陷阱

TreeMap 的视图方法特别实用,但要非常小心它们的边界语义。subMap(fromKey, toKey)是一个左闭右开区间,headMap(toKey)不包含 toKey,tailMap(fromKey)包含 fromKey。这些默认行为经常让新手写出边界差一的问题。

更隐蔽的是,视图不是快照,而是和原 TreeMap 共享数据的视图。通过 subMap 往里 put 数据,会直接写到原 Map;反过来,原 Map 里删掉的数据,视图也看不到了。如果你需要稳定快照,必须自己拷贝一份。

还有一个越界问题。向 subMap 视图里插入一个不在区间内的 key,会抛IllegalArgumentException。我自己的项目里就出现过一次:向headMap(endKey)子视图添加了一个大于等于 endKey 的 key,线上直接抛错。这类问题在测试环境很难发现,因为很多时候区间外数据量不大,不会走到边界。

另外要注意,如果你用没有实现 Comparable 的 key,或者自定义 Comparator 和自然排序混用,subMap 的区间判断也是依赖同一个 Comparator 的。所以视图方法的边界判断逻辑,本质上就是比较器逻辑的延伸。

5.4 调试 TreeMap 的小工具:把树打出来看

阅读和调试红黑树最大的障碍是你“看不见”这棵树。我之前调试一个自定义排序 bug 时,最笨但最有效的办法是写一个递归打印树结构的方法。下面这个工具方法我保留了挺久,分享给大家参考:

public static void printTree(AbstractMap.SimpleEntry<Integer, String> root, int depth) { // 简化版,实际可传入 TreeMap 的 root } public static void printNode(Object node, int depth) { if (node == null) { return; } Class<?> clazz = node.getClass(); try { Object left = clazz.getDeclaredField("left").get(node); Object right = clazz.getDeclaredField("right").get(node); Object key = clazz.getDeclaredField("key").get(node); Object parent = clazz.getDeclaredField("parent").get(node); boolean color = clazz.getDeclaredField("color").getBoolean(node); printNode(left, depth + 1); StringBuilder sb = new StringBuilder(); for (int i = 0; i < depth; i++) { sb.append(" "); } sb.append(key).append(color ? "(黑)" : "(红)"); if (parent != null) { sb.append(" parent=").append(clazz.getDeclaredField("key").get(parent)); } System.out.println(sb); printNode(right, depth + 1); } catch (Exception ignored) { } }

调用方式是利用反射获取 TreeMap 的 root 字段,再传入上面这个方法。虽然颜色没法直观地显示,但结构关系一眼就能看清,尤其是搞明白旋转前后父节点和子节点的挂载关系时特别管用。

如果你不想写反射,更省事的方式是在 IDE 的 Debug 模式里,直接展开 root 节点的字段,一层层看 left/right/parent/color。IntelliJ IDEA 的 Debugger 可以自定义数据视图,把 TreeMap 的 root 渲染成树状结构,调试效率高很多。

6. 我对 TreeMap 源码阅读的一些体会

我在读 TreeMap 源码时遇到过不少挫折,其中大部分都发生在fixAfterDeletion上。后来总结出一个对自己特别有效的读法:先把二叉搜索树的操作(插入、删除、查找)跑通,再单独把红黑树的五条性质写在便签上,然后用小数据集(比如 1 到 10 的插入序列)手动模拟每一步颜色变化和旋转动作。纸上过完一遍之后,再回头看源码,逻辑是顺的。

还有一点想提醒大家,源码注释里经常提到参考了 CLRS(算法导论)的红黑树章节,所以如果哪段代码看不懂,先去翻书里的对应章节,再把书里的伪代码和 JDK 实现对照着看,往往比死抠源码更高效。TreeMap 的实现虽然整体上遵循经典算法,但在细节上做了很多工程化取舍,比如用 boolean 表示颜色、用 null 代替 NIL 节点、把根节点强制染黑等,这些都是 JDK 作者为节省内存和提高可读性做的改进。

最后说一个我现在的选型习惯:遇到排序需求先问自己三句话——数据量级多大?写入频率高还是查询频率高?是否需要范围导航?如果只需要在数据展示前排一次序,直接用 Stream 的 sorted 更简单;如果需要动态维护有序且查询多,TreeMap 很合适;如果并发环境下还要有序,优先考虑 ConcurrentSkipListMap。源码是拿来用的,不是拿来背的——TreeMap 这段源码最大的价值,是帮你建立对有序数据结构整个链路的直觉。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/23 10:55:13

电感计算全解析:从空心线圈到磁芯变压器的公式与实测修正

简介&#xff1a;这份文档面向电子工程、电源设计与电磁器件相关专业的学生及工程师&#xff0c;系统整理了常见电感计算公式&#xff0c;帮助解决空心线圈、多层绕组及变压器线圈电感量估算与验证的问题。资源包内含1个doc文件&#xff0c;约313KB&#xff0c;以图文公式与参数…

作者头像 李华
网站建设 2026/9/23 10:52:19

考试失利后如何与父母沟通及自我重建

1. 面对考试失利&#xff1a;如何与父母沟通1.1 理解父母的期望与担忧父母对子女的期望往往源于关心和爱护。他们可能比你更早开始为这次考试做准备&#xff0c;在心里默默计算着各种可能性。当结果不如预期时&#xff0c;他们的失望可能更多是出于对你未来的担忧&#xff0c;而…

作者头像 李华
网站建设 2026/9/23 10:52:12

OKL4 1.4.1.1微内核实战:从QEMU启动到capability IPC详解

简介&#xff1a;本资源是OKL4微内核早期稳定版本1.4.1.1的完整源码发布包&#xff0c;面向操作系统原理学习者、嵌入式系统开发者及微内核研究者&#xff0c;为理解微内核架构设计、IPC机制与内存管理提供经典且可商用的实践范本。压缩包为tar.gz格式&#xff0c;总大小58.71M…

作者头像 李华
网站建设 2026/9/23 10:50:14

Python实战:基于朴素贝叶斯的垃圾邮件过滤系统设计与优化

简介&#xff1a;一份基于贝叶斯分类算法实现的垃圾邮件过滤软件项目&#xff0c;面向学习Python网络编程、文本挖掘与桌面应用开发的读者。项目包含完整可运行的邮箱客户端&#xff0c;内置IMAP协议收发模块&#xff0c;支持黑白名单自定义、特别关心标记、界面换肤等功能&…

作者头像 李华