HashMap 红黑树化与退化

概述

前文《HashMap 源码深度拆解(JDK 7→8)》完整拆解了 HashMap 的 put 流程------当链表长度 ≥ TREEIFY_THRESHOLD = 8table.length ≥ MIN_TREEIFY_CAPACITY = 64 时,调用 treeifyBin 将链表转为红黑树。这个判断是 JDK 8 对 HashMap 最重要的性能改进:当 hashCode 碰撞严重时,桶从 O(n) 链表退化回 O(log n) 红黑树,有效防御了"哈希碰撞攻击"(恶意构造大量哈希碰撞的 key 导致 HashMap 性能雪崩)。但红黑树不是免费的------TreeNode 的内存占用是 Node 的 1.75 倍,树化需要 O(n log n) 时间。本文将从树化条件出发,完整拆解红黑树在 HashMap 中从"引入"到"退化"的完整生命周期。

"为什么树化阈值是 8 而不是 5 或 10?""为什么退化阈值是 6 而不是 8?""treeifyBin 为什么不直接构建红黑树而要先将 Node 转 TreeNode 链表?""remove 后为什么节点数 > 6 也可能退化?"------这些问题的答案藏在 HashMap 对"性能与内存"和"红黑树 vs 链表"的精确权衡中。本文不从红黑树基础知识讲起,而是从 TreeNode 源码的每一个字段(parent/left/right/prev/red)和每一个方法(treeify/untreeify/balanceInsertion/balanceDeletion)出发,让读者看到红黑树五条性质如何在 Java 代码中落地,以及 JDK 开发者如何通过 8 树化、6 退化、resize 优先扩容、remove 结构检查等多层防御,确保红黑树"只在最必要时存在"。在此基础上,本文将深入到系统设计的维度,探讨如何设计一个能够抵御哈希碰撞攻击、监控树化状态、并可动态降级的高可用 HashMap 使用方案。

文章组织架构

flowchart TD A["1. 树化条件
双重判定与泊松分布"] --> B["2. TreeNode 结构
双结构设计与内存占用"] B --> C["3. treeifyBin
双阶段转换源码拆解"] C --> D["4. TreeNode 核心操作
find / putTreeVal / removeTreeNode"] D --> E["5. 退化机制
resize 退化与 remove 退化"] E --> F["6. 红黑树自平衡
旋转与染色的 Java 实现"] F --> G["7. 系统设计与工程实战
攻击防御、监控与调优"] classDef default fill:#f1f5f9,stroke:#334155,stroke-width:1.5px,color:#1e293b class A,B,C,D,E,F,G default1

分层说明 :模块 1 建立树化的数学基础和工程条件;模块 2 拆解 TreeNode 的字段设计和内存代价;模块 3-4 是全文核心------从 treeifyBin 的双阶段转换到 TreeNode 的查找/插入/删除操作;模块 5 揭示红黑树的退出机制------退化条件;模块 6 深入红黑树自平衡的 Java 实现;模块 7 将视角提升到系统架构层面,探讨如何在实际生产环境中安全、高效地使用 HashMap,并给出可落地的监控和防御方案。关键结论:红黑树在 HashMap 中不是永久存在的------它在链表 ≥ 8 时被引入(且容量足够大),在扩容拆分后 ≤ 6 或结构过于简单时退出。8 树化与 6 退化之间的缓冲区间(7)是 JDK 的精心设计,避免了在阈值边界频繁切换带来的性能抖动。TreeNode 同时维护红黑树和双向链表两套结构,既支持 O(log n) 查找,也支持 O(n) 顺序遍历。而在系统设计层面,我们需要进一步通过监控、限流和降级手段,构建起对"树化"这一代价高昂的优化手段的完整治理体系。


1. 树化条件:双重判定与泊松分布依据

HashMap 并非在链表长度达到某个阈值时无条件树化。它设置了一道双重判定,只有当两道闸门同时打开,树化才会真正发生。这背后的设计思想是"先扩容,后树化",体现了对内存开销和性能收益的精确权衡。

1.1 第一重判定:链表长度 ≥ 8 的泊松分布推导

第一个条件是我们熟知的 TREEIFY_THRESHOLD = 8。但为什么是 8?这个数字并非拍脑袋的决定,而是基于严格的概率计算。在理想情况下,hashCode 的分布遵循泊松分布。JDK 的开发者在其源码注释中给出了当负载因子 loadFactor = 0.75 时,桶中元素数量 k 的概率表。此处的泊松分布参数 λ 约为 0.5,因为 λ = size / capacity,当 size / capacity ≈ 0.75 * capacity / capacity,但源码中以 exp(-0.5) 计算,更精确的平均到达率是基于实际运行中桶的平均填充度约为 0.5。

源码注释的概率表如下(翻译自 HashMap 类注释):

markdown 复制代码
 * 0:    0.60653066
 * 1:    0.30326533
 * 2:    0.07581633
 * 3:    0.01263606
 * 4:    0.00157952
 * 5:    0.00015795
 * 6:    0.00001316
 * 7:    0.00000094
 * 8:    0.00000006
 * more: less than 1 in ten million

可以看到,当链表长度达到 8 时,其概率仅为 0.00000006,也就是不到千万分之一。这意味着,在 hashCode 设计良好且分布均匀的情况下,一个桶中积累 8 个或更多元素的概率极低。如果这种情况真的发生了,那几乎可以断定不是偶然的碰撞,而是 hashCode 函数本身存在问题------例如用户自定义了一个质量极差的 hashCode,或者正在遭受哈希碰撞攻击。

数学推导补充:泊松分布的概率质量函数为 P(X=k) = (λ^k * e^(-λ)) / k!。将 λ=0.5 代入,即可得到上表。注意,这里的 λ 是平均每个桶的元素个数,当负载因子为 0.75 时,由于 resize 的存在,实际的 λ 会略小于 0.75,约等于 0.5,因为哈希表在达到 0.75 容量时就会扩容,扩容后平均每桶元素数会降至 0.375 左右,综合运行周期平均约为 0.5。

设计意图解读:选择 8 作为阈值,是一个"高置信度"的信号。它用极低的误判率来触发一种代价较高的优化手段(树化)。如果阈值设为 5 或 6,则有相对较高的概率(> 0.0015%)在正常随机碰撞下触发不必要的树化,白白浪费内存。如果设为 10 或更高,则在遭遇恶意攻击时,抵御性能雪崩的及时性会变差。

1.2 第二重判定:表容量 ≥ 64 的扩容优先级

第二个条件是 MIN_TREEIFY_CAPACITY = 64。这是常被忽视但至关重要的一环。treeifyBin 方法的第一个逻辑分支是这样的:

java 复制代码
// java.util.HashMap.treeifyBin
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 第一重判断:表容量是否小于 64
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        // 如果是,不树化,而是优先扩容
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 满足双重条件,执行树化
        // ...
    }
}

逻辑非常清晰 :当链表长度已经达到 8,但哈希表的总容量还不足 64 时,JDK 的选择是不树化,而是进行 resize() 扩容。这是一个精妙的工程决策。

  • 原因一:扩容能从根本上解决问题。 小容量是导致碰撞频发的直接原因。容量为 16 时,两个 hashCode 低 4 位相同的 key 必然碰撞;但当容量扩容到 32 时,它们可能因第 5 位不同而被分配到不同的桶。扩容通过增加桶的数量,将集中的元素分散开,直接消灭长链表。这是一个"治本"的手段。
  • 原因二:树化的代价远高于扩容。 扩容是一次性操作,虽然需要重新分配所有节点,但每个节点的迁移只是几个指针的赋值,计算量是 O(n) 且常数因子小。而树化要将链表转换为维护复杂平衡关系的红黑树,涉及多次比较、旋转和染色,同样是 O(n log n) 时间,但 TreeNode 对象的内存开销和后续维护成本都显著更高。在小容量场景下,树化是"用牛刀杀鸡",扩容是性价比最高的方案。

树化条件决策流程图 直观地展示了这个双重判定逻辑:

flowchart TD A[链表插入后长度 >= 8] --> B{table.length >= 64 ?} B -- 否 --> C[调用 resize 扩容] C --> D[重新分配桶
链表可能被拆分变短] B -- 是 --> E[调用 treeifyBin 树化] E --> F[链表转换为红黑树]
  • a) 主旨概括 :流程图展示了 put 操作后触发树化的完整前置检查,核心在于"容量足够大"是树化的硬性前提,否则优先通过扩容尝试分散碰撞。
  • b) 逐元素分解 :起点是当链表插入新节点后长度到达阈值 8;第一个判断菱形代表 treeifyBin 内的容量检查;两条分支分别对应"扩容优先"和"真正树化"两条路径,各自导向不同的结果。
  • c) 设计原理映射 :此图是 MIN_TREEIFY_CAPACITY 设计哲学的直观体现------在小容量下,碰撞是"桶不够用"的问题,应扩容;在大容量下,碰撞是"hashCode 分布差"的问题,才应该树化。
  • d) 工程联系与关键结论树化是"最后的手段",扩容是首选的防御。这个双重判定确保了 HashMap 只在"确实需要"时才引入红黑树的复杂性和高内存开销。在生产中,如果我们发现树化频繁发生,首先应检查初始容量设置是否过小。

1.3 扩容与树化的性能权衡分析

扩容的代价:需要遍历整个 table,对每个节点重新计算索引并迁移。迁移过程中,对于链表节点,JDK 8 通过高低位拆分可以保持相对顺序;对于红黑树节点,则通过 split 方法拆分。扩容是一次性的停顿,但避免了后续每次操作的高复杂度。

