bigywater.tech
Java 内部原理 · 09

HashMap 树化:真实测出是插入第 9 个才变红黑树,不是第 8 个

"链表长度到 8 就转红黑树"是背得最熟的 HashMap 八股之一。做一个 hashCode 永远返回同一个值的坏类,真实反射进 HashMap 的内部 table,把这句话拆成三个可以验证的具体问题:到底是第几个触发的、容量不够会怎样、remove 之后会不会转回来。

8 个 → 插第 9 个
真实反射观察到:桶内已有 8 个 Node 时保持链表,插入第 9 个的那一刻才真正变成 TreeNode
16→32→64
起始容量不到 64 时,真实测出即使桶内节点数早过 8,也只会连续扩容,不会树化

上一篇:ThreadLocal 内存泄漏:线程池里不调用 remove(),真实测出 23MB 残留

怎么造一个必然扎堆的桶

正常的 key 分布均匀,几乎不可能自然凑出 8 个碰撞;所以自己写一个 hashCode 恒定的类,强行把碰撞做出来。

HashMap 的每个桶本来存的是一条单向链表,put 的时候如果发现某个桶里的节点数达到阈值,会尝试把这个桶从链表转成红黑树(TreeNode),用 O(log n) 的查找替换最坏情况下 O(n) 的链表遍历。要真实触发这个过程,先得让很多 key 真实落进同一个桶:

