首页 / Java 学习笔记 / 01

JAVA · Vol.I · DAY 01 · 集合框架源码

HashMap 源码精讲:数组+链表+红黑树

高级必问#集合#核心#源码
第 1 站

从一道高频面试题开始

先来看一道大厂高频面试题:

"请你详细说一下 HashMap 的底层实现原理?" —— 90% 的 Java 开发者都会遇到这道题,但真正能说清楚的人不超过 30%。

这个问题看似简单,却可以层层递进:从数据结构问到哈希算法,从扩容机制问到线程安全,从 JDK 1.7 问到 1.8 的优化。面试官通过这一个问题,就能判断你的 Java 功底到底有多深。

先给你一个 30 秒版答案,后面每一站都是在给这句话"上细节":

一句话点破

HashMap 底层是数组(Node[] table)+ 链表 + 红黑树:先用扰动函数 h ^ (h >>> 16) 打散 hashCode,再用 (n - 1) & hash 定位桶下标;桶内冲突少就挂链表,链表长度 ≥ 8 且数组 ≥ 64 时树化成红黑树;当 size > capacity × 0.75 时触发 2 倍扩容,JDK 8 扩容用 hash & oldCap 分流,无需重新计算下标。

在日常工作中,HashMap 是使用频率最高的数据结构之一。无论是缓存数据、配置映射、请求参数解析、还是作为 Spring 容器的底层存储(DefaultListableBeanFactory 内部就是用 ConcurrentHashMap 存储 BeanDefinition),HashMap 的身影无处不在。

在正式开始之前,先抛几个你工作中可能遇到过的问题:

  • 为什么有时候遍历 HashMap 的顺序和插入顺序不一样?
  • 为什么阿里规约要求初始化 HashMap 时必须指定容量?
  • 为什么说 HashMap 的 key 最好用不可变对象(如 String、Integer)?
  • 线上服务突然 CPU 飙到 100%,排查后发现是 HashMap 死循环——这是怎么发生的?

带着这些问题,我们从最底层开始,一步步拆解 HashMap 的设计。

第 2 站

核心数据结构:数组 + 链表 + 红黑树

HashMap 的底层结构一句话概括:数组 + 链表 + 红黑树。这个结构不是一蹴而就的,而是在 JDK 演进中逐步优化的。

table[] 数组 —— 默认长度 16,每个位置称为一个"桶(bucket)" [0] null [1] Node(key,value) Node(key,value) Node(key,value) 链表(next 指针串联) [n] 红黑树 链表长度 < 8 链表长度 ≥ 8 JDK 1.7 数组 + 链表(头插法) JDK 1.8 数组 + 链表 + 红黑树(尾插法)
图 1HashMap 底层结构全景:数组作为主干,每个桶通过链表或红黑树解决哈希冲突

我们先看几个写死在源码里的关键常量——面试里被反复追问的"16""0.75""8""6""64"都在这里:

HashMap.java · JDK 1.8 关键常量
// 默认初始容量 = 16(1 << 4),必须是 2 的幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

// 最大容量 2^30(1<<31 会溢出为负数,所以取 30)
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认负载因子 0.75(时间与空间的折中)
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 链表转红黑树的阈值:链表长度 ≥ 8
static final int TREEIFY_THRESHOLD = 8;

// 红黑树退化回链表的阈值:节点数 ≤ 6
static final int UNTREEIFY_THRESHOLD = 6;

// 树化的另一前提:数组长度必须 ≥ 64,否则优先扩容
static final int MIN_TREEIFY_CAPACITY = 64;

再看桶里存的节点结构。普通链表节点是 Node,它就是一个最朴素的单向链表节点——保存 hash、key、value 和指向下一个节点的 next 指针:

HashMap.Node · 链表节点(实现 Map.Entry)
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;   // 缓存的 hash 值,避免重复计算
    final K key;     // key 用 final 修饰 —— 这也是不能改 key 的原因
    V value;
    Node<K,V> next;   // 指向同一个桶里的下一个节点

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
}

当链表树化后,节点会升级为 TreeNode(继承自 LinkedHashMap.Entry)。对比一下两种节点:

维度Node(链表节点)TreeNode(树节点)
字段hash、key、value、next(共 4 个)继承 Node 全部字段,另加 parent、left、right、prev、red(共 5 个)
链接方式单向:只有 next双向:prev/next 串链表,parent/left/right 建树
约占用内存约 16~32 字节(视 JVM 是否开启指针压缩)约 48 字节,约为 Node 的 2 倍
查找复杂度O(n)O(log n)

从约 16 字节的 Node 膨胀到约 48 字节的 TreeNode,这也是为什么树化要有数组 ≥ 64 的前提——避免小表里频繁树化反而浪费内存。具体数值随 JVM 是否开启指针压缩(-XX:+UseCompressedOops)而浮动,但"树节点大约是链表节点的 2 倍"这个量级是确定的。

为什么要引入红黑树?

极端情况下,所有 key 的哈希值都落到同一个桶里,链表长度会变成 n。此时 get() 的时间复杂度从 O(1) 退化为 O(n)。1.8 引入红黑树后,即使所有元素碰撞到同一个桶,查找复杂度也能保证在 O(log n)