树化的代价:treeify 本身 O(n log n);每个 TreeNode 内存增加约 75%;后续每次 putremove 都可能触发旋转和染色操作,虽然这些都是 O(log n),但常数因子远大于链表的 O(1) 指针操作。

结论:当容量较小(< 64)时,扩容不仅能解决碰撞,还能为后续插入提供更宽裕的空间,延迟下一次扩容。因此,resize 的优先级被设定得更高。


2. TreeNode 结构:双结构设计与内存占用

当条件满足,链表被树化后,桶中的数据结构就从一个单向链表变成了一个 TreeNode 构成的复合结构。TreeNode 的设计非常精巧,它同时维护了两套关系:一个用于快速查找的红黑树,和一个用于顺序遍历的双向链表 。这种"双结构"设计是实现高效 splituntreeify 操作的关键。

2.1 继承链与指针系统

TreeNode 的继承链揭示了其字段的层层累加:

  1. HashMap.Node<K,V> :最基本的哈希桶节点。包含 hashkeyvalue 和指向单向链表下一个节点的 next 字段。
  2. LinkedHashMap.Entry<K,V> :继承自 Node。为了维护一个可预测的迭代顺序,增加了 beforeafter 两个指针,形成了贯穿所有桶的一个全局双向链表。
  3. HashMap.TreeNode<K,V> :继承自 LinkedHashMap.Entry。在这个基础上,它重用了 next 指针,并新增了 prev 指针,在当前桶的内部也形成了一个双向链表。同时,增加了 parentleftrightred 四个字段,以构建当前桶内的红黑树。
java 复制代码
// 继承关系与关键字段简化示意
static class Node<K,V> { // HashMap.Node
    final int hash;
    final K key;
    V value;
    Node<K,V> next; // 单向链表或红黑树桶中内部双向链表的 next
}

static class Entry<K,V> extends Node<K,V> { // LinkedHashMap.Entry
    Entry<K,V> before, after; // 维护全局双向链表,用于实现迭代顺序
}

static final class TreeNode<K,V> extends Entry<K,V> { // HashMap.TreeNode
    TreeNode<K,V> parent;  // 红黑树:父节点
    TreeNode<K,V> left;    // 红黑树:左子节点
    TreeNode<K,V> right;   // 红黑树:右子节点
    TreeNode<K,V> prev;    // 桶内双向链表:前驱节点
    boolean red;           // 红黑树:节点颜色(红/黑)
}

TreeNode 双结构图 清晰地展示了这种二维关系:

flowchart TD subgraph DoubleLinkSub["桶内双向链表(通过 prev/next 维护)"] direction LR TN1["TreeNode 1
prev=null, next=TN2"] --> TN2["TreeNode 2
prev=TN1, next=TN3"] --> TN3["TreeNode 3
prev=TN2, next=null"] end subgraph RedBlackSub["桶内红黑树(通过 parent/left/right 维护)"] direction TB Root["TreeNode 2 (root)
parent=null, left=TN1, right=TN3"] -- "left" --> TN1 Root -- "right" --> TN3 TN1 -- "parent" --> Root TN3 -- "parent" --> Root end classDef nodeStyle fill:#f1f5f9,stroke:#334155,stroke-width:1.5px,color:#1e293b class TN1,TN2,TN3,Root nodeStyle
  • a) 主旨概括 :该图描绘了同一个桶中的三个 TreeNode 节点如何同时存在于两条不同的链路上:一条是水平的双向链表,一条是垂直的红黑树。
  • b) 逐元素分解 :上图子图展示了 prevnext 指针形成的双向链关系,这个链表的顺序与节点插入顺序一致。下图子图展示了 parentleftright 指针形成的红黑树父子关系,该树的根节点可能不是链表头(本例中根节点是 TN2,但链表头是 TN1)。
  • c) 设计原理映射 :双向链表是"退化"和"扩容拆分"的快速通道。untreeify 只需遍历链表即可在 O(n) 时间内转换回 Node 链表,而无需对树进行复杂的遍历。split 在扩容时也是直接利用这个双向链表进行高低位拆分,非常高效。红黑树则提供了 O(log n) 的 getput 性能保证。
  • d) 工程联系与关键结论双结构是 HashMap 红黑树实现最精妙的设计之一,它将 O(log n) 的随机访问性能与 O(n) 的顺序处理效率无缝结合,避免了在两种数据结构之间相互转换时的巨大开销。prev 指针的存在使得删除操作时,节点可以从链表中轻松脱离。

2.2 内存占用:TreeNode 的"重量"与 JOL 实证

树化的内存代价是巨大的。我们可以通过 Java Object Layout (JOL) 工具或在 64 位 JVM 上开启压缩指针的情况下进行精确分析。以下计算基于典型的 HotSpot JVM(64位,-XX:+UseCompressedOops 默认开启)。

  • 对象头 :Mark Word 占 8 字节,Klass Pointer 压缩后占 4 字节,合计 12 字节
  • 普通 Node 对象
    • 对象头:12B
    • int hash:4B
    • K key(引用):4B
    • V value(引用):4B
    • Node<K,V> next(引用):4B
    • 合计:28B,对齐到 8 的倍数后为 32 字节
  • LinkedHashMap.Entry 对象 :在 Node 基础上增加 before (4B) 和 after (4B),合计 36B,对齐后 40 字节
  • TreeNode 对象 :在 Entry 基础上增加 parent (4B)、left (4B)、right (4B)、prev (4B) 和 boolean red (1B,但由于内存对齐,实际占用 4B)。新增字段共 20B,加上基类 40B,合计 60B,对齐到 8 的倍数后为 64 字节

但实际中,由于 TreeNode 继承了 Entrybefore/after,而树化时这两个字段并未使用(它们服务于 LinkedHashMap 的全局链表,在 HashMap 中为 null),因此部分分析中会忽略它们,只计算有效负载。即便如此,一个 TreeNode 也至少是 56 字节 左右。对比 Node 的 32 字节,TreeNode 的内存占用约为 1.75~2 倍。这意味着,当一个 HashMap 中若出现大量红黑树桶,其内存占用量会急剧膨胀,可能触发频繁的 GC。

JOL 验证代码

java 复制代码
// 引入 jol-core 依赖
System.out.println(ClassLayout.parseInstance(new HashMap.Node<>(1, "k", "v", null)).toPrintable());
System.out.println(ClassLayout.parseInstance(new HashMap.TreeNode<>(1, "k", "v", null)).toPrintable());

3. treeifyBin:双阶段转换源码拆解

当双重判定条件满足,treeifyBin 方法被调用。这个过程并非一步到位,而是清晰地分为两个阶段。这种设计解耦了"节点类型转换"和"树结构构建"两个关注点,使得代码逻辑更清晰,也更利于维护。

treeifyBin 双阶段转换流程图 描述了这一过程:

flowchart LR A[普通 Node 单向链表] -- 阶段一: replacementTreeNode --> B[TreeNode 双向链表] B -- 阶段二: treeify --> C[红黑树]
  • a) 主旨概括 :流程图展示了 treeifyBin 方法的内部实现分为两个独立的、顺序执行的阶段。
  • b) 逐元素分解 :阶段一的输入是只含 next 指针的 Node 单向链表,输出是增加了 prev/nextparent/left/right 指针但尚未形成树结构的 TreeNode 双向链表。阶段二的输入是 TreeNode 双向链表,输出是一棵平衡的红黑树。
  • c) 设计原理映射 :将类型转换和树构建解耦。treeify 方法可以通过遍历双向链表来获取所有节点,而无需关心节点是如何来的。这种模块化设计使得 untreeify(反向操作)和 split(扩容拆分)可以直接复用或绕过某个阶段。
  • d) 工程联系与关键结论双阶段设计体现了单一职责原则。阶段一是一个简单的、确定性的节点替换,阶段二才是有复杂平衡逻辑的算法。这种分层使得复杂的红黑树构建逻辑可以专注在已准备好的 TreeNode 链表上。

3.1 阶段一:replacementTreeNode --- 类型转换

java 复制代码
// java.util.HashMap.TreeNode
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}

// 在 treeifyBin 中的应用
for (TreeNode<K,V> p = replacementTreeNode(e, null); ; e = e.next) {
    // ...
}

这个方法非常简单,它所做的就是遍历原始的 Node 链表,为每一个 Node 创建一个对应的 TreeNode。新 TreeNodenext 指针直接从原 Nodenext 传递过来,而 prev 指针会在遍历过程中被正确设置,从而无缝地将单向链表升级为双向链表。此时,所有红黑树相关的指针(parentleftright)和颜色(red)都还是默认值(nullfalse)。

深入细节 :在 treeifyBin 中,这个转换循环是:

java 复制代码
for (TreeNode<K,V> p = replacementTreeNode(e, null); ; e = e.next) {
    // 每次循环,p 是当前 TreeNode,e 是原始 Node
    // p.prev = pre; // 设置前驱
    // pre = p;
    // 下一次循环再绑定后继
}

这个循环结束后,一个由 TreeNode 构成的、具有完整 prev/next 关系的双向链表就形成了。

3.2 阶段二:treeify --- 构建红黑树

