JDK JDK 学习手册8 → 27 · JDK 专用学习站
DEEPDIVE · COLLECTIONS · JDK 8

HashMap 内部结构:扰动、扩容与树化

JDK 8 · 深钻主题 03 / 03 · 预计 45 分钟
深入四步深钻
一句话JDK 8 的 HashMap 用「数组 + 链表 + 红黑树」平衡平均与最坏情况:扰动函数把高位带进低位,2 的幂容量把取模变成位与,树化只在极端碰撞时兜底。
本质:HashMap 的快来自「均匀散列 + 2 的幂索引」;树化是防御哈希碰撞的保险,不是常规路径;它始终不是线程安全的。

学完你要能自己回答

  1. HashMap 的 hash 扰动为什么右移 16 位?容量为什么必须是 2 的幂?
  2. 扩容时 JDK 8 如何拆分链表?为什么不再需要重新计算 hash?
  3. 树化的精确条件是什么?为什么容量小于 64 时先扩容?
  4. JDK 8 的 HashMap 在并发下还会死循环吗?
01 数组、桶与节点02 平均 O(1),最坏 O(n) / O(log n)

数组、桶与节点01

table 是一个 Node[],每个 Node 存 hash、key、value、next。下标用 (n - 1) & hash 计算,因此容量 n 必须是 2 的幂,位与才等价于取模。

参数默认值含义
DEFAULT_INITIAL_CAPACITY16数组初始容量
DEFAULT_LOAD_FACTOR0.75阈值 = 容量 × 负载因子
MAXIMUM_CAPACITY1 << 30最大容量
TREEIFY_THRESHOLD8树化阈值常量
UNTREEIFY_THRESHOLD6扩容拆分时退回链表的阈值
MIN_TREEIFY_CAPACITY64低于它优先扩容而不是树化

构造器传入的初始容量会被 tableSizeFor 向上取整到 2 的幂;new HashMap<>(100) 的实际 table 长度是 128。

容量不是 2 的幂时,(n - 1) & hash 会丢掉部分低位,导致桶分布不均;这是 HashMap 强制 2 的幂的根本原因。

平均 O(1),最坏 O(n) / O(log n)02

哈希均匀时,每个桶平均不到一个元素,查找是 O(1)。哈希冲突时退化为链表 O(n);树化后是红黑树 O(log n)。

key 的 equals/hashCode 必须一致:相等的对象必须有相同 hashCode;参与哈希的字段不能在校验后修改,否则桶位置变了就再也找不到。

String 的 hashCode 在 JDK 8 中缓存到字段 hash;但这不意味着可以拿可变对象做 key。
01 扰动函数:把高位带下来02 扩容:翻倍与高低位拆分03 树化:极端碰撞的兜底

扰动函数:把高位带下来01

下标只用 hash 的低位(n-1 的位数)。如果 hashCode 的高位有信息、低位全 0,直接取模会全部撞到同一个桶;JDK 8 用 (h = key.hashCode()) ^ (h >>> 16) 把高 16 位异或到低 16 位。

int h = 0x00010000;
System.out.println((16 - 1) & h);
System.out.println((16 - 1) & (h ^ (h >>> 16)));
0
1

扰动不是「消除碰撞」,只是让高位参与索引;两个不同 key 仍然可能得到同一个下标。它也不能替代一个好的 hashCode。

扩容时 JDK 8 不需要重新计算 hash:Node 里存了扰动后的 hash,扩容只用 (e.hash & oldCap) == 0 判断留在低位还是高位。

扩容:翻倍与高低位拆分02

当元素数超过 threshold = capacity * loadFactor 时,容量翻倍,阈值也跟着翻倍。JDK 8 把每个旧桶拆成 lo/hi 两条链:(e.hash & oldCap) == 0 留在原下标,否则移到 原下标 + oldCap。

拆分保持链表相对顺序(头插改尾插),避免了 JDK 7 并发扩容时的环形链表;但这不是线程安全保证。

HashMap<Integer, Integer> map = new HashMap<>();
for (int i = 1; i <= 12; i++) map.put(i, i);
System.out.println(map.size()); // 12,仍未扩容
map.put(13, 13);
// table 从 16 扩到 32
扩容是 O(n) 复制;已知元素数量时按 expected / 0.75 估算容量,避免多次扩容。

树化:极端碰撞的兜底03

当某个桶的链表节点数超过阈值,treeifyBin 会把链表转成红黑树。JDK 8 的实际判断是插入后 binCount >= TREEIFY_THRESHOLD - 1:桶内已有 8 个节点、再插入第 9 个时才触发。

如果此时 table 容量小于 64,treeifyBin 不树化,而是先扩容;因为小表扩容比树化更划算。树化后查找 O(log n),但节点更大、维护成本更高。

删除导致树变小时,removeTreeNode 会按树结构退化为链表;扩容拆分时,如果树桶拆分后的节点数不超过 6,也会退回链表。

不要把「链表长度到 8 就树化」当成精确规则;阈值是 8,但触发点、容量条件和退化条件都要一起看。
01 用反射观察 table 与树化02 并发:JDK 8 没有死循环,但仍会丢数据03 容量、key 与碰撞攻击

用反射观察 table 与树化01

下面这段代码在 JDK 8 上直接读 table 字段,观察桶的节点类型和容量变化。它只用于教学,生产代码不要依赖 HashMap 的内部结构。

import java.lang.reflect.Field;
import java.util.HashMap;