但注意,树化有两个条件同时满足才触发:① 链表长度 ≥ 8(TREEIFY_THRESHOLD);② 数组长度 ≥ 64(MIN_TREEIFY_CAPACITY)。如果数组还很小,优先选择扩容而不是树化——因为扩容能更均匀地分散元素。这两个阈值为什么这么定,下一站专门拆。

第 3 站

阈值设计:8 / 6 / 64 / 0.75 的数字密码

面试官经常会追问一句:"为什么是 8?为什么是 0.75?"——这可不是拍脑袋定的,源码注释里白纸黑字写着依据。

为什么 TREEIFY_THRESHOLD = 8:泊松分布

TreeNode 类的 Javadoc 注释里,JDK 作者贴出了一张泊松分布的概率表。假设 hashCode 是随机均匀分布的、负载因子为 0.75,那么一个桶里链表长度为 k 的概率是:

链表长度 k出现概率说明
00.60653066超过六成的桶是空的
10.30326533约三成的桶只有 1 个元素
20.07581633——
30.01263606——
40.00157952——
50.00015795——
60.00001316——
70.00000094——
80.00000006(约 6×10⁻⁸)不足千万分之一

含义很清晰:在正常随机分布下,链表长度根本不可能涨到 8。一旦某个桶的链表真的达到 8,几乎可以断定是 hashCode 分布出了大问题(比如大量 key 碰撞、或者用了 16 的倍数做 key,见第 11 站)。此时花 2 倍内存去换 O(log n) 的查找,是划算的兜底方案。换句话说:8 这个阈值,平时永远用不到,用到就是"救火"

为什么 MIN_TREEIFY_CAPACITY = 64:双重检查

树化还有一个前置条件——数组长度 ≥ 64。因为 TreeNode 的内存约为 Node 的 2 倍,在小表(如容量 16)上树化,内存开销大、收益小;而扩容只需要 O(n) 搬移、且能让元素重新均匀分布,比树化更"划算"。所以源码里 treeifyBin() 会做一次双重检查:数组不够大就直接扩容,不树化(完整源码在第 6 站)。

为什么 UNTREEIFY_THRESHOLD = 6:滞回(hysteresis)设计

树化阈值是 8,退化阈值却是 6,中间留了 7 这个缓冲带。这是典型的滞回设计:

后端类比:滞回

想象一台空调:26° 开启制冷、25.5° 关闭——如果开关温度设成同一个值,室温会在临界点反复震荡,压缩机频繁启停报废。数据库连接池的 max/min、负载均衡的熔断恢复阈值,用的都是同一个思想。如果树化/退化都用 8,那么在 8 附近增删元素会导致链表↔树来回切换,白白浪费转换成本。

所以真实语义是:链表涨到 ≥ 8 才树化;扩容拆分后节点 ≤ 6 才退化回链表UNTREEIFY_THRESHOLD 真正用武之地在 TreeNode.split(),第 6 站见源码)。

为什么 DEFAULT_LOAD_FACTOR = 0.75

  • 1.0:空间利用率 100%,但碰撞严重、查询变慢,还要频繁处理长链表;
  • 0.5:碰撞很少,但一半空间浪费,扩容频繁;
  • 0.75:时间与空间的折中点。源码注释的原话是 "offers a good tradeoff between time and space costs",同时在这个负载因子下,泊松分布的参数 λ ≈ 0.5,正好引出"链表长度 8 的概率不足千万分之一"——0.75 和 8 是配套设计的

默认容量 16 同样有讲究:太小(如 4)会频繁扩容,太大(如 1024)浪费内存;16 是 2 的 4 次方,配合位运算定位恰好合适。而 MAXIMUM_CAPACITY = 1 << 30 是因为 Java 的 int 最高位是符号位,1 << 31 会变成负数,数组长度也受 JVM 限制,所以取 2³⁰。

插入后:链表长度 ≥ 8 ? 数组长度 ≥ 64 ? 不树化 resize() 扩容,重新分散 treeifyBin() → 红黑树(O(log n)) 扩容时 split 拆分 拆分后节点 ≤ 6 ? untreeify() 退化为链表 省内存(Node 更小) 保持红黑树 仍是 O(log n) 链表节点数: 1 ── 6 ── 7 ── 8 ── 9 … ≤ 6 退化为链表 ≥ 8 树化(还需容量 ≥ 64)
图 2树化 / 退化的完整触发链路:8 与 64 同时满足才树化,6 是滞回缓冲
核心要点
  • 8:泊松分布下"正常情况几乎不可能出现"的长度,触发即救火;
  • 64:TreeNode 更占内存,小表优先扩容而非树化;
  • 6:与 8 之间留 2 个缓冲,防止链表↔树反复切换(滞回);
  • 0.75:时间/空间折中,与 λ≈0.5 的泊松分布、阈值 8 配套设计。
第 4 站

Hash 计算:扰动函数与 (n-1) & hash 下标定位

HashMap 的核心是哈希函数——如何把一个任意对象映射到数组的某个下标上。一个好的哈希函数应该让元素均匀分布在各个桶中,最大限度地减少碰撞。

JDK 1.8 的哈希计算分为两步:

第一步:扰动函数
hash = key.hashCode() ^ (key.hashCode() >>> 16)