java 复制代码
final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null;
    // 遍历 this(桶的第一个节点)开头的双向链表
    for (TreeNode<K,V> x = this, next; x != null; x = next) {
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null;
        if (root == null) {
            // 第一个节点作为根节点,必须染黑
            x.parent = null;
            x.red = false;
            root = x;
        } else {
            // 后续节点插入到红黑树中
            K k = x.key;
            int h = x.hash;
            Class<?> kc = null;
            for (TreeNode<K,V> p = root;;) {
                int dir, ph;
                K pk = p.key;
                // 1. 通过比较 hash 值决定方向
                if ((ph = p.hash) > h)
                    dir = -1; // 向左子树
                else if (ph < h)
                    dir = 1;  // 向右子树
                // 2. hash 相等时,尝试通过 Comparable 或 tieBreakOrder 决定方向
                else if ((kc == null && (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    dir = tieBreakOrder(k, pk); // 最后手段

                TreeNode<K,V> xp = p;
                // 3. 找到空位,插入节点
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp;
                    if (dir <= 0)
                        xp.left = x;
                    else
                        xp.right = x;
                    // 4. 调用 balanceInsertion 修复红黑树性质
                    root = balanceInsertion(root, x);
                    break;
                }
            }
        }
    }
    // 5. 确保红黑树的根节点是桶中的第一个节点
    moveRootToFront(tab, root);
}

treeify 方法的逻辑核心是从双向链表的头开始,迭代地将每个 TreeNode 插入到一棵不断增长的红黑树中。插入方向的判定严格遵循 hash 值的大小关系,当 hash 冲突时,会尝试使用 key 的 Comparable 实现,如果仍然无法确定,则使用 tieBreakOrderSystem.identityHashCode)作为最后裁决,确保即使 hash 碰撞也能建立确定性的排序。每次插入新节点后,都会立即调用 balanceInsertion 来对树进行旋转和染色,确保其始终满足红黑树的五条性质,这保证了树在构建过程中的自平衡。

tieBreakOrder 解析 :当两个 key 的 hash 相同,且都不实现 Comparable 或比较结果为 0 时,该方法通过比较两个对象的 identityHashCode(即 System.identityHashCode(k))来建立一个任意但确定的顺序。这保证了即使对于 equals 为 true 的相同 key(这种情况会被 put 覆盖,不会进入这里)或 hash 碰撞的 key,也能确定一个方向,避免无限循环。


4. TreeNode 核心操作:find / putTreeVal / removeTreeNode

HashMap 的红黑树并非只读的静态结构,它在 getputremove 时会被动态地查询和修改。TreeNode 内部封装了这些核心操作,每个操作都充分利用了双结构的优势。

4.1 查找:find

find 方法是 get 操作的底层实现,逻辑与 treeify 的插入路径查找一致。

java 复制代码
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
    TreeNode<K,V> p = this; // 从当前节点开始
    do {
        int ph, dir; K pk;
        TreeNode<K,V> pl = p.left, pr = p.right, q;
        // 1. 比较 hash,小则左,大则右
        if ((ph = p.hash) > h)
            p = pl;
        else if (ph < h)
            p = pr;
        // 2. hash 相等,且 key 引用相等或 equals,则命中
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))
            return p;
        // 3. hash 相等但 key 不等,根据子树情况决定搜索路径
        else if (pl == null)
            p = pr; // 左子树为空,只能走右边
        else if (pr == null)
            p = pl; // 右子树为空,只能走左边
        // 4. 左右子树都在,通过 Comparable 或 tieBreakOrder 决定方向
        else if ((kc != null || (kc = comparableClassFor(k)) != null) &&
                 (dir = compareComparables(kc, k, pk)) != 0)
            p = (dir < 0) ? pl : pr;
        else
            // 5. 最后手段:在右侧子树中递归查找
            if ((q = pr.find(h, k, kc)) != null)
                return q;
            else
                p = pl;
    } while (p != null);
    return null;
}

该方法在 O(log n) 时间内完成查找,只有在极端情况下(大量 key 的 hash 相同且不可比较),才会退化为对左右子树的递归搜索。注意第 5 步的设计:当无法通过 hash 和 Comparable 决定方向时,它优先搜索右子树,如果未找到再回溯搜索左子树。这是一种"试探性"查找,确保在缺少排序线索时仍然能覆盖全树。

4.2 插入/更新:putTreeVal

putTreeVal 结合了 find 的查找和 treeify 的插入逻辑,是 put 方法遇到红黑树桶时的调用目标。

java 复制代码
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
                               int h, K k, V v) {
    // ... 变量初始化 ...
    TreeNode<K,V> root = (parent != null) ? root() : this;
    for (TreeNode<K,V> p = root;;) {
        // ... 1. 查找过程,逻辑同 find ...
        // ... 找到空位或已存在节点 ...
        // 2. 如果 key 已存在,返回旧节点 (外层进行 value 替换)
        if (e != null) {
            return e;
        }
        // 3. key 不存在,创建 TreeNode,插入父节点下
        TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
        if (dir <= 0)
            xp.left = x;
        else
            xp.right = x;
        // 同时维护双向链表:将 x 插入到 xp 和 xpn 之间
        x.prev = xp;
        x.next = xpn;
        if (xpn != null)
            xpn.prev = x;
        // ...
        // 4. 自平衡并确保根节点在桶首
        moveRootToFront(tab, balanceInsertion(root, x));
        return null;
    }
}

这里体现了双结构的紧密协作:新节点在作为红黑树节点被插入到正确的左/右子树位置时,也同时被正确地插入到了桶内的双向链表中(通过设置 prevnext)。注意 moveRootToFront 的调用,它确保了桶的首节点(tab[i])始终是红黑树的根,这对于后续的 get 操作(直接检查 first instanceof TreeNode)至关重要。

4.3 删除:removeTreeNode

removeTreeNode 是最复杂的状态变更操作,因为它需要同时维护双向链表和红黑树,并且是退化逻辑的触发点之一。

java 复制代码
final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab,
                          boolean movable) {
    // 1. 从双向链表中解除自己
    // ... 处理 prev, next 指针 ...
    // 2. 开始处理红黑树部分
    if (root == null || root.right == null ||
        (rl = root.left) == null || rl.left == null) {
        // 3. 退化检查!如果红黑树太小或结构退化,直接转回链表
        tab[index] = first.untreeify(map);  // too small
        return;
    }
    // 4. 在红黑树中寻找替代节点并删除
    TreeNode<K,V> p = this, pl = left, pr = right, replacement;
    if (pl != null && pr != null) {
        // 寻找后继节点 s (successor),交换位置
        TreeNode<K,V> s = pr, sl;
        while ((sl = s.left) != null) // find successor
            s = sl;
        boolean c = s.red; s.red = p.red; p.red = c; // swap colors
        // ... swap pointers ...
    }
    // 5. 从树中物理删除节点 p
    if (replacement != p) {
        // ... 将 p 从其父节点断开,并让 replacement 接替 ...
    }
    // 6. 如果删除了一个黑节点,执行删除后自平衡
    if (p.red)
        root = balanceDeletion(root, replacement);
    // 7. 确保根节点在桶首
    if (movable)
        moveRootToFront(tab, root);
}

注意,退化检查(第 3 步)发生在真正的树节点删除之前。这意味着即使桶中还有超过 UNTREEIFY_THRESHOLD 个节点,只要红黑树的结构变得过于简单(如根节点的左孙子为空),它也会被判定为不值得维护树结构,直接退化回链表。这是一种 "结构性劣化"检测,比单纯的数量判断更为敏锐。


5. 退化机制:resize 退化与 remove 退化

红黑树的存在是为了解决极端碰撞下的性能问题,当这个问题不再存在或即将消失时,就应该让位给更简单、内存更友好的链表。HashMap 提供了两条退化路径,并辅以一个精心设计的缓冲区间来防止抖动。

退化触发路径图 汇总了所有可能的退化场景:

flowchart TD A["红黑树桶"] -- "resize 扩容" --> B{"拆分后节点数 <= 6 ?"} B -- "是" --> C["调用 untreeify
退化回链表"] B -- "否" --> D["保持红黑树
(可能重新树化)"] A -- "remove 删除节点" --> E{"红黑树结构检查
root/left/right/left.left 为 null ?"} E -- "是" --> C E -- "否" --> F["保持红黑树"] classDef default fill:#f1f5f9,stroke:#334155,stroke-width:1.5px,color:#1e293b class A,B,C,D,E,F default1
  • a) 主旨概括:该图揭示红黑树退化为链表存在"数量触发"和"结构触发"两种完全独立的机制。
  • b) 逐元素分解 :上方路径展示了由 resize 扩容引起的、以 UNTREEIFY_THRESHOLD 为标准的数量性退化。下方路径展示了由 remove 引起的、以树的完整性为标准的结构性退化,它不关心节点总数,只关心树形是否已退化为"伪链表"。
  • c) 设计原理映射:数量性退化防止了"大树"在拆分后变成"小树"仍占用内存。结构性退化则更为激进,它防止了"节点数多但树形已退化(如极度不平衡)"这种劣化红黑树的存在,一旦发现树的"根"或"根之子"缺失,立即放弃治疗。
  • d) 工程联系与关键结论退化的两种路径共同构成了一个"安全网",确保HashMap的内存和性能在任何动态变化下都能保持最优化。尤其是结构性退化,是防御频繁删除操作导致树性能劣化的"最后一道防线"。

5.1 UNTREEIFY_THRESHOLD = 6 与缓冲区间

UNTREEIFY_THRESHOLD 被设定为 6,而不是树化阈值 8。这个 8 → 6 的缓冲区间 是 JDK 的一个精心设计,旨在避免"抖动"(thrashing)。

