这道题在 Java 面试里出现的频率极高,但真正能答到点上的候选人并不多。大部分人的回答停留在“8 是泊松分布算出来的,6 是为了防止频繁转换”,再追问一句“泊松分布的 λ 是多少、算出来概率多大、为什么不是 7 和 9、为什么中间留了 7 这个空档”,就答不上来了。这篇文章把 HashMap 中 TREEIFY_THRESHOLD、UNTREEIFY_THRESHOLD、MIN_TREEIFY_CAPACITY 这三个常量背后的设计推导、源码执行路径、以及线上可能踩到的坑一次讲清楚。
一、先把三个常量摆出来,别把 8 和 6 混为一谈
JDK 8 的 java.util.HashMap 里定义了一组与树化相关的常量,很多人只记住了 8 和 6,却不知道还有一个 64 在中间卡着:
// 链表转红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;
// 红黑树转回链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
// 允许树化的最小表容量,注意是容量不是元素个数
static final int MIN_TREEIFY_CAPACITY = 64;
这三个常量的职责完全不同,面试时如果不区分,就容易被判定为“背题没背全”:
| 常量 | 值 | 触发条件 | 作用对象 |
|---|---|---|---|
| TREEIFY_THRESHOLD | 8 | 单个桶内链表节点数 >= 8 | 链表 → 红黑树 |
| UNTREEIFY_THRESHOLD | 6 | 红黑树节点数 <= 6(扩容拆分时) | 红黑树 → 链表 |
| MIN_TREEIFY_CAPACITY | 64 | table.length < 64 时优先扩容而非树化 | 抑制早期树化 |
注意 MIN_TREEIFY_CAPACITY = 64 这一条是很多八股文漏掉的。当你往一个刚 new 出来的 HashMap 里塞 9 个哈希冲突的元素时,桶里确实到了 8 个节点,但此时 table.length 往往只有 16,treeifyBin 会先执行 resize() 把容量翻倍到 32、64,而不是立刻树化。原因是小表阶段扩容的收益远大于树化——扩容后哈希桶变多,原本挤在一个桶里的节点大概率被分散开,根本不需要红黑树这么重的结构。
二、8 这个数字的来源:泊松分布与 0.00000006
HashMap 源码里有一段著名的注释,位置在 TREEIFY_THRESHOLD 上方(JDK 8u 各版本措辞略有差异,但数据一致):
Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use (see TREEIFY_THRESHOLD). And when they become too small (due to removal or resizing) they are converted back to plain bins. In usages with well-distributed user hashCodes, tree bins are rarely used. Ideally, under random hashCodes, the frequency of nodes in bins follows a Poisson distribution with a parameter of about 0.5 on average for the default resizing threshold of 0.75, although with a large variance because of resizing granularity. Ignoring variance, the expected occurrences of list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The first values are: 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
这段注释信息量很大,拆开看:
- λ = 0.5。这个参数怎么来的?HashMap 默认负载因子是 0.75,容量为
n时元素数为0.75n,桶数为n,所以平均每个桶的节点数就是0.75n / n = 0.75。但注释里写的是 0.5,而不是 0.75。原因是 map 中的元素并不是均匀铺满所有桶的,扩容粒度带来的方差使得实际分布的均值被取为 0.5 这个经验值。源码作者 Adrian 在邮件列表里解释过这一点,属于“忽略方差后的保守取值”。 - P(k=8) = 0.00000006。按照泊松公式
P(X=k) = e^(-λ) λ^k / k!,代入 λ=0.5:e^-0.5 ≈ 0.60653066,乘上0.5^8 = 0.00390625,再除以8! = 40320,得到约6.0e-8。也就是说在理想的随机哈希下,一个桶里正好出现 8 个节点的概率不到一亿分之六。 - 为什么选 8 而不是 7 或 9:P(k=7) ≈ 0.00000094,P(k=9) ≈ 3.3e-9。若阈值取 7,树化会频繁发生,而树节点的内存开销约为普通 Node 的两倍,得不偿失;取 9 则意味着要容忍到 9 个节点才树化,桶内查找的 O(n) 已经在 8 这个规模上明显拖慢性能。8 是“树化概率已经低到可以忽略”与“链表查找成本开始不可接受”的平衡点。
把节点数、概率、单次查找平均比较次数放在一张表里对比,会非常直观:
| 桶内节点数 k | 泊松概率 P(k) | 链表平均查找次数 (k+1)/2 | 红黑树查找次数 ≈ log2(k) |
|---|---|---|---|
| 4 | 0.00157952 | 2.5 | 2 |
| 6 | 0.00001316 | 3.5 | 2.58 |
| 7 | 0.00000094 | 4.0 | 2.81 |
| 8 | 0.00000006 | 4.5 | 3.0 |
| 10 | 3.3e-9 | 5.5 | 3.32 |
可以看到,在 k=8 之前,链表和红黑树的查找次数差距并不悬殊,而红黑树每个节点要多维护 parent、left、right、red 四个字段(TreeNode 继承自 LinkedHashMap.Entry,再继承 HashMap.Node),内存开销几乎翻倍。等到 k=8,链表平均要找 4.5 次才能命中,且这个概率已经小到“几百万个桶里才有一个”,此时引入红黑树才有意义。
三、6 的作用:不是“8 和 6 中间必须留个 7”,而是留缓冲带
很多面试官会问:“为什么树转链表的阈值是 6 而不是 7?”——如果也取 7,那么一个桶内恰好有 7 个节点时,就会在“树化”和“退化”之间反复横跳:
- 插入第 8 个节点 → 触发树化,变成红黑树。
- 删除一个节点,剩 7 个 → 如果阈值是 7,立刻退化为链表。
- 再插入一个节点,回到 8 个 → 又树化。
每次树化和退化都不是免费的:树化要遍历链表、构造 TreeNode、调用 treeify 做红黑树平衡;退化要遍历树、重新组装成链表。如果业务在 7 和 8 之间高频增删,就会出现结构性抖动,白白消耗 CPU。
取 6 之后,7 变成了一个“安全带”区间:桶内节点从 8 个降到 7 个时不做任何动作,继续用红黑树;只有降到 6 个及以下,才认为“这个桶确实稀疏回来了”,执行退化。这里还有一个常被忽略的细节:退化只在扩容拆分时发生,普通的 remove 操作并不会触发退化。
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
// 如果是树节点,走 P_TREE 分支查找
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// ... 删除并返回
}
}
可以看到删除时若命中树节点,只是调用 getTreeNode 找到目标并从树中摘除,没有调用 untreeify。真正调用 untreeify 的地方在 HashMap.resize() 里拆分树桶的逻辑:
// resize() 中对树桶按 (e.hash & oldCap) == 0 拆成 lo/hi 两条链
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
} else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
// 拆分后节点数 <= 6 才退化成链表
if (lc <= UNTREEIFY_THRESHOLD)
loHead = loHead.untreeify(map);
else
loHead = (loTail != null) ? loTail.treeify(tab) : loHead;
tab[index] = loHead;
}
// hiHead 分支同理
}
所以回答这道题时说“删除到 6 就退化成链表”是不准确的,更严谨的表述是:扩容拆分树桶后,若某条链节点数 ≤ 6 则退化为链表,否则重新树化。这个设计本身就避免了删除操作引发的抖动——你尽管在树里删,树稀疏了也无所谓,等下一次扩容时再一并处理。
四、树化的完整执行路径:从 putVal 到 treeifyBin
光记住常量不够,能讲清执行路径才体现读过源码。putVal 在链表尾部追加节点时判断是否达到树化阈值:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
// 已经是树,走红黑树插入
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 注意:binCount 从 0 开始,这里判断是 >=
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// ... 省略替换值与 afterNodeAccess
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
这里 binCount >= TREEIFY_THRESHOLD - 1 即 binCount >= 7,因为 binCount 统计的是“新节点插入前已经走过的节点数”,从 0 起算。当 binCount 到 7 时,说明链上原有 7 个节点,加新插入的共 8 个,正好达到阈值。
然后是 treeifyBin,这里是第二个容易踩坑的地方:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
// 容量不到 64,优先扩容
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab); // 真正构建红黑树
}
}
所以完整的树化条件其实是三条同时满足:① 某个桶内节点数 ≥ 8;② table.length ≥ 64;③ 该桶当前仍是链表。如果容量不足,会先 resize 到 64,扩容之后哈希重新分布,很多桶的冲突自然消失了。
五、动手验证:用反射和打印看真实的转化时机
光看源码不如跑一遍。下面这段代码通过自定义 hashCode 强制制造冲突,观察桶内类型和阈值的变化。注意不要用默认的 String 键,因为其 hash 是打散的,很难凑够 8 个冲突。
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
public class TreeifyDemo {
static class Key {
final int id;
Key(int id) { this.id = id; }
@Override public int hashCode() { return 1; } // 全部冲突到同一个桶
@Override public boolean equals(Object o) {
return o instanceof Key && ((Key) o).id == id;
}
@Override public String toString() { return "K" + id; }
}
static Object tableOf(HashMap<?, ?> map) throws Exception {
Field f = HashMap.class.getDeclaredField("table");
f.setAccessible(true);
return f.get(map);
}
static String binType(HashMap<?, ?> map, int index) throws Exception {
Object[] table = (Object[]) tableOf(map);
if (table == null || table[index] == null) return "empty";
return table[index].getClass().getSimpleName(); // Node 或 TreeNode
}
public static void main(String[] args) throws Exception {
HashMap<Key, Integer> map = new HashMap<>();
for (int i = 0; i < 20; i++) {
map.put(new Key(i), i);
Object[] table = (Object[]) tableOf(map);
System.out.printf("size=%2d, capacity=%2d, bin[1]=%s%n",
map.size(), table.length, binType(map, 1));
}
}
}
运行这段代码(需要 JDK 8 及以上,并在 module-info 未受限的环境执行,JDK 17 需加 --add-opens java.base/java.util=ALL-UNNAMED),你会看到一条清晰的轨迹:容量先按 16 → 32 → 64 翻倍,直到容量达到 64、且桶内节点达到 8 个之后,bin[1] 的类型才从 Node 变成 TreeNode。这正是 MIN_TREEIFY_CAPACITY 在起作用。
如果你在 JDK 9+ 上直接跑,可能会见到这样的报错:
java.lang.reflect.InaccessibleObjectException: Unable to make field transient java.util.HashMap$Node[] java.util.HashMap.table accessible: module java.base does not "opens java.util" to unnamed module @1b6d3586
解决办法是加 JVM 参数:
java --add-opens java.base/java.util=ALL-UNNAMED TreeifyDemo
这是模块化之后反射访问 JDK 内部字段的通用处理方式,和 HashMap 本身无关,但面试时顺带提一句会加分。
六、几个必须澄清的误区
误区一:链表长度一到 8 就立刻变红黑树
错。前面说了,容量小于 64 时先扩容。而且要注意,扩容后如果哈希重新分布,桶内节点数可能直接降到 8 以下,树化根本不会发生。这就是为什么很多人在小 map 上做实验,塞了十几个冲突元素却始终看不到 TreeNode。
误区二:树转回链表是“节点数降到 6 就立刻转”
错。普通删除不触发退化,只有 resize() 里的 split 才会判断 lc <= 6 或 hc <= 6。这意味着一个桶里哪怕只剩 1 个节点,只要不扩容,它可能仍然挂着一棵只有一个节点的树。这并不影响正确性,只是多占了一点内存。
误区三:树化阈值的概率是算出来的精确值
半对。0.00000006 是按泊松分布 λ=0.5 计算的结果,但 λ=0.5 本身是一个忽略方差后的经验取值,源码注释也明说了 “although with a large variance because of resizing granularity”。所以这个数字应当理解为“量级正确”,而不是“严格推导”。面试时能说出这一点,就已经超过绝大多数候选人了。
误区四:8 和 6 的差值 2 是随意的
也不是完全随意,但确实没有严格数学推导。留 2 的缓冲带是为了让“树化-退化”这两个动作之间有一个不可达的中间态(7),避免抖动。如果取 8 和 7,中间就没有缓冲;如果取 8 和 5,缓冲区太大,一个桶明明还有 6、7 个节点却用着树结构,内存浪费。2 是一个工程上合理的折中。
七、顺带提一句:这些阈值能用参数调整吗
不能。TREEIFY_THRESHOLD 和 UNTREEIFY_THRESHOLD 都是 static final 常量,没有对应的系统属性或构造参数。可以通过反射修改(不推荐,且 JDK 17 下需要 --add-opens 并修改 final 字段,非常脆弱),也可以通过继承 HashMap 覆盖方法,但意义都不大。真正可以调的只有两个:
initialCapacity:初始化容量,推荐用Maps.newHashMapWithExpectedSize(n)(Guava)或(int)(n / 0.75f) + 1计算,避免反复扩容。loadFactor:负载因子,默认 0.75,通常不建议改。调低会浪费空间,调高会加剧冲突、更容易触发树化。
一个常见的实操建议是:如果已知 map 要装 1000 个元素,直接 new HashMap<>(2048)(或至少 1024),一次性分配到位,比让它在 16、32、64…… 一路扩容到 1024 要快得多,也避免了扩容过程中的树桶拆分开销。
小结
回到题目本身,一段能在面试里拿满分的回答应该包含这四层:
- 数值来源:8 来自泊松分布 λ=0.5 下 P(k=8)≈0.00000006 这个量级,即默认场景(负载因子 0.75)下桶内出现 8 个节点的概率低到可以忽略,此时引入红黑树把 O(n) 查找降到 O(log n) 才划算;TreeNode 体积约为 Node 的两倍,概率不低就不能滥用。
- 为什么是 6:如果树退化也取 7 或 8,在 7/8 附近频繁增删会导致树化和退化反复发生,结构抖动消耗 CPU;留出 7 作为缓冲不可达区间,避免抖动。
- 完整触发条件:树化需要桶内节点 ≥ 8 且 table.length ≥ 64(
MIN_TREEIFY_CAPACITY),否则先 resize;退化只在resize()的split()中判断剩余节点 ≤ 6 时发生,普通删除不触发。 - 工程含义:默认参数下几乎不会走到树化,所以不要把“HashMap 有红黑树”当成性能保障;真正该做的是设计好 hashCode、预估容量、控制负载因子。
FAQ
Q1:为什么 λ 取 0.5 而不是 0.75(负载因子)?
0.75 是元素数/桶数的上界,但这是个上限而非均值。实际扩容是按 2 倍粒度进行的,大部分时刻 map 的装载率分布在 0.375~0.75 之间,取中值附近的 0.5 更接近平均情况。源码注释明确写了这是在 “ignoring variance” 的前提下的取值,因此只保证量级。
Q2:如果 hashCode 全部相同,HashMap 会退化成什么复杂度?
树化前是 O(n),树化后是 O(log n)。但如果是极端的全冲突且键的 compareTo 也无法区分(非 Comparable 或比较结果相等),红黑树插入时会走 tieBreakOrder,用 System.identityHashCode 来打破平局,此时查找仍然能保持 O(log n),但插入顺序就不再稳定。这也是为什么文档反复强调“不要依赖 HashMap 的遍历顺序”。
Q3:ConcurrentHashMap 的阈值也是 8 和 6 吗?
是的,ConcurrentHashMap 同样定义了 TREEIFY_THRESHOLD = 8、UNTREEIFY_THRESHOLD = 6、MIN_TREEIFY_CAPACITY = 64。差别在于它的树化是在 putVal 的同步块里完成的,且退化逻辑在 replaceTreeNode/split 等多个路径上都有体现,比 HashMap 更复杂。
Q4:JDK 7 的 HashMap 有红黑树吗?
没有。JDK 7 的 HashMap 是数组 + 链表,头插法,扩容时可能形成环形链表导致 CPU 100%(经典的并发 put 死循环问题)。JDK 8 改成尾插法解决环化,同时引入红黑树优化长链表查询。如果面试官问到“为什么 JDK 8 要引入红黑树”,标准答案是:极端哈希冲突下,链表 O(n) 查询是性能杀手,红黑树把最坏情况压到 O(log n)。
Q5:为什么不用跳表(skip list)替代红黑树?
跳表节点需要额外的多级指针数组,随机层数导致内存不可控,且平均时间复杂度与红黑树相近。红黑树实现成熟、内存结构确定(每个 TreeNode 固定 4 个引用字段),更适合放进 JDK 这种对内存和确定性要求高的基础库。Redis 的 zset 用跳表是因为它更看重范围查询和实现简洁性,场景不同。
Q6:线上出现 HashMap 相关 CPU 飙高,怎么排查?
先 jstack 抓取线程栈,如果多个线程卡在 HashMap.getNode 或 HashMap.putVal,且有大量 TreeNode 相关的帧,说明存在严重的哈希冲突。用 jmap -histo:live <pid> 观察 java.util.HashMap$TreeNode 实例数量,若远超预期,就要检查自定义键的 hashCode 实现,是不是把所有对象都 hash 到少数几个值上了。