第二步:取模定位
index = hash & (table.length - 1)
key.hashCode() 例如: 12345678 扰动: h ^ (h >>> 16) 高16位 ⊕ 低16位 hash & (n - 1) 等价于 hash % n(但更快) 为什么 hashCode 需要"扰动"? 如果直接用 hashCode() 的低位来决定桶下标 —— 而 hashCode() 的高位变化大、低位可能相似 扰动函数让高位也参与运算(高16位异或低16位),使最终的下标更"随机"、碰撞更少 这就是面试时要说的"为什么不是直接 hashCode % n"
图 3HashMap 的哈希计算:先扰动、再定位,让你的 key 均匀散落在数组各处

来看 hash() 方法的真实源码,整个扰动只有一行:

HashMap.hash() · 扰动函数
static final int hash(Object key) {
    int h;
    // key 为 null 时 hash = 0 —— 这就是 HashMap 允许 null key 的原因,
    // 且 null key 永远落在 0 号桶(0 & (n-1) == 0)
    return (key == null) ? 0
         : (h = key.hashCode()) ^ (h >>> 16);
    //       ↑ 取原始 hashCode      ↑ 高 16 位无符号右移后异或
}

h ^ (h >>> 16) 的含义:把 32 位 hashCode 的高 16 位右移下来,和低 16 位做异或。这样高位的信息被"混入"了低位。因为后面定位桶用的是 hash & (n-1),当 n 较小时(比如 16),只有最低 4 位参与运算——如果不扰动,高位再不一样的两个 key 也容易碰撞。

用一组具体数字验证扰动的作用(n = 16,只看低 4 位):

key 的 hashCode不扰动:hashCode & 15扰动后:h ^ (h>>>16)扰动后 & 15
0x1234567880x1234444C12(0xC)
0xABCD56788 ← 撞车0xABCDFDB55 ← 错开了

两个 hashCode 的低 16 位都是 0x5678,不扰动时必然撞进同一个桶;扰动后高位信息混入低位,一个落 12 号桶、一个落 5 号桶,碰撞被化解了

追问:扰动为什么用异或 ^,不用 & 或 |?为什么右移 16 位而不是 8 位?
点破

为什么用 ^& 的结果偏向 0(任一操作数为 0 结果就是 0),| 偏向 1,只有 ^ 让每一位 0/1 各占一半,分布最均匀;② 为什么是 16:32 位整数正好一分为二,把高 16 位完整地"折叠"进低 16 位,信息损失最小。顺便对比:JDK 7 的扰动做了 4 次(^ (h>>>20) ^ (h>>>12)^ (h>>>7) ^ (h>>>4)),JDK 8 精简为 1 次——因为 JDK 8 的红黑树兜底让"更弱的扰动"也可接受,而 1 次异或性能更好。

定位桶下标的代码藏在 putVal() 里,就是那个经典的 (n - 1) & hash

HashMap.putVal() · 桶定位片段
// n 是数组长度,tab 是 table 数组
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
// (n-1) & hash 等价于 hash % n,但位运算比取模快得多
思考:为什么 table.length 必须是 2 的幂?

因为当 n = 2k 时,n - 1 的二进制形式是 k 个连续的 1(比如 16-1=15=1111₂),这样 hash & (n-1) 的结果就只在 [0, n-1] 范围内均匀分布。如果 n 不是 2 的幂,n-1 的二进制中会有 0 位,导致某些桶永远分不到元素,浪费空间且增大碰撞。

其实 2 的幂还有一个更关键的用途:扩容时可以用 hash & oldCap 一步判断新位置(第 7 站详讲),这是 JDK 8 不用重新计算下标的前提。所以"必须是 2 的幂"不是洁癖,是两处优化的地基。

那用户传入的容量不是 2 的幂怎么办?构造器里用 tableSizeFor() 强制纠正:

HashMap.tableSizeFor() · 把任意容量变成 ≥ 它的最小 2 的幂
static final int tableSizeFor(int cap) {
    int n = cap - 1;          // 先减 1:防止 cap 本身是 2 的幂时结果翻倍
    n |= n >>> 1;              // 从最高位的 1 开始,向右全部"涂"成 1
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1
         : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
// 例:cap=7  → n=6(0110) → 涂完 7(0111) → 返回 8
// 例:cap=16 → n=15(1111) → 返回 16(不会错误地变成 32)

实战 Tips:面试时你可以补充"这就是为什么阿里规约要求 new HashMap(n) 时,建议传 (int) (n / 0.75 + 1),这样既避免了扩容,又保证了容量会被 tableSizeFor() 自动纠正为 2 的幂"。比如 new HashMap(7) 实际容量是 8,而不是 7。

第 5 站

put() 完整流程:putVal 源码逐行拆解

面试官说:"你画一下 HashMap 的 put 流程。" —— 这是一个经典的"白板编程题"。我们通过一张图把整个过程串起来:

put(key, value) table 为空?→ resize() 初始化 hash(key) 扰动 + 定位桶下标 i table[i] == null? 是 → ③ 直接插入 否 ↓ ④⑤ 遍历链表/红黑树 key 相同 → 覆盖 value(onlyIfAbsent 为 false 时) 尾插法:追加到链表末尾(1.7 是头插法) 链表长度 ≥ 8?→ treeifyBin() ++size > threshold?→ resize() return oldValue
图 4HashMap put() 的完整流程图 —— 一次记不住没关系,理解逻辑比背步骤更重要

入口 put() 本身极薄,真正干活的是 putVal()

HashMap.put() · 入口与参数含义
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}
// onlyIfAbsent=true 时不覆盖旧值 —— putIfAbsent() 就是走这个参数
public V putIfAbsent(K key, V value) {
    return putVal(hash(key), key, value, true, true);
}
// evict 参数服务于 LinkedHashMap 的"访问顺序"LRU 扩展,HashMap 不用