8 树化 ↔ 6 退化缓冲区间示意图

flowchart LR subgraph SubList["链表区域"] L["0-6 个节点
稳定为链表"] end subgraph SubBuffer["缓冲区域"] B["7 个节点
不变,维持原样"] end subgraph SubTree["红黑树区域"] T["8 个及以上
稳定为红黑树"] end L -->|"增加至7个"| B B -->|"增加至8个"| T T -->|"减少至7个"| B B -->|"减少至6个"| L classDef subListStyle fill:#e0f2fe,stroke:#0284c7,stroke-width:2px classDef subBufferStyle fill:#f1f5f9,stroke:#475569,stroke-width:2px classDef subTreeStyle fill:#dcfce7,stroke:#16a34a,stroke-width:2px classDef nodeStyle fill:#ffffff,stroke:#cbd5e1,stroke-width:1.5px,color:#1e293b class SubList subListStyle class SubBuffer subBufferStyle class SubTree subTreeStyle class L,B,T nodeStyle
  • a) 主旨概括:该图显示了当桶中元素数量在 6、7、8 之间变化时,数据结构的稳定状态。
  • b) 逐元素分解:有三个状态区间:"0-6" 稳定为链表;"8+" 稳定为红黑树;"7" 是一个缓冲地带,既不会从链表树化,也不会从红黑树退化。箭头表明元素数量变化时,数据结构并不随之立即切换,只有在超出缓冲区间后才会转换。
  • c) 设计原理映射 :设想如果树化阈值和退化阈值都是 8,那么一个桶在元素数量在 8 附近波动时(例如因插入和删除在 7、8、9 间反复),就会反复执行开销巨大的 treeifyuntreeify 操作,导致严重的性能抖动。缓冲区间的存在,使得数据结构获得了"磁滞"效应,一旦建立,就需要更显著的数量变化才会被撤销。
  • d) 工程联系与关键结论这个 8↔6 的缓冲设计是工程中对理论的一个优雅修正。它用简单的数值差解决了"临界点抖动"这个普遍存在于自适应系统设计中的难题,体现了 JDK 开发者深厚的工程素养。在我们的系统设计中,也应借鉴这种"非对称阈值"的思想来避免频繁的模式切换。

5.2 untreeify:逆向转换

untreeify 方法执行与 treeify 相反的操作。它遍历桶内的双向链表,为每个 TreeNode 创建一个普通的 Node,并将它们用单向链表(next 指针)串联起来。这个过程简单且高效,时间复杂度为 O(n)。由于双向链表的存在,这个操作无需接触复杂的树结构,直接线性遍历即可完成。源码中,它会从当前节点开始,沿着 next 指针遍历,对每个 TreeNode 调用 replacementNode(与 replacementTreeNode 相反)创建一个新的 Node,并将它们串接成单向链表。


6. 红黑树自平衡:旋转与染色的 Java 实现

红黑树能保证 O(log n) 性能的关键在于其自平衡机制。HashMap 的 TreeNode 内部,通过四个方法实现了这一套《算法导论》中的经典算法。本节将逐行拆解其实现,并映射到红黑树的五大性质。

6.1 红黑树五大性质与 Java 实现的映射

Java 实现严格遵循了红黑树的数学定义:

  1. 节点非红即黑 :由 boolean red 字段保证。
  2. 根节点为黑treeifybalanceInsertion 等操作最后都会执行 root.red = falsemoveRootToFront 也会确保 first.red = false
  3. 叶子节点(NIL)为黑 :在 Java 中,null 被视为黑色节点,所有叶子都是 null,这在旋转和染色代码中通过 null 检查隐式处理。
  4. 红色节点的子节点必为黑 :由 balanceInsertion 循环中的修复逻辑保证,循环的核心条件就是 xp.red(父节点为红)。
  5. 任意节点到叶子节点的简单路径包含相同数量的黑色节点:这是插入和删除修复算法的核心约束,通过旋转和染色来恢复被破坏的黑高一致性。

6.2 旋转:rotateLeft 和 rotateRight

旋转是调整树结构的基本操作,旨在改变局部平衡性而不破坏二叉搜索树的性质。

红黑树旋转操作图 展示了左旋和右旋如何改变节点关系:

flowchart TD subgraph 右旋 Right Rotate P_before[P] -- parent --> Q_before[Q] Q_before -- left --> A_before[A] Q_before -- right --> B_before[B] P_after[Q] -- parent --> Q_after[P] P_after -- left --> A_after[A] P_after -- right --> B_after[B] end subgraph 左旋 Left Rotate X_before[X] -- parent --> Y_before[Y] Y_before -- left --> C_before[C] Y_before -- right --> D_before[D] X_after[Y] -- parent --> Y_after[X] X_after -- left --> C_after[C] X_after -- right --> D_after[D] end
  • a) 主旨概括:此图展示了左旋和右旋前后,相关节点(P与Q,X与Y)及其子树(A与B,C与D)的父子关系和左右子树归属的变化。注意旋转中"谁变成谁的子树"的关键变化。
  • b) 逐元素分解:以右旋为例,旋转前 P 是 Q 的父节点,Q 是右孩子。旋转后,Q 成为新的局部根节点,P 成为 Q 的左孩子,Q 原来的左子树 B 变为 P 的右子树。左旋是其镜像操作。每个节点的左右孩子关系发生了交换,但中序遍历顺序不变。
  • c) 设计原理映射:旋转操作保证了在改变节点层级关系时,中序遍历的顺序(P→A→Q→B)保持不变,从而维护了二叉搜索树的有序性。同时,它通过改变树的结构,为解决"黑高不平衡"和"连续红节点"问题提供了手段。
  • d) 工程联系与关键结论rotateLeftrotateRight 是红黑树自平衡算法的基础原语。它们的 Java 实现完全聚焦于指针的重新指向,不涉及任何 key 的比较或移动,因此是非常轻量级的 O(1) 操作。代码中大量的 null 检查确保了在各种边界条件下的鲁棒性。

源码逐行解读 - rotateLeft

java 复制代码
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
                                      TreeNode<K,V> p) {
    TreeNode<K,V> r, pp, rl;
    // p 不能为 null,且必须有右孩子 r
    if (p != null && (r = p.right) != null) {
        // 1. r 的左子树 rl 移交为 p 的右子树
        if ((rl = p.right = r.left) != null)
            rl.parent = p;
        // 2. r 接管 p 的父节点 pp
        if ((pp = r.parent = p.parent) == null)
            // 如果 p 原是根,则 r 成为新根,并染黑
            (root = r).red = false;
        else if (pp.left == p)
            pp.left = r;  // p 是左孩子
        else
            pp.right = r; // p 是右孩子
        // 3. p 成为 r 的左孩子
        r.left = p;
        p.parent = r;
    }
    return root;
}

每一步都伴有详细的 null 检查,确保在 rl 为空或 p 是根节点时的正确性。rotateRight 是其镜像,将上述代码中的 leftright 互换即可。

6.3 插入修复:balanceInsertion

插入一个红色节点(默认是红色,x.red = true)可能破坏性质 4(红色节点不能有红色子节点)。balanceInsertion 通过一个循环,自底向上地修复这个问题,直到根节点。它主要处理三种情况,其逻辑完全对应《算法导论》:

  1. 叔叔节点是红色(Case 1) :将父节点、叔叔节点染黑,祖父节点染红,然后将当前关注点 x 上移至祖父节点,继续循环。
  2. 叔叔节点是黑色,且当前节点是父节点的右孩子(LR/RL型,Case 2) :对父节点进行左旋/右旋,将结构转换为情况 3,此时 x 变为原先的父节点。
  3. 叔叔节点是黑色,且当前节点是父节点的左孩子(LL/RR型,Case 3):将父节点染黑,祖父节点染红,然后对祖父节点进行右旋/左旋。此操作完成后,红黑树性质全部恢复,循环结束。
java 复制代码
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
                                            TreeNode<K,V> x) {
    x.red = true; // 新插入节点必须为红
    for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
        // 情况0: x 是根,染黑返回
        if ((xp = x.parent) == null) { x.red = false; return x; }
        // 情况0: 父节点是黑,无需调整
        else if (!xp.red || (xpp = xp.parent) == null) return root;
        
        // 父节点是祖父节点的左孩子
        if (xp == (xppl = xpp.left)) {
            // 情况1: 叔叔节点 xppr 存在且为红色
            if ((xppr = xpp.right) != null && xppr.red) {
                xppr.red = false;
                xp.red = false;
                xpp.red = true;
                x = xpp; // 关注点上升
            } else {
                // 情况2: 叔叔黑色,且 x 是父的右孩子 (LR型)
                if (x == xp.right) {
                    root = rotateLeft(root, x = xp);
                    xpp = (xp = x.parent) == null ? null : xp.parent;
                }
                // 情况3: 叔叔黑色,且 x 是父的左孩子 (LL型)
                if (xp != null) {
                    xp.red = false;
                    if (xpp != null) {
                        xpp.red = true;
                        root = rotateRight(root, xpp);
                    }
                }
            }
        } else { // 父节点是祖父节点的右孩子,对称处理
            if ((xppl = xpp.left) != null && xppl.red) {
                xppl.red = false;
                xp.red = false;
                xpp.red = true;
                x = xpp;
            } else {
                if (x == xp.left) {
                    root = rotateRight(root, x = xp);
                    xpp = (xp = x.parent) == null ? null : xp.parent;
                }
                if (xp != null) {
                    xp.red = false;
                    if (xpp != null) {
                        xpp.red = true;
                        root = rotateLeft(root, xpp);
                    }
                }
            }
        }
    }
}