public class HashMapProbe {
    static final class Key {
        final int h;
        final String name;
        Key(int h, String name) { this.h = h; this.name = name; }
        public int hashCode() { return h; }
        public boolean equals(Object o) {
            return o instanceof Key && ((Key) o).h == h
                    && ((Key) o).name.equals(name);
        }
    }

    static Object[] table(HashMap<?, ?> map) throws Exception {
        Field f = HashMap.class.getDeclaredField("table");
        f.setAccessible(true);
        return (Object[]) f.get(map);
    }

    public static void main(String[] args) throws Exception {
        HashMap<Key, String> map = new HashMap<>(64);
        for (int i = 1; i <= 8; i++) {
            map.put(new Key(1, "k" + i), "v" + i);
        }
        System.out.println("size=8 type="
                + table(map)[1].getClass().getSimpleName());

        map.put(new Key(1, "k9"), "v9");
        System.out.println("size=9 type="
                + table(map)[1].getClass().getSimpleName());

        HashMap<Integer, Integer> grow = new HashMap<>();
        for (int i = 1; i <= 12; i++) grow.put(i, i);
        System.out.println("n=12 cap=" + table(grow).length);

        grow.put(13, 13);
        System.out.println("n=13 cap=" + table(grow).length);
    }
}
size=8 type=Node
size=9 type=TreeNode
n=12 cap=16
n=13 cap=32

输出验证了两点:8 个碰撞节点仍是 Node,第 9 个才变 TreeNode;默认容量在第 13 个元素时从 16 扩到 32。

这段代码在 JDK 9+ 会因模块强封装失败;需要 --add-opens java.base/java.util=ALL-UNNAMED 才能继续反射。

并发:JDK 8 没有死循环,但仍会丢数据02

JDK 7 的扩容用头插法,两个线程同时 resize 可能把链表接成环,导致 get 死循环。JDK 8 改成尾插并保留 lo/hi 顺序,环的问题基本消失,但并发 put 仍可能丢更新、读到旧值、size 不一致。

  • modCount 的 fail-fast 只是尽力检测,不保证并发下一定抛 ConcurrentModificationException。
  • 复合操作(先 get 再 put)不是原子的;computeIfAbsent 才是原子操作。
  • 并发场景用 ConcurrentHashMap;JDK 8 用 CAS + synchronized 锁桶,不再用 Segment 分段锁。
  • Collections.synchronizedMap 只同步单个方法,复合操作仍需外部加锁。
JDK 8 修复的是「扩容成环」这个具体 bug,不是把 HashMap 变成线程安全容器。

容量、key 与碰撞攻击03

  • 预设容量按 expected / 0.75 + 1 估算,减少扩容;不要设得过大,浪费数组。
  • key 用不可变对象;equals 与 hashCode 用同一组字段。
  • hashCode 要均匀;常量 hashCode 会让所有 key 挤进一个桶,退化成链表/树。
  • 树化能防御哈希碰撞攻击,但不能防御糟糕的 key 设计;攻击者仍可用大量碰撞消耗 CPU。
  • 负载因子调低会增加空间、减少碰撞;调高会省空间、增加查找成本。
0.75 是空间与时间的折中,不要随意改;如果确实要改,先做容量规划和压测。
01 先猜再看02 能否讲清因果

先猜再看01

先猜再看容量为什么必须是 2 的幂?
因为下标用 (n - 1) & hash 代替取模;只有 n 是 2 的幂时,n-1 才是低位全 1 的掩码,位与才等价于对 n 取模。扩容时还能用 oldCap 位快速判断高低位。
先猜再看链表到 8 就一定树化吗?
不是。桶内已有 8 个节点、插入第 9 个时才触发 treeifyBin;如果容量小于 64,会先扩容而不是树化。
先猜再看JDK 8 的 HashMap 并发扩容还会死循环吗?
尾插 + lo/hi 拆分基本消除了 JDK 7 的环形链表,但仍会丢更新、读脏数据、size 不一致;它不是线程安全的。

能否讲清因果02

01. 扰动函数具体做了什么?
把 hashCode 无符号右移 16 位后与原值异或,让高 16 位参与低位的索引计算;它不消除碰撞,只是改善分布。
02. 扩容时如何判断节点留在原桶还是移到高位桶?
用 (e.hash & oldCap) == 0:为 0 留在原下标,否则移到原下标 + oldCap;因为容量翻倍后多出的那一位正好是 oldCap。
03. 树化的三个条件是什么?
桶内节点数超过 8(实际第 9 个插入触发)、table 容量至少 64、并且确实发生碰撞;容量不足 64 时先扩容。
04. 为什么 HashMap 的 key 要用不可变对象?
key 修改后 hashCode 可能变化,桶下标随之改变,但 entry 仍在旧桶里,导致 get/remove 找不到;不可变对象能保证哈希稳定。
05. JDK 8 的 ConcurrentHashMap 为什么不再用 Segment?
它改成 CAS + synchronized 锁单个桶,锁粒度更细、并发度更高;Segment 主要保留用于序列化兼容。
能说清「扰动 → 索引 → 扩容拆分 → 树化兜底」这条链,并指出树化不是第 8 个节点,这一节就过关了。
学习进度 记录保存在本机浏览器 · 不上传

JDK 学习手册 · JDK / Java 专用学习站 · 纯静态 · 双击 index.html 即可使用

本站专注 JDK 版本演进与 Java 教学,非 Oracle / OpenJDK 官方站点。版本数据来自 OpenJDK 官方 Features 表;课程示例已在对应本机 JDK 实测。构建日期 2026-11-12。