下面是 putVal() 的核心源码(含红黑树分支),图 4 的 8 个步骤都能在这里一一对应上:

HashMap.putVal() · 插入主流程(JDK 1.8)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;

    // ① table 为空 → 触发 resize() 初始化(懒加载:构造时不建数组)
    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;
        // ④ 桶第一个节点 key 就相同 → 记下,待会儿覆盖
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)   // ⑤ 红黑树分支 → putTreeVal
            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);
                    // ⑦ 链表长度 ≥ 8 → 尝试树化(treeifyBin 内部还会校验容量 ≥ 64)
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;  // 链表中找到相同 key
                p = e;
            }
        }
        // 找到了相同 key 的节点 → 覆盖 value
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            return oldValue;   // 覆盖旧值:返回旧值
        }
    }
    ++modCount;    // 结构性修改计数,fail-fast 依赖它(见第 10 站)
    // ⑧ size 超过阈值 → 扩容
    if (++size > threshold)
        resize();
    return null;   // 新增 key:返回 null
}

几个容易忽略的细节:

  • binCount 的计数起点:它从 0 开始,数的是"桶头节点 p 之后"的节点。当追加第 8 个节点时 binCount == 7 == TREEIFY_THRESHOLD - 1,触发 treeifyBin()——注意这只是"申请",真正建树前还要过容量 ≥ 64 这一关。
  • 返回值语义:覆盖已存在的 key 返回旧值,新增 key 返回 null。但如果旧值本身就是 null,返回值也是 null——所以不能用返回值区分"新增"和"覆盖为 null"。
  • ⑤ 红黑树分支putTreeVal() 在树里按红黑树规则插入;若 key 没实现 Comparable,会退化用 System.identityHashCode 兜底比较(tieBreakOrder)。

重点理解第 ④ 步中 key 相同的判断逻辑(注意源码里的短路顺序):

p.hash == hash && (p.key == key || key.equals(p.key))

先比较 hash(快,int 比较)→ 再比较 ==(引用相同,最快)→ 最后才 equals()(可能较慢)。这是典型的"先用低成本过滤,再用高成本确认"。也正因为这个顺序,hashCode 不同的两个对象,HashMap 直接认为它们不相等,根本不会调 equals() ——这就是必须同时正确实现 hashCode 和 equals 的根本原因。

不可变 key 的重要性

如果你用可变对象做 key,并且修改了影响 hashCode 的字段,那么 HashMap 就再也找不到这个 entry 了——它还在原来的桶里,但 containsKey() 返回 false。这就是为什么推荐用 String、Integer 等不可变对象做 key。这也解释了 Node.key 为什么是 final:源码层面根本不打算让你改 key。

第 6 站

树化与退化:treeifyBin 与 split 方法级源码

上一站看到 putVal() 在链表第 8 个节点插入时调用 treeifyBin(tab, hash)。但树化不是"说树就树"的,treeifyBin() 内部会做双重检查

HashMap.treeifyBin() · 树化入口(含 64 双重检查)
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) {
        TreeNode<K,V> hd = null, tl = null;
        // 双重检查②:先把链表节点逐个替换成 TreeNode,串成双向链表
        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);   // 真正构建红黑树(按 hash 建树、旋转染色)
    }
}

所以链表转红黑树要同时满足:putVal 侧的"长度 ≥ 8" + treeifyBin 侧的"容量 ≥ 64",缺一个都不树化。这也是面试官最爱挖的坑:"链表 9 个节点就一定会树化吗?" —— 答:不一定,容量不足 64 时走的是扩容。

为什么"扩容优先"

容量 < 64 说明元素总量小(最多 64×0.75 ≈ 48 个),此时扩容成本极低,而且扩容后 hash & oldCap 会把一个长链拆成两半,问题迎刃而解;树化反而要付出 2 倍内存的 TreeNode 和旋转开销。类比后端:缓存容量还很大时,与其给单个热点 key 上复杂的淘汰策略,不如直接加实例扩容分流。

那么红黑树什么时候退化为链表?入口在扩容时:resize() 遇到树节点桶会调用 TreeNode.split(),它把一棵树按 hash & oldCap 拆成 lo/hi 两棵子树,拆完如果哪边节点数 ≤ 6,就直接退化成链表——这就是 UNTREEIFY_THRESHOLD = 6 真正的用武之地

HashMap.TreeNode.split() · 扩容时的树拆分(UNTREEIFY_THRESHOLD 的战场)
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    // bit = oldCap(旧容量),与链表拆分同一个判断:hash & oldCap
    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) {     // 新增位为 0 → 留原位 index
            if ((e.prev = loTail) == null) loHead = e;
            else loTail.next = e;
            loTail = e; ++lc;
        } else {                           // 新增位为 1 → 移 index + oldCap
            if ((e.prev = hiTail) == null) hiHead = e;
            else hiTail.next = e;
            hiTail = e; ++hc;
        }
    }

    if (loHead != null) {
        // 关键:拆完 ≤ 6 个 → untreeify 退化为链表(省内存)
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else { tab[index] = loHead; if (hiHead != null) loHead.treeify(tab); }
    }
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else { tab[index + bit] = hiHead; if (loHead != null) hiHead.treeify(tab); }
    }
}