6.4 删除修复:balanceDeletion

删除一个节点比插入复杂得多。当被删除的节点是黑色时,会破坏性质 5(黑高一致性)。balanceDeletion 方法负责修复,其核心思想是将"缺少一个黑色"的矛盾向上传递,直到找到一个红色节点可以填补这个黑色缺口,或者矛盾到达根节点。这涉及兄弟节点颜色、兄弟的子节点颜色等多种复杂情况的组合,共 8 种 Case(对称的两大类)。HashMap 的实现是《算法导论》的经典伪代码的精确 Java 翻译,由于篇幅所限,此处不逐行展开,但其结构清晰:通过 while (x != root && isBlack(x)) 循环,根据兄弟节点 sib 及其子节点的颜色,通过旋转和染色将"黑高少一"的矛盾逐步上移。

6.5 moveRootToFront

这是 HashMap 特有的一个操作,用于解决扩容后树根位置变迁的问题。因为扩容后新的索引计算是基于 (newCap - 1) & hash,红黑树的根节点可能不再是桶中的第一个节点。

java 复制代码
static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
    // ...
    if (root != first) {
        // ... 将 root 从双向链表中断开 ...
        // ... 将 root 插入到桶的最前端 (first 之前) ...
        tab[index] = root; // 确保 tab[index] 是根
    }
}

这个方法确保了 tab[i] 始终指向红黑树的根,这是 HashMap 中很多优化(如 get 时先检查 first 是否为 TreeNode)能够正确工作的前提。


7. 系统设计与工程实战:攻击防御、监控与调优

理解了红黑树化的原理后,我们必须将其应用于系统设计层面。在生产环境中,HashMap 的树化不仅仅是底层数据结构的切换,更可能关联到服务的稳定性、内存管理和安全防御。

7.1 积极防御:抵御哈希碰撞攻击

在 JDK 7 及以前,恶意攻击者可以精心构造一系列 String 或自定义对象,使其拥有相同的 hashCode 但彼此不 equals,然后通过 HTTP 请求参数等形式发送给服务器。这些参数被解析后放入同一个 HashMap 中,就会形成超长链表。查找或插入这些 key 时,CPU 会陷入 O(n) 的链表遍历,导致 CPU 占用瞬间飙升,形成 DOS(Denial of Service)攻击。

JDK 8 的树化机制是抵御这种攻击的一道天然屏障。即便攻击者成功制造了大量 hash 碰撞,其所在的桶也会在达到阈值后由链表转化为红黑树,查找性能从 O(n) 恢复为 O(log n)。对于 n=10000,查找次数从平均 5000 次降至约 14 次,攻击效果被成数量级地削弱。

防御架构设计

flowchart TD subgraph AttackSub["攻击检测与防御系统"] A["HTTP 请求进入"] --> B{"参数解析"} B --> C["放入 HashMap"] C --> D{"监控指标异常?"} D -- "是" --> E["触发告警"] E --> F["限流/熔断"] F --> G["清洗攻击流量"] D -- "否" --> H["正常业务处理"] end subgraph MonitorSub["监控维度"] I["JMX 指标采集"] --> J["HashMap size"] I --> K["树化桶数量"] I --> L["GC 频率与耗时"] end C --> I L --> D classDef attackSub fill:#eef2ff,stroke:#6366f1,stroke-width:2px classDef monitorSub fill:#fef3c7,stroke:#d97706,stroke-width:2px classDef nodeStyle fill:#ffffff,stroke:#cbd5e1,stroke-width:1.5px,color:#1e293b class AttackSub attackSub class MonitorSub monitorSub class A,B,C,D,E,F,G,H,I,J,K,L nodeStyle
  • a) 主旨概括:该架构图展示了一个完整的防御体系,从攻击流量进入,到放入 HashMap,再到监控指标检测、告警和限流的全过程。
  • b) 逐元素分解:攻击流量经过参数解析后放入共享的 HashMap;监控系统通过 JMX 实时采集 HashMap 的 size、树化桶数量和 GC 指标;当树化桶数量或 GC 频率异常飙升时,触发告警,并执行限流或熔断,阻断攻击。
  • c) 设计原理映射:此设计遵循"纵深防御"原则。第一层是 JDK 自带的树化机制,保证极限碰撞下的性能;第二层是应用层的监控和限流,防止树化带来的内存膨胀拖垮整个服务。
  • d) 工程联系与关键结论树化解决了性能问题,但没解决内存问题。大量红黑树节点仍会导致内存飙升和 GC 频繁。因此,生产环境必须辅以监控和流量控制,形成立体防御。

7.2 反模式与排查:糟糕的 hashCode 导致频繁树化

故障排查实战分析(完整版)场景 :线上服务的某全局 HashMap 频繁触发 Full GC,Heap Dump 分析发现大量 HashMap.TreeNode 对象。经查,该 HashMap 的 key 是用户自定义类 UserBehavior,其 hashCode 实现极差(几乎都返回相同的值)。

(a) 为什么大量 TreeNode 会导致 Full GC? TreeNode 的内存占用是普通 Node 的约 1.75 倍。当大量桶被树化后,整个 HashMap 的内存占用量急剧膨胀。假设原 HashMap 有 10 万个 Node 对象,内存约 32MB;若全部树化,将膨胀至约 56MB。对于更大的数量级,内存翻倍,直接挤压新生代,使对象过早晋升至老年代,导致老年代迅速填满,频繁触发 Full GC。同时,GC 在遍历老年代中的巨型对象图时,停顿时间也会显著增加。

(b) 如何在保留原有 key 类的前提下优化? 采用 Wrapper(包装器)模式 对 key 进行二次哈希:

java 复制代码
public class SafeKey implements Comparable<SafeKey> {
    private final UserBehavior data;
    private final int hash;

    public SafeKey(UserBehavior data) {
        this.data = data;
        // 使用高质量的散列函数,如 MurmurHash 或复用 HashMap 的扰动函数
        this.hash = spread(data.hashCode());
    }

    private static int spread(int h) {
        return (h ^ (h >>> 16)) & 0x7fffffff;
    }

    @Override public int hashCode() { return hash; }
    @Override public boolean equals(Object o) { 
        // ... 比较 data 
    }
    @Override public int compareTo(SafeKey o) {
        return Integer.compare(this.hash, o.hash);
    }
}

如果无法修改 key 的插入方式,可以考虑限制 HashMap 容量并启用 LRU 淘汰 (如使用 LinkedHashMapremoveEldestEntry),防止元素无限制增长。

(c) 如何通过 JVM 参数和监控提前发现此类问题?

  • JVM 参数 :添加 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log,分析 GC 日志中 Full GC 的频率和耗时。
  • JMX 监控指标
    • java.lang:type=Memory:关注 HeapMemoryUsage,特别是 Old Gen 的使用率。
    • java.lang:type=GarbageCollector,name=ConcurrentMarkSweepG1 Old Generation:关注 CollectionCountCollectionTime
  • 自定义指标 :通过反射或字节码增强,定期上报 HashMapTreeNode 桶的比例和总节点数。
  • 在线诊断工具
    • 使用 Arthas 的 vmtoolheapdump 分析对象分布。
    • 使用 memory 命令统计 HashMap$TreeNode 实例数量。
    • 使用 watch 命令监控 java.util.HashMap treeifyBin 方法的调用次数,若频率异常增高,则表明哈希碰撞严重。

故障排查流程时序图

flowchart TD A[监控告警: GC 频繁] --> B[获取 Heap Dump] B --> C[MAT/JProfiler 分析
发现大量 TreeNode] C --> D{定位到特定 HashMap} D --> E[分析 Key 的 hashCode 实现] E --> F{hashCode 质量?} F -- 差 --> G[代码修复: 包装器/二次哈希] F -- 好 --> H[检查容量与负载因子] G --> I[灰度上线验证] I --> J[监控指标恢复正常]
  • a) 主旨概括:该流程展示了从监控告警到定位、修复、验证的完整排查路径。
  • b) 逐元素分解:起始于 GC 频繁告警,通过 Heap Dump 和 MAT 工具分析内存占用,定位到具体的 HashMap 和 Key 类,然后评估其 hashCode 质量。若质量差,进行代码修复;若质量好,则可能是容量设置不当,需调整初始容量或负载因子。修复后灰度上线,验证 GC 指标恢复。
  • c) 设计原理映射:排查流程遵循"性能问题→内存分析→代码审查→设计优化"的标准路径。强调可观测性在先,先看清问题再动手。
  • d) 工程联系与关键结论树化导致的 GC 问题往往是"慢性病",初期不易察觉。建立完善的 JVM 监控和业务指标(如树化桶数量)是预防此类问题的基石。代码层面,对任何作为 HashMap key 的自定义类,必须严格审查其 hashCode 的分布均匀性。

7.3 系统设计实践:HashMap 使用规范与降级策略