TreeifyDemo.java(实验用坏类) static final class BadHash { final int id; BadHash(int id) { this.id = id; } @Override public int hashCode() { return 42; } // 永远返回同一个值 }

HashMap 定位桶用的不是 hashCode() 原始值,而是先做一次扰动(h ^ (h >>> 16))再和 (table.length - 1) 做按位与。因为这个坏类的 hashCode() 永远是 42,扰动后的值也永远一样,所以不管插入多少个 BadHash 实例,它们都会真实落进同一个桶——用反射拿到 HashMap.table 字段,再拿到这个桶里节点的 getClass().getSimpleName(),就能真实看到它从 Node 变成 TreeNode 的那一刻。

真实验证一:容量给够时,第几个 put 真正触发树化

预先把 HashMap 容量开到 128,排除"容量不够导致扩容而不是树化"这个变量,单独看节点数这一个维度。

真实实测 · new HashMap<>(128),连续插入同 hashCode 的 key
put 第  1 个 -> table.length=128, 桶[42] 节点类=Node, 沿 next 链长=1
put 第  2 个 -> table.length=128, 桶[42] 节点类=Node, 沿 next 链长=2
put 第  3 个 -> table.length=128, 桶[42] 节点类=Node, 沿 next 链长=3
put 第  4 个 -> table.length=128, 桶[42] 节点类=Node, 沿 next 链长=4
put 第  5 个 -> table.length=128, 桶[42] 节点类=Node, 沿 next 链长=5
put 第  6 个 -> table.length=128, 桶[42] 节点类=Node, 沿 next 链长=6
put 第  7 个 -> table.length=128, 桶[42] 节点类=Node, 沿 next 链长=7
put 第  8 个 -> table.length=128, 桶[42] 节点类=Node, 沿 next 链长=8
put 第  9 个 -> table.length=128, 桶[42] 节点类=TreeNode, 沿 next 链长=9

真实结果和背下来的"8 就转树"有个容易被忽略的偏差:桶里已经有 8 个 Node 的时候,类型仍然是链表;要等第 9 个节点插入完成,才真实看到类型变成 TreeNode。JDK 里这个阈值常量确实叫 TREEIFY_THRESHOLD = 8,但它的语义是"桶里已有节点数达到 8(不算新插入的这个)才触发树化检查",而不是"总节点数到 8 就树化"——真实数据里,总节点数是 9 的时候,树化才真正发生。

分步演示:桶内节点从链表变成红黑树的每一步

把"真实验证一"的 9 步插入串成动画:每一帧都是真实反射读出的桶内节点数和节点类型,没有手工画。

桶[42] 逐个 put:节点类型从 Node 变成 TreeNode 的真实过程(容量固定 128,数据来自对 HashMap.table 的反射读取)
这一步新插入的节点这一步发生变化的状态项

    真实验证二:容量不到 64,再多碰撞也树化不了

    这次不预先给容量,用默认的 new HashMap<>() 从 16 开始,看看碰撞节点数早就过了 8 之后,到底发生了什么。

    真实实测 · new HashMap<>(),默认容量 16 开始
    put 第  1 个 -> table.length= 16, 桶[10] 节点类=Node
    put 第  2 个 -> table.length= 16, 桶[10] 节点类=Node
    put 第  3 个 -> table.length= 16, 桶[10] 节点类=Node
    put 第  4 个 -> table.length= 16, 桶[10] 节点类=Node
    put 第  5 个 -> table.length= 16, 桶[10] 节点类=Node
    put 第  6 个 -> table.length= 16, 桶[10] 节点类=Node
    put 第  7 个 -> table.length= 16, 桶[10] 节点类=Node
    put 第  8 个 -> table.length= 16, 桶[10] 节点类=Node
    put 第  9 个 -> table.length= 32, 桶[10] 节点类=Node
    put 第 10 个 -> table.length= 64, 桶[42] 节点类=Node
    put 第 11 个 -> table.length= 64, 桶[42] 节点类=TreeNode
    put 第 12 个 -> table.length= 64, 桶[42] 节点类=TreeNode
    第 9 个插入本该按上面的实验触发树化,但因为此时 table.length 只有 16,真实观察到的是扩容(16→32),不是树化;紧接着第 10 个插入又连续触发了一次扩容(32→64),同一个桶里的节点类型全程停留在 Node,直到容量真正达到 64、第 11 个插入完成后,才第一次真实看到 TreeNode

    JDK 源码里这个门槛叫 MIN_TREEIFY_CAPACITY = 64:即使某个桶的节点数已经达到树化检查的阈值,只要整个 table 的容量还没到 64,treeifyBin 方法内部会选择直接扩容而不是真的转成树,理由很直接——容量太小时桶本来就容易碰撞,先扩容通常就能把这一堆节点重新打散到不同的桶里,用不着红黑树。真实数据里能看到这个决策的连锁反应:一次该树化的时机被容量门槛拦下来,直接引发了两次连续扩容。

    真实验证三:remove 到 0 个,类型有没有转回链表

    另一句常见说法是"节点数减到 6 就转回链表",拿真实数据核对一下这句话在 remove() 场景下成不成立。

    接上第一组实验(容量 128,已树化,桶里 10 个节点),从 10 个开始逐个 remove,每一步都反射检查桶里节点的类型:

    真实实测 · 从 10 个逐个 remove 到 0 个
    remove 到剩  9 个 -> 桶[42] 节点类=TreeNode
    remove 到剩  7 个 -> 桶[42] 节点类=TreeNode
    remove 到剩  5 个 -> 桶[42] 节点类=TreeNode
    remove 到剩  4 个 -> 桶[42] 节点类=TreeNode
    remove 到剩  3 个 -> 桶[42] 节点类=TreeNode
    remove 到剩  2 个 -> 桶[42] 节点类=TreeNode
    remove 到剩  1 个 -> 桶[42] 节点类=TreeNode
    remove 到剩  0 个 -> 桶[42] 节点类=TreeNode
    一路删到只剩 1 个节点,类型全程真实保持 TreeNode,没有在剩 6 个、剩 5 个或任何中间点观察到转回 Node 的情况
    "减到 6 个转回链表"错在哪?这句话描述的其实是扩容(resize)时的拆分逻辑:一个已树化的桶在扩容时会按新的哈希位被拆成两个桶,如果拆分后某一半的节点数 <= UNTREEIFY_THRESHOLD(6),这一半会转回链表——但这只发生在 resize 的 split 过程里。真实源码里 remove() 对应的 TreeNode.removeTreeNode 用的是另一套判断:检查树的形状是不是"太小"(根节点的左右子树够不够深),不是简单数节点个数。真实实验也做了扩容场景的旁证:同一批碰撞 key 因为哈希值完全相同,扩容拆分时没法被分到两个不同的新桶里,所以即使触发扩容,这个桶依然整体保留 TreeNode——"6 个转回链表"这个数字是真的,但触发它的场景比"remove 到某个数字"要窄得多。

    参考与说明

    • 本文全部节点类型、table.length 均在本机真实运行 Java 17(反射需要 --add-opens java.base/java.util=ALL-UNNAMED)得到,三组实验各自独立运行,互相验证。
    • 这篇和系列第一篇HashMap 容量与阈值共用同一个 table 字段反射手法,建议按顺序读。
    • 没有涉及:红黑树内部的旋转、插入平衡算法细节,TreeNode.split 具体怎么按新增的那一位哈希值把节点分到两个新桶——这些留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号