补充一个边界:除了扩容退化,remove() 删节点时如果树被删得"太扁"(根节点都没有右子树之类的结构判断),removeTreeNode() 也会直接 untreeify() 退化。也就是说退化路径有两条:扩容 split 按 6 判断、删除按结构判断,但"≤ 6"这个数字只出现在 split 里。

顺便把红黑树"是什么"补一句,面试被追问时不至于卡壳:红黑树是自平衡二叉查找树,5 条性质——① 节点非红即黑;② 根是黑;③ 叶子(null)是黑;④ 红节点的两个孩子必须是黑(不能出现连续红);⑤ 从任一节点到其每个叶子的所有路径,黑色节点数相同。这 5 条保证了树高 ≤ 2×log₂(n+1),所以查找稳定在 O(log n)。

核心要点
  • 树化双条件:putVal 里长度 ≥ 8(binCount ≥ TREEIFY_THRESHOLD - 1)+ treeifyBin 里容量 ≥ 64;
  • 容量不足 64 时"扩容优先于树化",因为扩容能拆散长链且成本低;
  • UNTREEIFY_THRESHOLD = 6 用在 TreeNode.split():扩容拆分后 ≤ 6 个节点就退化为链表;
  • 红黑树 5 条性质保证树高 O(log n),是"最坏情况"的兜底。
第 7 站

扩容 resize():2 倍扩容与 lo/hi 拆分

扩容是 HashMap 中最"贵"的操作——需要搬移已有元素。触发条件是 size > capacity × loadFactor,默认 16 × 0.75 = 12,即第 13 个元素插入时触发扩容。先看 resize() 的前半段——容量与阈值的计算:

HashMap.resize() · 新容量/新阈值计算(JDK 1.8)
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;

    if (oldCap > 0) {                          // ① 常规扩容:容量 × 2
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;       // 到顶了:不再扩容,阈值放开
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY
                 && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;                  // 阈值也翻倍:12 → 24
    }
    else if (oldThr > 0)                        // ② 构造时指定过容量:
        newCap = oldThr;                              //    容量暂存在 threshold 里,首次 resize 拿来建数组
    else {                                          // ③ 无参构造:用默认值
        newCap = DEFAULT_INITIAL_CAPACITY;            // 16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
    }
    if (newThr == 0) {                            // ② 分支的阈值在这里补算
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY
                  ? (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;                                   // 新数组挂上去(后续再逐个搬元素)

这一段信息量很大,拆三点:

  • 为什么容量能暂存在 threshold 里:构造器只算容量、不建数组(懒加载),于是把 tableSizeFor() 的结果先塞进 threshold,等第一次 put() 触发 resize 时再"物归原主"。这也是为什么 new HashMap(7) 第一次 put 才真正分配 8 长度的数组。
  • 阈值翻倍的前提:只有 oldCap ≥ 16 才走 newThr = oldThr << 1;如果旧容量是 4/8 这类"用户指定的小容量",newThr 保持 0,走下面 ft = newCap × loadFactor 重新算——因为此时旧阈值本来就不是"容量 × 0.75"(它被容量占用了)。
  • 到顶保护:容量 ≥ 2³⁰ 时不再扩容,把 threshold 放开到 Integer.MAX_VALUE,让 map 继续能存数据。

再看后半段——元素搬移。这是 JDK 8 最精妙的部分:不需要重新计算每个元素的 hash。因为容量始终是 2 的幂,扩容 16 → 32 相当于掩码多出最高一位(1111₂ → 11111₂),每个元素的新下标只取决于 hash 新增的那一位是 0 还是 1:

  • 如果 hash & oldCap == 0:新增位是 0,元素在新表中的位置不变
  • 如果 hash & oldCap != 0:新增位是 1,元素的新位置 = 原位 + oldCap

所以 1.8 扩容时把每个桶的链表拆成两条:"低位链表"(lo,位置不变)"高位链表"(hi,位置 + oldCap),分别挂到新数组的两个位置上。整个搬移过程没有一次取模、没有一次重新调用 hashCode。面试时说到这个细节,面试官就知道你真正读过源码。

扩容前:oldCap = 16,桶 [5] 挂着 4 个节点 [5] A hash&16=0 B hash&16=16 C hash&16=0 D hash&16=16 逐个用 hash & 16 分流(尾插保持顺序) 扩容后:newCap = 32,一个桶变两个桶 [5] A C loHead/loTail(原位) [21] B D hiHead/hiTail(原位+16) 规律:hash 新增的那一位是 0 → 留在原位;是 1 → 挪到 原位 + oldCap(5 → 21) 全程只做了一次位运算 hash & oldCap,没有取模、没有重新 hashCode
图 5JDK 8 扩容:一个桶按 hash & oldCap 拆成"原位"与"原位+oldCap"两个桶

这段"低位 / 高位链表拆分"的逻辑,对应 resize() 里的经典源码:

HashMap.resize() · 链表 rehash 片段(JDK 1.8)
// 桶里只有 1 个节点:直接按新掩码定位(还是位运算,无取模)
if (e.next == null)
    newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);   // 树桶 → 第 6 站
else {
    // loHead/loTail:低位链表(留在原索引 j)
    // hiHead/hiTail:高位链表(移到索引 j + oldCap)
    Node<K,V> loHead = null, loTail = null;
    Node<K,V> hiHead = null, hiTail = null;
    Node<K,V> next;
    do {
        next = e.next;
        // 关键:用 hash & oldCap 判断新增的那一位是 0 还是 1
        if ((e.hash & oldCap) == 0) {
            if (loTail == null) loHead = e;
            else loTail.next = e;
            loTail = e;        // 尾插,保持原有顺序
        } else {
            if (hiTail == null) hiHead = e;
            else hiTail.next = e;
            hiTail = e;
        }
    } while ((e = next) != null);

    // 低位链表:原位 j
    if (loTail != null) { loTail.next = null; newTab[j] = loHead; }
    // 高位链表:新位 j + oldCap
    if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }
}