为了最大化地利用 HashMap 的性能并规避其风险,在生产系统设计中应遵循以下规范:

  1. 预估容量并合理初始化new HashMap<>(expectedSize / 0.75 + 1),避免频繁扩容。
  2. key 设计准则
    • 必须同时重写 equalshashCode,且 hashCode 应均匀分布。
    • 对于作为 key 的类,尽可能实现 Comparable,以便在树化时能高效比较,避免退化到 tieBreakOrder
  3. 防御性编程
    • 对外部输入作为 key 的 HashMap,应限制其最大容量,或使用 Collections.unmodifiableMap 防止恶意注入。
    • 对于可能遭受攻击的场景,可以封装一个 SafeHashMap,内部监控树化事件,并上报指标。
  4. 降级与限流
    • 当检测到 HashMap 树化桶比例超过阈值(如 10%)时,可以动态切换到 ConcurrentHashMap(其分段锁机制可进一步分散风险)或限流拒绝新请求。
    • 可以设计一个 AdaptiveMap,在运行时动态评估,当碰撞严重时自动将 key 进行二次哈希(如先 murmur3 再放入 HashMap)。

好的,我将根据您的要求重写"面试高频专题"部分。这部分将独立于正文,包含 10 道以上核心面试题,每题均附带详尽的、深度的解析。其中系统设计/故障排查题将包含架构图、业务流程和时序图,以满足更高的完整性和专业度要求。


面试高频专题

1. HashMap 中链表转红黑树的条件是什么?为什么需要两个条件而不是一个?

解析

HashMap 链表转为红黑树需要同时满足两个条件:

  1. 链表长度达到树化阈值TREEIFY_THRESHOLD = 8。即单个桶中节点数量 ≥ 8。
  2. 哈希表容量达到最小树化容量MIN_TREEIFY_CAPACITY = 64。即 table.length ≥ 64

为什么需要两个条件?

这是"先扩容,后树化"设计思想的体现。当桶中冲突严重时,首先应怀疑是容量不足导致桶分配不均。如果表容量很小(如默认 16),即使某个桶中链表长度达到 8,也很可能只是因为桶的总数太少,大量元素被迫挤在少数桶中。这种情况下,扩容resize)可以将桶数量翻倍,元素被重新散列到更多的桶中,链表自然缩短。扩容的代价是 O(n),但常数因子小,且能从根本上提升整体均匀性。

只有当容量已经足够大(≥ 64),但仍有桶的链表长度达到 8,才能断定这不是容量问题,而是 hashCode 分布本身的缺陷,或者是遭受了哈希碰撞攻击。此时才需要"下猛药"------将链表结构升级为红黑树,以 O(log n) 的查找复杂度抵御极端碰撞。双重条件防止了在小容量下发生不必要的、代价高昂的树化,体现了 JDK 对性能与内存的精细权衡。

2. 为什么树化阈值是 8 而不是其他数字?退化阈值为什么是 6 而不是 8?

解析

树化阈值定为 8 是基于泊松分布的精确概率计算。在理想随机哈希下,桶中元素数量 k 服从参数 λ ≈ 0.5 的泊松分布。JDK 源码注释给出的概率为:

k 概率
0 0.6065
1 0.3033
2 0.0758
3 0.0126
4 0.00158
5 0.000158
6 0.0000132
7 0.00000094
8 0.00000006

桶中有 8 个节点的概率仅为 6×10⁻⁸,小于千万分之一。如此低的概率意味着:一旦发生,几乎可以断定不是随机碰撞,而是哈希函数的质量问题或恶意攻击。选择 8 作为阈值,能以极低的误判率(千万分之一)触发树化这种"重武器",避免了对正常随机碰撞的过度反应。如果取 5 或 6,误判率会高两个数量级,造成不必要的内存浪费。

退化阈值定为 6 而不是 8,是为了在树化阈值和退化阈值之间设置一个 缓冲区间 (7)。如果两者都是 8,当一个桶的元素数量在 8 上下波动时,会频繁发生"链表 → 红黑树 → 链表"的反复转换。treeifyuntreeify 操作开销巨大,这种"抖动"会严重拉低性能。设定 6 为退化阈值,意味着:

  • 当节点数从 7 增到 8 时,触发树化。
  • 当节点数从 8 降到 7 时,仍保持红黑树。
  • 只有当节点数进一步降到 6 或更少时,才退化回链表。

这个 8↔6 的非对称设计,给予了一次"迟滞",避免了边界抖动,是工程上解决"临界点摇摆"问题的经典手法。

3. TreeNode 为什么要同时维护红黑树和双向链表两套结构?

解析

TreeNode 内部同时维护了两套关系:

  • 红黑树关系 :通过 parentleftright 指针,保证 O(log n) 的查找、插入和删除。
  • 双向链表关系 :通过 prevnext 指针(继承自 Nodenext,加上 prev),维持桶内节点的插入顺序。

设计理由

  1. 高效退化(untreeify :当需要将红黑树转回链表时(如 resize 后节点数 ≤ 6,或 remove 导致树结构过于简单),只需沿着 next 指针遍历一次双向链表,为每个 TreeNode 创建一个 Node 并串成单向链表即可。时间复杂度 O(n),且完全不涉及复杂的树遍历,极其高效。
  2. 高效扩容拆分(split :扩容时,需要将一棵红黑树拆分为高位和低位两个链表。利用已有的双向链表,可以直接按 hash & oldCap 是否为 0 将节点分为两条子链表,然后分别决定是否树化。这个操作同样是 O(n) 且不需要遍历树结构。
  3. 保持迭代顺序 :双向链表维护了元素插入桶的顺序。虽然 HashMap 本身不保证整体迭代顺序,但在单个桶内,迭代器可以沿着 next 指针快速、线性地遍历,而无需执行树的中序遍历,这在某些场景下更符合直觉。

双结构本质上是将"有序链表"作为"持久化存储",将"红黑树"作为"查询加速索引"。修改操作同时维护两者,而遍历、拆分、退化等操作则只使用链表结构,真正做到了各取所长。

4. treeifyBin 为什么要分两个阶段(replacementTreeNodetreeify)而不是一步构建红黑树?

解析

treeifyBin 的转换过程明确分为两步:

  • 阶段一 :调用 replacementTreeNode,遍历 Node 链表,将每个 Node 替换为 TreeNode,并通过 prevnext 建立双向链表。此时所有 TreeNode 的红黑树指针(parentleftright)均为 null,颜色为黑(默认)。
  • 阶段二 :调用 treeify,以双向链表中的第一个节点为起点,迭代地将每个 TreeNode 插入红黑树。插入过程中执行查找、比较、balanceInsertion 等操作。

为什么不一步完成?

  1. 解耦类型转换与树构建 :类型转换(NodeTreeNode)是一个简单、确定性的机械操作;而构建红黑树则涉及复杂的比较、旋转和染色。分阶段让代码职责更单一,treeify 方法只需关注"如何从一组已存在的 TreeNode 中构建红黑树",而无需关心这些节点从何而来。
  2. 依赖双向链表treeify 的算法核心是遍历链表,为每个节点在树中找到位置。它必须依赖一个完整的、可遍历的节点序列。阶段一事先建立起双向链表,为阶段二提供了稳定的迭代基础。如果不先构建链表,就需要在构建树的同时维护一个临时列表,反而增加了复杂度。
  3. 可复用性与可逆性 :这种分阶段设计使得 untreeify(退化)可以完全避开树操作,直接从双向链表还原成 Node 单向链表。同样,split(扩容拆分)也只需要依赖双向链表。这种设计使得反向操作和对等操作都变得简洁。

5. HashMap 的红黑树在什么情况下会退化为链表?除了 resize 拆分还有什么触发条件?

解析

红黑树退化回链表有两种触发路径:

路径一:数量性退化(resize 后) 扩容 resize 时,split 方法会统计拆分后每个子桶中的节点数量。如果子桶节点数 ≤ UNTREEIFY_THRESHOLD(6),则调用 untreeifyTreeNode 链表转换为普通 Node 单向链表。这是最常见、可预期的退化路径。

路径二:结构性退化(remove 时)removeTreeNode 方法中,在实际删除节点之前,JDK 会进行一项激进的结构检查:

java 复制代码
if (root == null || root.right == null ||
    (rl = root.left) == null || rl.left == null) {
    tab[index] = first.untreeify(map);  // too small
    return;
}

这段代码检查了红黑树的结构是否过于"瘦弱":如果根节点为 null,或根的右孩子为 null,或根的左孩子为 null,或根的左孩子的左孩子为 null,就立即退化为链表。

为什么即使节点总数超过 6 也要退化?因为这种结构意味着红黑树已经极度不平衡,甚至已经退化为一条链。例如,连续删除节点可能导致树的一侧完全为空,此时的"红黑树"实际上只有左斜链,查找性能退化到 O(n)。继续维护这样一棵"劣化树"的平衡开销远大于收益,不如干脆转回链表。结构性退化是 JDK 对红黑树质量的主动防御,确保 Map 中不会存在"名为树、实为链"的低效结构。

6. 红黑树的五条性质是什么?balanceInsertionbalanceDeletion 分别如何处理插入和删除后的自平衡?

解析

红黑树的五大性质

  1. 每个节点非红即黑。
  2. 根节点是黑色。
  3. 每个叶子节点(null / NIL)是黑色。
  4. 如果一个节点是红色,则它的两个子节点都是黑色(即不允许出现连续的红节点)。
  5. 对于任意节点,从它到其所有后代叶子节点的简单路径上,包含相同数目的黑色节点(称为黑高)。

balanceInsertion 的处理逻辑 : 新插入的节点默认设为红色,可能破坏性质 4。修复循环的条件是"当前节点 x 的父节点 xp 是红色"。根据叔叔节点(xpprxppl)的颜色,分三种情况:

  • Case 1:叔叔为红 → 父、叔染黑,祖父染红,将 x 上移到祖父,继续循环。
  • Case 2:叔叔为黑,且 x 为父的右子(LR型) → 对父节点左旋,变成 Case 3。
  • Case 3:叔叔为黑,且 x 为父的左子(LL型) → 父染黑,祖父染红,对祖父右旋。修复完成。

整个过程自底向上,最多 O(log n) 次旋转,通常只需一次旋转即可完成修复。

balanceDeletion 的处理逻辑 : 删除节点若为黑色,会破坏性质 5(黑高减少 1)。修复循环的核心是将"缺少一个黑色"的矛盾向上传递,并根据兄弟节点 sib 及其子节点的颜色,分四大类(每类再分两个对称子情况,共 8 种):

  • 兄弟为红 → 通过旋转和染色转化为兄弟为黑的情况。
  • 兄弟为黑,且兄弟的两个子节点均为黑 → 兄弟染红,将矛盾上移至父节点。
  • 兄弟为黑,且兄弟的左子红、右子黑 → 通过旋转变为下一情况。
  • 兄弟为黑,且兄弟的右子为红 → 旋转并染色,使黑高恢复,循环终止。

balanceDeletion 比插入修复更复杂,其循环可能执行更多次,但 HashMap 中的实现严格遵循《算法导论》伪代码,保证了自平衡的正确性。

7. HashMap 中 moveRootToFront 方法的作用是什么?为什么扩容后需要调用它?

解析

moveRootToFront 的作用是确保红黑树的根节点始终位于哈希桶的第一个位置 (即 tab[index] == root)。

扩容后需要调用它的原因:扩容时,整个 table 被重新分配,节点根据新的容量重新计算索引。对于红黑树桶,虽然节点们仍然在同一个新桶中,但红黑树的根节点可能会发生变化。例如,在 split 过程中,原来的根节点可能被分到了低位或高位子链,而留在原桶中的节点构成的树可能有了新的根。如果不调整,tab[index] 可能指向一个非根节点。

get 操作会首先检查 tab[index] 的第一个节点 first。如果它是 TreeNode 类型,则直接调用 first.getTreeNode(hash, key) 进行树内查找。如果 first 不是树的根,那么 getTreeNode 内部还需要通过 root() 方法向上追溯到真正的根,虽然功能上可以找到,但这会导致额外的、不必要的指针跳跃。moveRootToFront 在每次可能改变树根的操作(如 treeify, balanceInsertion, balanceDeletion, split)后都会被调用,它将真正的根从双向链表中摘取出来,插入到桶的最前端,并将 tab[index] 指向它。这保证了后续查找的 O(1) 入口判断和最少指针跳转。

8. TreeNode 和普通 Node 的内存占用差异有多大?为什么树化不能太早?

解析

在 64 位 JVM 且默认开启压缩普通对象指针(-XX:+UseCompressedOops)的情况下:

  • Node :对象头 12B + hash(4B) + key(4B) + value(4B) + next(4B) = 28B,对齐后 32 字节
  • TreeNode :在 Node 基础上继承 LinkedHashMap.Entry(+before4B, after4B),再新增 parent(4B)、left(4B)、right(4B)、prev(4B)、red(对齐后占 4B),合计 32+8+20=60B,对齐后 64 字节 (或约 56 字节,取决于如何统计)。即使按保守估计,也是 Node 的 1.75 倍 以上。

树化太早的弊端: 如果树化阈值设得太低(例如 5),在正常随机碰撞下仍有约 0.0015 的概率会触发树化。这种"不必要"的树化会导致:

  1. 内存浪费:大量桶被树化后,HashMap 的整体内存占用可能飙升 50% 以上,容易引发 Full GC。
  2. CPU 开销treeify 本身 O(n log n),加上后续每次 put/remove 都可能触发旋转和染色,这些操作的常数因子远大于链表的指针调整。
  3. 代码复杂度上升:维护树的代码远比链表复杂,过早引入增加了出错概率。

因此,树化阈值必须定在一个概率极低的点(千万分之一),确保它只在"真正需要"时才介入,这就是"8"这个数字的工程意义。

9. 什么是哈希碰撞攻击?JDK 8 如何通过树化防御这种攻击?

解析

哈希碰撞攻击 :攻击者利用 Web 应用(如 Java Servlet 容器)会解析 HTTP 请求参数并放入 HashMap 的特性,精心构造大量 key,使它们的 hashCode 相同但 equals 为 false。这些 key 会被放入同一个桶中形成极长的链表。在 JDK 7 及以前,HashMapget/put 在链表上的操作复杂度为 O(n)。当 n 达到数万时,单次操作可能耗时数毫秒,大量此类请求会迅速耗尽 CPU,导致服务不可用(DoS 攻击)。

JDK 8 的防御 : JDK 8 引入了"链表转红黑树"机制。当单桶链表长度达到 8 且表容量达到 64 时,链表自动转换为红黑树。此后,在该桶上的 get/put 操作复杂度降为 O(log n)。对于 n=10000,链表平均查找需要 5000 次比较,而红黑树只需约 14 次。性能差距约 350 倍,攻击造成的 CPU 消耗被量级性地降低。此外,攻击者想要维持一个桶的树化状态,必须持续发送碰撞 key,而红黑树在元素减少时会退化为链表,这种自适应机制也增加了攻击的难度。

注意:树化只是将计算复杂度从 O(n) 降为 O(log n),并不能消除由大量元素带来的内存压力。因此,生产环境仍需配合流量控制和监控。

10. 【系统设计/故障排查题】线上服务的全局 HashMap 频繁 Full GC,Heap Dump 发现大量 TreeNode 对象。经查 key 的 hashCode 实现极差。请详细分析原因、给出优化方案并设计监控体系。

解析

(a) 为什么大量 TreeNode 会导致频繁 Full GC?

这是一个典型的"内存膨胀导致 GC 压力"的问题,深入分析如下:

  • 内存膨胀 :单个 TreeNode 约为 Node 的 1.75~2 倍大小。假设一个 HashMap 存储 50 万个对象,正常情况下以 Node 为主,占用约 16MB。若因哈希碰撞严重导致 80% 的桶被树化,则内存瞬间膨胀至近 30MB,且这些 TreeNode 将长期存活(因为 map 是全局的),直接进入老年代。
  • 对象晋升 :在 put 操作高峰期,新生代频繁创建 Node 对象。当达到树化阈值时,treeifyBin 会为该桶的所有 Node 创建对应的 TreeNode,并丢弃原 Node。这些新 TreeNode 在经历 Minor GC 时,由于年龄增长或直接因大对象进入老年代,加速了老年代的填充。
  • Full GC 触发 :老年代迅速增长至使用率阈值(如 CMS 的 -XX:CMSInitiatingOccupancyFraction=92),触发 Full GC。Full GC 需要遍历整个老年代的对象图,由于 TreeNode 的树状结构复杂,GC 的标记时间显著增加,导致长暂停。若 Full GC 后仍无法回收足够空间(因为 map 本身就是存活对象),就会陷入频繁 Full GC 的恶性循环。
  • CPU 消耗:在 GC 进行的同时,业务线程可能因内存分配失败而被迫等待,请求积压,CPU 被 GC 和无效查找(攻击或劣化 hashCode 下的红黑树查询)共同占用,服务雪崩。

(b) 保留原有 key 类前提下的优化方案

如果 UserBehavior 类不能修改(如第三方库),可采取以下方案:

方案一:使用包装器(Wrapper)进行二次哈希

java 复制代码
public class SafeKey {
    private final UserBehavior data;
    private final int hash;
    public SafeKey(UserBehavior data) {
        this.data = data;
        // 扰动函数,混合高位信息,避免低位集中
        this.hash = spread(data.hashCode());
    }
    private static int spread(int h) {
        return (h ^ (h >>> 16)) & 0x7fffffff;
    }
    @Override public int hashCode() { return hash; }
    @Override public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof SafeKey)) return false;
        SafeKey that = (SafeKey) o;
        return this.data.equals(that.data);
    }
}

然后使用 Map<SafeKey, Value> 替代原 map。优点 :实现简单,立即解决分布问题。缺点 :需修改所有使用 map 的代码;增加了包装对象的开销;需要保证 SafeKeyequals 正确性。

方案二:使用 ConcurrentHashMap 并启用 LRU 限制容量 利用 LinkedHashMapremoveEldestEntry 或 Guava Cache 实现 LRU,控制最大元素数量,防止无限增长。但无法根治 hashCode 质量问题,碰撞仍存在,只是控制了总数。

方案三:数据预哈希put 之前,用高质量的哈希函数(如 Guava Hashing.murmur3_128)对 key 的特定字段进行哈希,得到一个新的 long 哈希值,然后以这个 long 为 key,原对象作为 value 的一部分。适用于 key 不可变且业务允许的场景。

推荐:方案一(Wrapper)是解决哈希碰撞最直接、成本最低的方式。

(c) 监控、告警与自动化处理的架构设计

架构图