对比:HashMap ×2 vs ArrayList ×1.5

面试常把两个"扩容"放一起问,一张表说清:

维度HashMapArrayList
扩容倍数×2(oldCap << 1×1.5(old + (old >> 1),见 grow()
容量约束必须是 2 的幂任意正整数(默认 10)
底层结构Node[] 哈希桶Object[] 连续数组
为什么这个倍数必须保持 2 的幂,才能用 (n-1)&hashhash&oldCap 优化无位运算约束;1.5 倍在"扩容次数"与"平均内存浪费"之间折中(×2 浪费更多空间,×1.25 扩容太频繁)
搬移成本O(n) 逐桶拆链(节点引用搬家)O(n) 整块 Arrays.copyOf(内存拷贝)
实战思考:如果预知要存 1000 个元素,HashMap 初始容量应该设多少?

答案:2048(不是 1024)。公式:capacity = tableSizeFor((int)(expectedSize / 0.75 + 1)) = tableSizeFor(1334) = 2048。这样整个插入过程中一次扩容都不会发生(2048 × 0.75 = 1536 > 1000)。如果你设 1024,容量不够大,还是会触发 1-2 次扩容——扩容要搬移全部元素,批量加载场景下是实打实的 CPU 与 GC 开销。

第 8 站

JDK 7 头插 vs JDK 8 尾插:一次历史性的修复

1.8 扩容时用尾插保持顺序,1.7 却是头插、顺序会反转。这看起来只是"插在哪头"的差异,背后却是当年一起线上事故的根因。

JDK 1.7 的扩容:transfer + 头插

1.7 的扩容是 resize() + transfer():创建 2 倍大小的新数组,然后逐元素重新计算下标,再用头插法放进新数组:

// JDK 1.7 transfer 核心逻辑(简化)
for (Entry<K,V> e : oldTable) {
  while (e != null) {
    Entry<K,V> next = e.next;  // 先存下一个
    int i = indexFor(e.hash, newCapacity); // 重新计算下标
    e.next = newTable[i];  // 头插:当前节点指向桶里的旧头
    newTable[i] = e;     // 当前节点成为新的头
    e = next;
  }
}

两个关键差异:

  • 逐元素重新计算下标:每个元素都要过一遍 indexFor(e.hash, newCapacity),虽然只是位运算,但 1.8 连这一步都省了(用 hash & oldCap 一次分流)。
  • 头插导致链表逆序:新元素插在桶的头部,所以原本 A→B→C 的链表,扩容后会变成 C→B→A
JDK 1.7 头插 旧桶: A B C 逐个头插,先插的会被后插的挤到后面 新桶: C B A 顺序反转:A→B→C 变 C→B→A 并发下两个线程交错 transfer → 环形链表 → get() 死循环 → CPU 100% JDK 1.8 尾插 旧桶: A B C loTail.next = e 逐个追加到尾部 新桶: A B C 顺序保持:A→B→C 还是 A→B→C 尾插 + lo/hi 拆分 → 不会形成环形链表 死循环问题在 1.8 被根治
图 6头插 vs 尾插:同样是扩容,一个顺序反转,一个原封不动

JDK 7 之所以用头插,是因为实现最简单:新节点直接挂桶头,不需要维护尾指针。但代价是扩容时链表逆序——而"逆序 + 节点引用交错"在多线程并发扩容时就会形成环形链表(两个线程同时 transfer 同一链表,线程 A 挂起,线程 B 迁移完(已逆序),线程 A 恢复后用旧的 next 引用继续头插,链就环上了)。环形链表一旦形成,后续 get() 在环里永远走不到 null,CPU 直接 100%。这个事故细节第 10 站展开。

1.8 改为尾插,配合 lo/hi 拆分保持链表顺序,从根上消除了环形链表。对比一下 1.7 与 1.8 的全貌:

维度JDK 1.7JDK 1.8
数据结构数组 + 链表数组 + 链表 + 红黑树
插入方式头插尾插
hash 扰动4 次:^(h>>>20) ^ (h>>>12) ^ (h>>>7) ^ (h>>>4)1 次:h ^ (h>>>16)
扩容下标计算每个元素 indexFor() 重新算hash & oldCap 一次分流,无需重算
扩容后链表顺序逆序保持原序
并发扩容可能环形链表 → get 死循环 CPU 100%不再死循环(但仍不安全,见第 10 站)
追问:1.8 用了尾插,就线程安全了吗?

不是。尾插只是治好了"死循环",但并发 put() 依然会丢数据:两个线程同时发现某个桶为空,各自 new 一个节点往同一位置写,后写的覆盖先写的;size++ 也不是原子的,计数会错;扩容时两个线程同时 resize 会互相覆盖数组引用。所以 1.8 的 HashMap 依然绝不能用于并发——并发场景只有一个答案:ConcurrentHashMap

第 9 站

get() 读路径:为什么平均是 O(1)

读操作是 HashMap 最频繁的路径,核心方法 getNode()

HashMap.get() / getNode() · 读路径(JDK 1.8)
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // ① 表非空 && 桶非空(桶空直接返回 null,最快的失败路径)
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // ② 先比桶首节点(命中率最高,因为最近插入/访问的元素多在桶头附近)
        if (first.hash == hash &&
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            // ③ 树桶 → 树内查找,O(log n)
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // ④ 链表 → 逐个比较,直到命中或走到 null
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;   // 没找到
}

读路径和写路径共享同一个 key 比较逻辑:先比 hash(int 相等),再比 ==,最后 equals(),层层过滤。复杂度结论:

场景get() 复杂度说明
理想(hash 均匀)O(1)桶首节点直接命中,只比一次 hash
桶内是链表O(链长) ≤ O(n)平均很短,最坏退化
桶内是红黑树O(log n)即使全部碰撞也有兜底

所以"HashMap 是 O(1)"这个说法要严谨:平均 O(1),最坏 O(log n)(树化后),完全退化才是 O(n)。树化 + 良好扰动正是为了把"最坏情况"从 O(n) 压到 O(log n)。

追问:为什么重写 equals() 必须重写 hashCode()?

HashMap 用 hashCode() 定位桶、用 equals() 在桶内查找。如果你只重写了 equals() 不重写 hashCode(),那么两个逻辑上相同的对象可能落在不同桶里——HashMap 永远找不到它。这是 Java 开发中最常见的 bug 之一。

一个让后端崩溃的真实场景

把数据库查询结果按"订单号"聚合到 HashMap<OrderKey, List>,OrderKey 只重写了 equals() 没重写 hashCode()。结果同样的订单号查出两次,两次都落不同桶,containsKey() 永远 false,数据被重复聚合、报表翻倍——排查了半天才发现是 hashCode 契约被破坏。这就是阿里规约里"重写 equals 必须重写 hashCode"的来由。

还有一个小坑:get() 返回 null 可能是"key 不存在",也可能是"value 就是 null"。要区分必须用 containsKey()(它走同一个 getNode(),只看节点是否存在)。

核心要点
  • get 与 put 共用同一套"hash → 桶 → 桶内查找"路径,比较顺序永远是 hash → == → equals;
  • 平均 O(1)、树桶 O(log n),前提是 hashCode 分布均匀;
  • equals/hashCode 契约必须同时遵守,否则 HashMap 形同虚设;
  • get 返回 null 有歧义,判断 key 是否存在用 containsKey()。
第 10 站

线程安全:死循环、fail-fast 与并发选型

真实生产事故

某电商系统在"双十一"促销期间,多个线程同时往一个全局 HashMap 中写入缓存数据。JDK 1.7 环境下运行一段时间后,服务器 CPU 突然飙到 100%。排查发现:HashMap 在并发扩容时形成了环形链表,后续的 get() 操作陷入死循环。最终通过重启恢复,紧急将缓存容器替换为 ConcurrentHashMap。

环形链表的形成(JDK 1.7 头插法 + 并发扩容) 线程 A(扩容中,头插法转移): A→B B→C C→null 线程 B(同时扩容,交错操作后): B→A A→B ⚠ B → A → B → A … 死循环!
图 7JDK 1.7 并发扩容时头插法导致的环形链表形成过程

1.8 虽然根治了死循环,但并发下还有两处"软伤":

  • 数据丢失:两个线程同时命中空桶各自 new 节点,后写覆盖先写;
  • 计数错乱++size 非原子,多线程累加会丢更新,导致扩容时机漂移。

fail-fast:遍历时的保护机制

HashMap 不是线程安全,但它在遍历时有个防御机制——modCount。每次结构性修改(put 新增、remove、clear)都会 ++modCount;迭代器创建时记录期望值,每次 next() 校验,不一致立刻抛 ConcurrentModificationException

迭代器校验 · fail-fast · 边遍历边删的正确姿势
// ✗ 错误:遍历中直接 remove → ConcurrentModificationException
for (String key : map.keySet()) {
    if (shouldRemove(key)) map.remove(key);
}

// ✓ 正确 1:用 Iterator.remove()(会同步维护 modCount)
Iterator<String> it = map.keySet().iterator();
while (it.hasNext()) {
    if (shouldRemove(it.next())) it.remove();
}

// ✓ 正确 2:JDK 8+ 用 removeIf(内部就是迭代器)
map.keySet().removeIf(key -> shouldRemove(key));

注意:fail-fast 只是"尽量早失败",它不保证任何一致性,更不能用来实现线程安全——它在单线程误删时给你报错,仅此而已。

并发方案对比

生产环境方案对比
维度HashMapHashtableConcurrentHashMap (JDK 8)
线程安全❌ 否✅ 全表 synchronized✅ CAS + synchronized(锁桶头节点)
null key/value✅ key 最多 1 个 null,value 不限❌ 都不允许❌ 都不允许
初始容量 / 扩容16 / ×211 / ×2+116 / ×2
容量形式2 的幂素数(对取模 % 友好)2 的幂
定位方式(n-1) & hash(hash & 0x7FFFFFFF) % len(n-1) & hash
锁粒度整表(所有方法 synchronized)桶头节点(粒度到桶)
结论单线程首选❌ 已淘汰✅ 并发首选

补充几个易混点:Hashtable 不允许 null 是因为它直接调用 key.hashCode() 而没做 null 保护;ConcurrentHashMap 不允许 null 则是因为并发下无法区分"value 是 null"与"节点不存在",get() 返回 null 的语义会变得不可靠。Hashtable 初始容量 11、扩容 ×2+1,是因为它用 % 取模,素数容量能让取模分布更均匀——这是和 HashMap 完全不同的设计路线。

第 11 站

面试追问 & 生产最佳实践

追问 1:HashMap 和 Hashtable 的区别?

HashMap:非线程安全 | 允许 null key/value | 初始 16 | 扩容 ×2 | 1.8 引入红黑树
Hashtable:线程安全(synchronized) | 不允许 null | 初始 11 | 扩容 ×2+1 | 只有链表

追问 2:loadFactor 为什么默认是 0.75?

这是空间与时间的折中,详细推导见第 3 站:

  • 1.0:空间满,碰撞严重,查询慢
  • 0.5:碰撞少,一半空间浪费
  • 0.75:泊松分布下,桶中元素超过 8 个的概率低于千万分之一(源码注释可查),与树化阈值 8 配套

追问 3:HashMap 遍历顺序为什么不稳定?

桶下标由 hash & (n-1) 决定,而 n(容量)会随扩容变化:扩容后元素下标重新分布(lo/hi 拆分),插入顺序自然被打乱。即使不扩容,不同 key 的 hash 落点也没有任何顺序可言。需要稳定顺序就用 LinkedHashMap(插入序/访问序)或 TreeMap(key 序),别指望 HashMap 保序。

生产环境 Checklist

✅ 最佳实践
  • 指定初始容量new HashMap((int)(expectedSize / 0.75 + 1)),批量加载前先算好,避免反复扩容搬移;
  • 用不可变对象做 key:String、Integer、LocalDate 等;可变 key 改字段后 get() 永远找不到;
  • 重写 equals() 必须重写 hashCode():IDEA/Lombok 可自动生成;
  • 并发场景用 ConcurrentHashMap:不要在并发环境用 HashMap(1.8 不死循环了,但会丢数据);
  • 遍历删除用 Iterator.remove() / removeIf():直接 map.remove() 会抛 ConcurrentModificationException;
  • 需要顺序用 LinkedHashMap:比如做 LRU 缓存(accessOrder=true + 重写 removeEldestEntry);
  • 序列化注意:确保 key 和 value 都实现了 Serializable;
  • 警惕"规律性"的 key:如 16 的倍数(0、16、32…)在容量 16 时全落 0 号桶,扰动函数救不了这类低位恒为 0 的 hashCode,分布会塌缩成单链——这也是为什么说"hashCode 的均匀性靠业务方保证"。
后端类比:把 HashMap 想成一个"服务"

桶 = 数据库分片,扰动函数 = 分片键的散列算法,扩容 = 分库分表,红黑树 = 热点分片上的二级兜底(如缓存 + 索引)。好的分片键让流量均匀;分片键选得差(低位规律),一个分片被打爆——和 16 倍数 key 挤进 0 号桶是同一个道理。理解了 HashMap 的权衡,你就理解了分布式系统的权衡。

总结

这一篇你掌握了什么

核心知识点回顾

  • 数据结构:JDK 1.7 数组+链表(头插法)→ JDK 1.8 数组+链表+红黑树(尾插法);Node 与 TreeNode 是两代节点,内存约差 2 倍;
  • 哈希计算:扰动函数 h ^ (h>>>16)(JDK 7 是 4 次扰动)+ 位运算取模 hash & (n-1),n 必须是 2 的幂(tableSizeFor 保证);
  • 阈值设计:树化 8 且 64(泊松分布 + 内存权衡),退化 6(滞回缓冲),负载因子 0.75(时间/空间折中,λ≈0.5);
  • put 流程:判空初始化 → 定位桶 → 桶空直插 / 桶内遍历 → 覆盖或尾插 → 触发 treeifyBin → 超阈值 resize;put 返回旧值、新增返回 null;
  • 扩容优化:1.8 无需重新 hash,hash & oldCap 直接决定新位置(0→原位,1→原位+oldCap),lo/hi 两条链尾插保序;HashMap ×2 与 ArrayList ×1.5 原因不同;
  • 线程安全:1.7 头插法并发扩容可形成环形链表死循环(CPU 100%),1.8 尾插法根治;但 1.8 仍会丢数据/计数错乱,并发必须用 ConcurrentHashMap;遍历有 fail-fast 保护(modCount);
  • 最佳实践:指定初始容量 (expectedSize / 0.75 + 1)、不可变 key、equals/hashCode 契约、遍历删除用迭代器、警惕低位规律的 key。

一句话总结

HashMap = 用位运算换速度(扰动 + 掩码定位)、用阈值换平衡(8/64 树化、6 退化、0.75 负载)、用尾插换安全(1.8 修复死循环)。每一个数字、每一次迁移都不是偶然,背后都是"时间换空间、空间换时间"的经典权衡。

👉 下一篇:ConcurrentHashMap —— 从分段锁到 CAS + synchronized 的演进

Comments · 评论