flowchart TD subgraph AppClusterSub["应用集群"] App1["App Instance 1"] App2["App Instance 2"] App3["App Instance 3"] end subgraph MonitorSub["监控采集层"] Prometheus["Prometheus 指标采集"] JMX["JMX Exporter / Micrometer"] AGENT["自研 Agent
反射统计 TreeNode 数量"] end subgraph AlertSub["告警与可视化"] Grafana["Grafana 仪表盘"] AlertM["Alertmanager"] end subgraph AutoSub["自动化处理"] ConfigCenter["动态配置中心
Apollo / Nacos"] RateLimiter["限流组件
Sentinel"] Degrade["业务降级逻辑"] end App1 --> JMX App2 --> JMX App3 --> JMX App1 --> AGENT JMX --> Prometheus AGENT --> Prometheus Prometheus --> Grafana Prometheus --> AlertM AlertM --> ConfigCenter ConfigCenter --> RateLimiter ConfigCenter --> Degrade RateLimiter --> App1 Degrade --> App1 classDef appSub fill:#e0f2fe,stroke:#0284c7,stroke-width:2px classDef monSub fill:#f3e8ff,stroke:#9333ea,stroke-width:2px classDef alertSub fill:#dcfce7,stroke:#16a34a,stroke-width:2px classDef autoSub fill:#ffedd5,stroke:#ea580c,stroke-width:2px classDef nodeStyle fill:#ffffff,stroke:#cbd5e1,stroke-width:1.5px,color:#1e293b class AppClusterSub appSub class MonitorSub monSub class AlertSub alertSub class AutoSub autoSub class App1,App2,App3,JMX,AGENT,Prometheus,Grafana,AlertM,ConfigCenter,RateLimiter,Degrade nodeStyle

核心监控指标

指标名称 类型 描述
hashmap_size Gauge HashMap 总元素数
hashmap_treeified_buckets Gauge 当前树化的桶数量
hashmap_treeify_events_total Counter 树化事件的累计次数
jvm_memory_used_bytes{area="old"} Gauge 老年代内存使用量
jvm_gc_pause_seconds_total Counter GC 累计耗时
http_requests_latency_seconds Histogram 接口响应时间

告警规则

  • 树化桶数量在 5 分钟内增长超过 10% → 告警级别:WARNING
  • 老年代使用率超过 85% 且 Full GC 频率 > 2 次/5min → 告警级别:CRITICAL
  • 接口 P99 响应时间 > 2s → 告警级别:CRITICAL

故障排查流程时序图

flowchart TD Monitor["监控系统"] -- "触发告警" --> Oncall["值班工程师"] Oncall --> Step1["查看 Grafana 仪表盘
确认 GC 与树化指标异常"] Step1 --> Step2["获取 Heap Dump
jmap -dump:live,file=hdump.hprof PID"] Step2 --> Step3["MAT 分析:定位 TreeNode 数量
及其来源 HashMap"] Step3 --> Step4{"检查 key 的 hashCode 实现"} Step4 -- "质量差" --> Step5["代码修复:引入 Wrapper
或优化 hashCode"] Step4 -- "质量正常" --> Step6["检查容量与负载因子
优化初始容量或限流"] Step5 --> Step7["灰度发布修复版本"] Step6 --> Step7 Step7 --> Step8["监控指标恢复
关闭告警"] classDef default fill:#f1f5f9,stroke:#334155,stroke-width:1.5px,color:#1e293b class Monitor,Oncall,Step1,Step2,Step3,Step4,Step5,Step6,Step7,Step8 default1

自动化处理: 当检测到树化桶比例超过 20% 且老年代内存 > 90% 时,动态配置中心可下发指令:

  1. 启用 限流(Sentinel),对该 HashMap 所在服务的写入请求进行 QPS 限制。
  2. 或执行 降级 :新建请求不再写入该 HashMap,转而使用备用缓存(如基于 ConcurrentHashMap 且带有安全哈希的版本),并异步重建旧 map 的数据。
  3. 极端情况下,直接熔断,返回兜底数据,保护服务存活。

这一整套监控、告警、自动化处理体系,将 HashMap 的红黑树化从"底层黑盒"转变为了"可观测、可治理"的系统能力。

11. TreeMap 的红黑树实现和 HashMap.TreeNode 有什么异同?为什么 HashMap 不直接复用 TreeMap 的代码?

解析

相同点

  • 两者都实现了标准的红黑树数据结构,遵循相同的五大性质,插入、删除、旋转的逻辑本质上一致。

不同点

  • 节点结构TreeMap.Entry 只包含红黑树所需的 left, right, parent 和颜色,是一个纯粹的树节点。而 HashMap.TreeNode 同时维护了红黑树指针和双向链表指针(prev, next),形成双结构。
  • 排序依据TreeMap 完全依赖 ComparatorComparable 来决定元素顺序,不允许无比较器的对象。HashMap.TreeNode 首先是基于 hash 值排序,仅在 hash 冲突时才使用 ComparabletieBreakOrder 作为 tie-breaker,因此它天然支持不可比较的对象。
  • 集成方式TreeMap 是独立的容器,其树节点本身就是数据载体。HashMap.TreeNodeHashMap.Node 的子类,需要无缝融入到哈希桶的链表/树混合体系中,因此它必须兼容 Nodenext 字段,并且有退化、拆分等与哈希表扩容紧密耦合的操作。

不直接复用的原因

  1. 结构耦合HashMap 需要桶中的节点既能作为链表节点(实现 Node),又能作为树节点。直接复用 TreeMap.Entry 无法适配 Node 的继承体系和桶结构。
  2. 双结构需求HashMap 的红黑树需要与双向链表共存,以支持 O(n) 的退化、拆分和迭代。TreeMap 没有这种需求,它的节点是纯粹的树节点。
  3. 性能与内存TreeMap 的节点不需要 next/prev,复用会导致内存浪费或需要大量适配代码。自建 TreeNode 允许精确的内存控制和字段最小化。

因此,HashMap 必须从零实现自己的红黑树节点,虽然算法思想一致,但结构上完全不同。

12. 为什么 HashMap.TreeNode 继承自 LinkedHashMap.Entry 而不是直接继承 HashMap.Node?

解析

TreeNode 的继承链是:HashMap.NodeLinkedHashMap.EntryHashMap.TreeNode

直接继承 Node 是可行的,但继承 LinkedHashMap.Entry 是一个为了代码复用而做出的巧妙决策

LinkedHashMap.EntryNode 的基础上增加了 beforeafter 两个指针,用于在 LinkedHashMap 中维护一个全局的双向链表,从而实现可预测的迭代顺序(如插入顺序或访问顺序 LRU)。

HashMap 的上下文中,TreeNode 并不需要这个全局双向链表,beforeafter 指针在树化时是闲置的。但是,通过继承 Entry

  • TreeNode 无需再从零开始定义 prevnext 之外的字段,避免了重复代码。
  • 更重要的是,这使得 TreeNode 可以直接在 LinkedHashMap 的子类(如 LRU 缓存实现)中复用 !在 LinkedHashMap 中,如果某个桶发生了树化,那么这些 TreeNode 同时也就天然具备了 before/after 指针,能够被无缝地纳入 LinkedHashMap 的双向链表管理中,无需任何额外转换。这种设计使得 HashMap 的树化机制对 LinkedHashMap 完全透明且兼容,是 JDK 类库内部高内聚、可扩展设计的典范。

如果直接继承 Node,那么在 LinkedHashMap 场景下,还需要额外创建一个包装类来持有 before/after 并包裹 TreeNode,导致复杂性剧增。因此,继承 LinkedHashMap.Entry 是一个一举两得的优雅选择。

延伸阅读

  • 《算法导论(第3版)》第13章(红黑树):提供了本文所述所有平衡算法的严格数学证明与伪代码。
  • OpenJDK 源码HashMap.javaTreeNode 内部类的完整实现(约 400 行),是理解双结构和自平衡操作的最佳材料。
  • JDK-8023463:HashMap 树化的 JEP 提案,详细阐述了树化特性的动机、设计和性能测试结果。
  • 《Effective Java(第3版)》第11条(覆盖 hashCode 时总要覆盖 equals):避免因 hashCode 实现不当导致 HashMap 性能问题的根本指南。
  • 《Java Performance: The Definitive Guide》:关于 JVM GC 调优和内存分析的权威著作。

HashMap 树化速查表

项目 值 / 描述
树化阈值 TREEIFY_THRESHOLD = 8
最小树化容量 MIN_TREEIFY_CAPACITY = 64
退化阈值 UNTREEIFY_THRESHOLD = 6
节点类型 HashMap.TreeNode<K,V> extends LinkedHashMap.Entry<K,V>
TreeNode 关键字段 parent, left, right, red (红黑树) / prev, next (双向链表)
TreeNode 内存(64位压缩) 约 56~64 字节
树化方法 treeifyBinreplacementTreeNode + treeify
退化触发条件 1. resize 后节点数 ≤ 6 2. removeroot/root.right/root.left/root.left.left 任一为 null
退化方法 untreeify
自平衡方法 rotateLeft, rotateRight, balanceInsertion, balanceDeletion
根维护方法 moveRootToFront
相关推荐
凌波粒1 小时前
LeetCode--513.找树左下角的值(二叉树)
java·算法·leetcode
喜欢小苹果的码农1 小时前
xxl-job主流程分析
java
敖正炀1 小时前
HashMap 源码深度拆解(JDK 7→8)
java
Yeats_Liao1 小时前
物联网接入层技术剖析(二):epoll到底是怎么工作的
java·linux·网络·物联网·信息与通信
DevOpenClub1 小时前
职教高考及高职分类招生控制线 API 接口
java·数据库·高考
Tsuki_tl1 小时前
【总结】Java的线程状态
java·后端·面试·多线程·并发编程·线程状态
苦逼的猿宝1 小时前
springboot的网页时装购物系统
java·毕业设计·springboot·计算机毕业设计
WL_Aurora1 小时前
Java多线程编程基础与实践
java·多线程
再写一行代码就下班1 小时前
根据给定word模板,动态填充指定内容,并输出为新的word文档。(${aa}占位符方式且支持循环动态表格)
java·开发语言