HashMap 源码深度拆解(JDK 7→8)

概述

前文《ArrayList 与 LinkedList 源码全景》建立了线性集合的两个极端:ArrayList 的连续内存 O(1) 随机访问,LinkedList 的离散内存 O(1) 头尾插入。HashMap 则是一个精妙的折中------通过哈希函数 将键映射到数组索引,实现"近似 O(1)"的随机访问;当哈希碰撞发生,再用链表(JDK 8 升级为红黑树)解决冲突。可以说,HashMap = 数组 + 链表/红黑树,是前文两种数据结构的有机结合。理解了前文的扩容代价(Arrays.copyOfO(n))和节点操作(断链重连),才能理解本文的 resize 高低位拆分和 JDK 7 死链的节点指针操作。

几乎每个 Java 开发者都知道 HashMap 的底层是数组+链表,JDK 8 引入了红黑树优化,JDK 7 并发扩容会导致死循环 CPU 100%。但真正能解释"为什么容量必须是 2 的幂 "、"(n-1)&hash 为什么等价于取模 "、"hash 扰动函数为什么要将高 16 位异或低 16 位 "、"JDK 7 头插法为什么会导致环形链表 "、"为什么 loadFactor 是 0.75 而不是 0.5 或 0.9 "的人,少之又少。本文不会重复"HashMap 原理总结",而是从每个位运算的数学依据 出发,到 JDK 7 到 JDK 8 源码行级对比 的演进逻辑,再到 loadFactor 背后的泊松分布推导,让读者对 HashMap 的理解从"会用"升级到"能设计"。

核心要点:

  • 底层结构Node[] 数组 + Node 链表/红黑树,容量必须为 2 的幂
  • 哈希扰动(h = key.hashCode()) ^ (h >>> 16),高 16 位混入低 16 位
  • 索引计算(n - 1) & hash,利用 2 的幂特性等价取模
  • 扩容机制 :2 倍扩容,尾插法拆分为高低位两条链表
  • JDK 7 死链头插法 + 并发扩容 → 链表反转 → 环形链表 → CPU 100%
  • loadFactor 0.75:泊松分布------链表长度 8 概率 < 千万分之一

文章组织架构图:

flowchart TD A["1. 底层结构: Node[]数组 + 2的幂容量约束"] --> B["2. 哈希函数: 扰动函数与索引计算数学原理"] B --> C["3. put流程: 碰撞处理与链表遍历"] C --> D["4. 扩容机制: 尾插法与高低位链表拆分"] D --> E["5. JDK 7→8 演进: 头插法死链推演与尾插法改进"] E --> F["6. loadFactor 调优: 泊松分布与工程实践"] F --> G["7. 工程实战: 内存占用与反模式"] G --> H["8. 系统设计视角: 模板方法模式与并发环境下的设计取舍"] classDef default fill:#f1f5f9,stroke:#475569,stroke-width:1.5px,color:#1e293b classDef highlight fill:#e9d5ff,stroke:#9333ea,stroke-width:2px,color:#4c1d95 classDef perf fill:#d1fae5,stroke:#059669,stroke-width:1.5px,color:#064e3b classDef design fill:#fef9c3,stroke:#d97706,stroke-width:1.5px,color:#78350f class A,B,C,D,G default1 class E highlight class F perf class H design

分层说明 :模块 1 建立底层结构与 2 的幂约束认知;模块 2 深入哈希函数的数学设计;模块 3 串联 put 的完整代码路径;模块 4 拆解扩容机制的核心------高低位拆分;模块 5 是全文亮点------JDK 7 到 JDK 8 的头插法死链完整推演;模块 6 回到工程层面------loadFactor 的调优依据;模块 7 回归实战;模块 8 从系统设计视角揭示 HashMap 留给子类的扩展点。关键结论:HashMap 的设计是数学与工程的精妙结合------2 的幂容量让取模变为位运算,扰动函数让高位信息参与索引,尾插法解决了 JDK 7 死链的致命缺陷,0.75 的 loadFactor 是泊松分布下的最优平衡。理解这些设计决策,才能真正掌握 HashMap 的选型与调优。


1. 底层结构:Node 类、table 数组与 2 的幂容量约束

HashMap 的底层数据结构,是它对"空间换时间"思想的极致体现。它本质上是一个精心设计的桶数组

1.1 Node<K,V>:链表的基石

在 JDK 8 中,HashMap 的静态内部类 Node<K,V> 实现了 Map.Entry<K,V> 接口,它是构成链表的基本单元。

java 复制代码
// JDK 8 HashMap.Node 源码(简化版)
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash; // key的哈希值,经过扰动处理,final修饰避免重复计算
    final K 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;
    }
    // getKey, getValue, equals, hashCode 等方法省略...
}

设计解读:

  • final int hash :这个字段是预计算好的 key 的扰动哈希值。节点一旦创建,其哈希值就不再改变。为什么是 final?因为 Node 在放入桶后,其位置由 hash 决定,若 hash 可变,则"基于哈希的集合"这一根基就崩溃了。这与《Effective Java》的建议一致:作为 Map key 的对象最好是不可变的
  • Node<K,V> next :这就是链表的"链"。当发生哈希碰撞时,多个 Node 就是通过这个 next 指针在同一个桶(bucket)中形成单向链表。

1.2 table 数组:2 的幂容量约束的起源

装载 Node 的,是一个名为 table 的数组。

java 复制代码
transient Node<K,V>[] table;

这个数组的容量(table.length)有一个强制约束:必须是 2 的幂 。这是 HashMap 源码设计中排名第一的核心约束,几乎所有的性能优化都建立在此之上。

为什么必须是 2 的幂?

原因有两个,分别对应索引计算扩容拆分,它们都有一个共同的数学基础:

  1. 索引计算(寻址) :当容量 n 为 2 的幂时,hash % n 等价于 hash & (n - 1)% 运算是除法和取余,CPU 周期长;而 & 是位运算,仅需一个 CPU 周期,效率极高。n-1 的二进制形式全是 1,相当于一个"低位掩码",能将哈希值的高位清零,只保留在 [0, n-1] 范围内的低位。
    • n = 16n-1 = 150b1111)。hash & 15 的结果只取决于 hash 的低 4 位,范围 0-15
  2. 扩容拆分(rehash) :当容量从 n 扩容到 2n 时,原桶 i 的节点只会被拆分到 ii + n 这两个位置,绝不会到别处。这同样归功于 n 是 2 的幂。其二进制特征是只有一位为 1(如 160b10000),新增位恰好是这一位左移一位(320b100000),这使得扩容后只需判断 hash 的那一位新增位是 0 还是 1,就能快速拆分链表。

这两个原因为什么成立,将在模块 2模块 4 中进行严格的数学证明。

tableSizeFor(int cap):保证 2 的幂的"魔法"

当使用 new HashMap(int initialCapacity) 时,为了保证容量必须是 2 的幂,JDK 提供了一个精巧的方法 tableSizeFor,它会找到大于等于 cap 的最小 2 的幂

java 复制代码
// JDK 8 HashMap.tableSizeFor 源码
static final int tableSizeFor(int cap) {
    int n = cap - 1;            // Step 1: 减1,应对 cap 本身就是2的幂的情况
    n |= n >>> 1;               // Step 2
    n |= n >>> 2;               // Step 3
    n |= n >>> 4;               // Step 4
    n |= n >>> 8;               // Step 5
    n |= n >>> 16;              // Step 6: 五次右移、按位或,将最高位1后面的所有位都变为1
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

数学解析:

  • Step 1 (int n = cap - 1) :这是为了处理 cap 本身就是 2 的幂的边界情况。如果 cap = 8,不减 1 直接执行后续操作会得到 16,但我们需要的是 8。减 1 后 n = 7 (0b0111)。
  • Step 2-6 (五次右移并按位或) :这个操作的目的是n 的二进制表示中,最高位 1 之后的所有低位全部置为 1
    • 初始n = 0b0010 xxxx ...(假设最高位 1 在从右数第 6 位)。
    • n |= n >>> 1 :将最高位 1 及其右邻 1 位都置为 10b0011 xxxx ...
    • n |= n >>> 2 :将已覆盖的最高两位 1 及其右邻两位都置为 10b0011 11xx ...
    • ...以此类推,因为是取最高位的 1 往后复制 ,经过 1+2+4+8+16 = 31 次移位,足以将一个 int 类型 32 位整数的任何可能的最高位 1 覆盖到所有低位。
  • Step 7 (n + 1) :此时 n 是形如 0b00011111 的值,加 1 后即为 0b00100000,恰好是大于等于原 cap 的最小 2 的幂。

工程验证:

java 复制代码
// 验证 tableSizeFor 的示例
System.out.println(tableSizeFor(10)); // 输出: 16
System.out.println(tableSizeFor(16)); // 输出: 16
System.out.println(tableSizeFor(31)); // 输出: 32

生产影响 :这个算法确保了 HashMap 在任何构造方式下都能维持 2 的幂容量,保障了后续所有位运算优化的正确性。

1.3 懒初始化 (Lazy Initialization)

与 JDK 8 中的 ArrayList 一样,HashMap 也采用了懒初始化策略。

java 复制代码
// new HashMap() 仅仅设置了 loadFactor,table 为 null
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

// 首次调用 put 时,才通过 resize() 方法创建 table 数组
final V putVal(int hash, K key, V value, ...) {
    Node<K,V>[] tab; int n;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length; // 初始化发生在这里
    ...
}

设计考量 :节省内存。许多 HashMap 在创建后可能从未使用,懒初始化避免了为空的 table 数组(即使是默认的 16 容量)分配不必要的内存。

图 1:HashMap 底层结构图

flowchart LR subgraph A [桶数组 table] direction LR T0[0] T1[1] T2[2] Td[...] T15[15] end T0 --> N0_0(Node:K1,V1) T0 --> N0_1(Node:K2,V2) N0_0 --> N0_1 T2 --> Treenode_Root( TreeNode: K3, V3 ) Treenode_Root --> Treenode_Left( TreeNode: K4, V4 ) Treenode_Root --> Treenode_Right( TreeNode: K5, V5 ) style T0 fill:#e6f3ff,stroke:#3399ff style T2 fill:#ffe6e6,stroke:#ff3333 style T1 fill:#f0f0f0,stroke:#666 style Td fill:#f0f0f0,stroke:#666 style T15 fill:#f0f0f0,stroke:#666

a) 主旨概括 :该图展示了 HashMap 的核心数据结构:一个 Node[] 类型的 table 数组。数组的每个元素被称为一个"桶"(bucket)。当哈希碰撞发生时,采用"分离链表法"(Separate Chaining)解决冲突。JDK 8 中,当某个桶的链表长度过长时,会将其转换为红黑树结构以提升性能。

b) 逐元素分解

  • 桶数组:一个初始容量为 16(默认)且始终为 2 的幂的数组。
  • 桶 0 :包含一个由两个 Node 组成的链表。Node(K1,V1) 指向 Node(K2,V2),表示 K1K2 的哈希值映射到了同一个桶索引 0。
  • 桶 1:为空,表示尚未有键值对映射到此桶。
  • 桶 2 :包含一个 TreeNode 结构的红黑树根节点。这是 JDK 8 的新特性,当链表长度达到阈值(TREEIFY_THRESHOLD,值为 8)时,结构会从链表转变为红黑树。

c) 设计原理映射 :数组提供 O(1) 的随机访问能力,是 HashMap 性能的基石。链表/红黑树处理哈希碰撞,保证在碰撞发生时,数据结构依然可用。链表用于节点数少的情况,因为它结构简单,插入删除成本低;红黑树用于节点数多的情况,因为它的查找、插入、删除时间复杂度为 O(log n),远优于链表的 O(n)

d) 工程联系与关键结论理解 HashMap 内存布局的关键在于,每个桶存的都是一个引用(4 或 8 字节),指向链表的头节点或红黑树的根节点。table 数组很稀疏时,大部分桶都是空的,这造成了内存浪费,而这正是 loadFactor 进行空间-时间权衡的舞台。


2. 哈希函数:扰动函数设计意图与索引计算数学原理

key 到桶索引,分为两个步骤:计算哈希值、映射到数组下标。这两个步骤都充满了数学与工程的精妙结合。

2.1 扰动函数:让高 16 位参与运算

java 复制代码
// JDK 8 HashMap.hash 方法源码
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • (h = key.hashCode()) :首先调用 key 自身的 hashCode() 方法,获取一个 32 位的整型哈希值 h
  • (h >>> 16) :将 h 无符号右移 16 位 。这会把 h高 16 位移动到低 16 位的位置,高 16 位则全部补 0。
  • ^ (异或运算) :将原始的 h 与右移后的值进行异或。最终结果的高 16 位依然是 h 的高 16 位 ,而低 16 位变成了 h 的高 16 位与低 16 位的混合体

设计意图:为什么需要将高 16 位混入低 16 位?

答案在于索引计算的"低位掩码"效应。假设 table 的容量 n 很小,比如默认的 16,则 n-1 = 150b1111)。此时进行索引计算 (n - 1) & hash 时,hash 只有最低 4 位参与了运算。

如果 keyhashCode 实现得不够好,只在低位有变化(例如,一系列连续的整数),它们的低 4 位可能完全相同,导致大量碰撞。或者,即使 hashCode 实现得很好,但高 16 位的信息在容量小的时候被完全忽略了。这都增加了碰撞的风险。

通过将高 16 位与低 16 位进行异或混合,扰动函数将高位的信息"传播"到了低位。这样,当容量较小时,高位的信息也能影响到索引的决定,使得哈希分布更加均匀,有效降低了碰撞概率。

为什么是异或(^)而不是与(&)或(|)?

  • &:结果偏向 0(0&1=0)。
  • |:结果偏向 1(0|1=1)。
  • ^:结果最均衡(0^1=1,1^1=0),能最大程度地保持信息的熵。

2.2 索引计算:位运算取模的数学证明

索引计算公式为:i = (n - 1) & hash。其中 ntable 的容量。

数学等价性证明 :当 n 是 2 的幂时,hash % n 等价于 hash & (n-1)

  1. 设定 :令 n = 2^kk 为自然数)。例如 n = 16,则 k = 4
  2. n 的二进制表示n = 0b0001 0000...(第 k 位为 1,其余为 0)。
  3. n-1 的二进制表示n-1 = 0b0000 1111...(低 k 位全部为 1,其余为 0)。对于 n=16n-1=150b1111
  4. hash % n 的数学含义 :在 2 进制下,hash % 2^k 就是取 hash 二进制表示的最低 k的值。
  5. hash & (n-1) 的位运算含义& 运算与一个低 k 位全为 1 的数,其结果等价于只保留 hash 的低 k,高位全部清零。

结论 :二者在数学上完全等价。% 依赖除法指令,CPU 周期长(数十个周期);& 是逻辑与指令,仅需 1 个周期,性能优势巨大。

2.3 null key 的特殊处理

hash() 方法的第一行就处理了 null key:return (key == null) ? 0 : ...。这意味着 null key 的哈希值永远是 0,它永远存储在 table[0] 这个桶中。这就是 HashMap 允许一个 null key 的原因。

图 2:哈希扰动与索引计算流程图

flowchart TD subgraph A [哈希扰动函数 hash] Start[Key对象] --> HC{调用 key.hashCode} HC --> |返回32位 int h| Shift[无符号右移16位: h >>> 16] Shift --> |得到高16位移至低16位| XOR[异或运算: h ^ h>>>16] XOR --> |得到最终扰动哈希值| HashValue[int hash] end subgraph B [索引计算 putVal] HashValue --> IndexCompute[位运算取模: hash & n-1] n[容量n=16] --> SubOne[减1: n-1=15=0b1111] SubOne --> IndexCompute IndexCompute --> |结果范围 0-15| BucketIndex[最终桶索引] end

a) 主旨概括 :该流程图清晰地展示了从 key桶索引 的两阶段计算。上半部分是 hash() 方法的扰动计算,下半部分是 putVal 方法中的索引计算。

b) 逐元素分解

  • 输入 :一个 key 对象。
  • Step 1: hashCode() :获取 32 位原始哈希值 h
  • Step 2: 右移16位 :将 h 的高 16 位信息移到低位。
  • Step 3: 异或 :混合原始低位和高位信息,产生扰动后的 hash
  • Step 4: hash & (n-1) :将扰动后的 hash 与数组长度减 1(低位掩码)进行按位与,计算出最终的数组索引。

c) 设计原理映射 :扰动函数的设计哲学是"高位信息下沉",弥补了短掩码(如 15 的 4 位掩码)只能看到哈希值低位的缺陷。索引计算则利用 2 的幂的数学特性,以性能极高的位运算替代了取模运算。

d) 工程联系与关键结论这两个步骤共同保证了 HashMap 能在绝大多数场景下,以 O(1) 的近似均匀概率将键值对分布到各个桶中。 任何性能敏感的 HashMap 应用,都应重视作为 key 的对象的 hashCode() 实现质量,因为它是一切均匀分布的源头。


3. put 流程:key 为 null 处理、哈希碰撞与链表遍历

put 方法是 HashMap 的灵魂,其内部逻辑串联了初始化、碰撞处理、扩容等所有核心机制。所有的 put 操作最终都汇聚到 putVal 这个核心方法。

java 复制代码
// JDK 8 HashMap 核心方法源码(简化与注释)
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 1. 懒初始化:如果 table 为空,先进行扩容(初始化)
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 2. 定位桶:i = (n - 1) & hash。如果桶为空,直接新建节点放入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 3. 检查桶的首节点:hash相同且key相同(引用相同或equals为真)
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p; // 记录该节点,后续会执行覆盖逻辑
        // 4. 如果桶结构是红黑树,走树的分支(本篇略,详述于系列第3篇)
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 5. 桶结构是链表:遍历链表
            for (int binCount = 0; ; ++binCount) {
                // 5.1 到达链表尾部仍未找到匹配key,尾插新节点
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 5.2 检查链表长度是否达到树化阈值(TREEIFY_THRESHOLD=8)
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 5.3 遍历过程中找到了匹配的key
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break; // 记录节点e,跳出循环
                p = e; // p后移,继续遍历
            }
        }
        // 6. 覆盖旧值:如果e不为null,表示存在相同key的旧节点
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value; // 将旧value替换为新value
            afterNodeAccess(e); // 为LinkedHashMap预留的回调
            return oldValue;
        }
    }
    // 7. 结构调整记录与扩容检查
    ++modCount; // fail-fast 机制
    if (++size > threshold)
        resize(); // 扩容
    afterNodeInsertion(evict); // 为LinkedHashMap预留的回调
    return null;
}

流程深度解析:

  1. 懒初始化验证 :首次 put 时,tablenull,触发 resize()。此处 resize() 既负责初始化也负责扩容,是 HashMap 的"总管家"。newCap = DEFAULT_INITIAL_CAPACITY (16)newThr = (int)(16 * 0.75f) = 12
  2. 索引计算与空桶判断i = (n - 1) & hash,精确定位。若 tab[i] == null,直接"入住",这是最高效的路径。
  3. 首节点匹配:在进入链表遍历前,优先检查桶的首节点。这个优化很关键,因为很多桶可能只有一个节点,可以直接命中,省去了遍历的开销。
  4. 链表遍历与尾插for (int binCount = 0; ; ++binCount) 循环遍历链表。JDK 8 采用尾插法 :找到链尾(p.next == null),将新节点链接在 p 之后。这与 JDK 7 的头插法有本质区别,决定了并发扩容时的安全性(详见模块 5)。
  5. 树化检查binCount >= TREEIFY_THRESHOLD - 1,即链表长度达到 8 时,调用 treeifyBin(tab, hash),尝试将链表树化。注意,这是一个"尝试",最终是否树化还取决于 table.length 是否 >= 64MIN_TREEIFY_CAPACITY)。此细节将在系列第3篇展开。
  6. modCountsize 维护modCount 是 fail-fast 迭代器的基础,任何结构性修改都会使其自增。size 是当前 HashMap 中的键值对总数。

图 3:put 方法完整业务流程时序图

sequenceDiagram participant Client participant HashMap participant Resize participant Bucket Client->>HashMap: put(key, value) HashMap->>HashMap: hash(key) alt table为空或长度为0 HashMap->>Resize: resize() Resize-->>HashMap: 新table end HashMap->>HashMap: i = (n-1) & hash HashMap->>Bucket: 获取桶i的首节点p alt 桶为空 HashMap->>Bucket: 新建Node放入桶i Bucket-->>HashMap: else 桶不空 alt 首节点p的key匹配 HashMap-->>HashMap: 记录节点e=p else 红黑树 HashMap->>TreeNode: putTreeVal TreeNode-->>HashMap: 返回节点e else 链表 loop 遍历链表 alt 到达链尾 HashMap->>Bucket: 尾插新Node alt binCount >= 7 HashMap->>HashMap: treeifyBin(tab, hash) end else 找到匹配key HashMap-->>HashMap: 记录节点e end end end opt e != null HashMap->>HashMap: e.value = value HashMap-->>Client: 返回oldValue end end HashMap->>HashMap: ++modCount, ++size alt size > threshold HashMap->>Resize: resize() end HashMap-->>Client: 返回null

a) 主旨概括 :该时序图详细展示了 put 操作从调用到返回的完整生命周期,涵盖了懒初始化、桶定位、碰撞处理(链表/红黑树)、尾插法、树化判断和扩容检查等所有分支。

b) 逐元素分解

  • Client :调用 map.put(key, value)
  • HashMap :首先计算哈希值,然后确保 table 已初始化。计算桶索引后,根据桶的状态(空、一个节点、链表、红黑树)执行不同的逻辑分支。
  • Resize :在初始化和 size > threshold 时被调用,负责创建新数组或扩容旧数组。
  • Bucket :代表 table 数组的某个位置,执行具体的节点插入或替换。

c) 设计原理映射:整个流程体现了"快速路径"优化思想------空桶直接插入,单节点直接比较,这两种情况占了大多数,无需进入循环。链表遍历采用尾插,与 JDK 8 的整体设计哲学一致。

d) 工程联系与关键结论putVal 方法是 HashMap 全部设计决策的集大成者。 理解它的分支结构,就等于理解了 HashMap 在各种状态下的行为。任何对 HashMap 的调试和性能分析,都应从此方法入手。


4. 扩容机制:resize 尾插法、高低位链表拆分与 rehash

size > threshold(容量 * loadFactor)时,HashMap 会触发扩容。扩容是 HashMap 中最昂贵、最复杂的操作。

java 复制代码
// JDK 8 HashMap.resize 核心源码(大幅简化与注释)
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    
    // --- 第一部分:计算 newCap 和 newThr ---
    if (oldCap > 0) {
        // 常规扩容:容量和阈值都翻倍
        newCap = oldCap << 1;      // 新容量 = 旧容量 * 2
        // 阈值翻倍,除非旧容量已>=最大容量
        newThr = oldThr << 1;     
    } else if (oldThr > 0) // 初始化时,如果构造器指定了 initialCapacity
        newCap = oldThr;
    else {               // 无参构造器初始化,使用默认值
        newCap = DEFAULT_INITIAL_CAPACITY; // 16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
    }
    // ... (省略边界情况处理) ...
    threshold = newThr;
    
    // --- 第二部分:创建新数组,并转移旧数据 ---
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) { // 遍历旧table的每个桶
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null; // 帮助GC
                // 情况1: 桶中只有一个节点
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 情况2: 桶中是红黑树(详情略)
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 情况3: 桶中是链表,核心:高低位拆分
                else {
                    Node<K,V> loHead = null, loTail = null; // 低位链表(保持在原索引)
                    Node<K,V> hiHead = null, hiTail = null; // 高位链表(移到 原索引+oldCap)
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 【核心判断】:hash值与旧容量进行与运算
                        if ((e.hash & oldCap) == 0) { // 结果为0,加入低位链表
                            if (loTail == null) loHead = e;
                            else loTail.next = e;
                            loTail = e;
                        } else {                     // 结果不为0,加入高位链表
                            if (hiTail == null) hiHead = e;
                            else hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 将低位链表放回原索引
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 将高位链表放到 原索引+oldCap
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

4.1 高低位拆分原理:数学与源码的完美映射

这是 resize 中最精妙的部分。为什么 (e.hash & oldCap) == 0 就能将一条链表拆分成两条,并且索引位置计算完全正确?

数学推导:

假设旧容量 oldCap = 16 (0b10000),则新容量 newCap = 32 (0b100000)。

  1. 旧索引计算oldIndex = hash & (oldCap - 1) = hash & 150b01111),只取决于 hash 的低 4 位。
  2. 新索引候选newIndex = hash & (newCap - 1) = hash & 310b11111),取决于 hash 的低 5 位。
  3. 新旧索引关系 :比较新旧索引,唯一的不同在于新索引多看了 hash第 5 位 (从低位数起,oldCap 所代表的那一位)。
    • 如果 hash 的第 5 位是 0,则 newIndex 的低 5 位值与旧索引的低 4 位值完全相等,即 newIndex = oldIndex
    • 如果 hash 的第 5 位是 1,则 newIndex 的低 5 位值 = 旧索引的低 4 位值 + 16 (第5位的权重)。即 newIndex = oldIndex + oldCap
  4. 判断条件映射 :如何判断 hash 的第 5 位是 0 还是 1?答案就是 (e.hash & oldCap) == 0oldCap=16 的二进制是 0b10000,此 & 运算恰好提取了 hash 的第 5 位。为 0 则结果是 0(低位链表),为 1 则结果是 16(高位链表)。

尾插法的体现 :在 do...while 循环中,loTail.next = e; loTail = e; 操作明确地将新节点追加到尾部,并用 loTail 维护尾节点引用。这保证了扩容后,原链表的节点顺序在新链表中得以保持。

图 4:扩容高低位拆分原理图

flowchart TB subgraph Old["旧数组: oldCap=16"] O_Bucket_n["桶 j"] --> Head_Old["Node A"] Head_Old --> Node_B["Node B"] Node_B --> Node_C["Node C"] end subgraph Logic["拆分逻辑: e.hash & oldCap"] direction TB Head_Old --> Check_A{"hash_A & 16 ?"} Node_B --> Check_B{"hash_B & 16 ?"} Node_C --> Check_C{"hash_C & 16 ?"} end subgraph New["新数组: newCap=32"] Check_A -->|"==0"| N_Bucket_j["桶 j"] Check_B -->|"!=0"| N_Bucket_jn["桶 j+16"] Check_C -->|"==0"| N_Bucket_j N_Bucket_j --> Node_A_Lo["Node A"] Node_A_Lo --> Node_C_Lo["Node C"] N_Bucket_jn --> Node_B_Hi["Node B"] end classDef oldSub fill:#e0f2fe,stroke:#0284c7,stroke-width:1.5px classDef logicSub fill:#f1f5f9,stroke:#475569,stroke-width:1.5px,stroke-dasharray:5 5 classDef newSub fill:#dcfce7,stroke:#16a34a,stroke-width:1.5px classDef nodeStyle fill:#ffffff,stroke:#cbd5e1,stroke-width:1.5px,color:#1e293b class Old oldSub class Logic logicSub class New newSub class O_Bucket_n,Head_Old,Node_B,Node_C,Check_A,Check_B,Check_C,N_Bucket_j,N_Bucket_jn,Node_A_Lo,Node_C_Lo,Node_B_Hi nodeStyle

a) 主旨概括 :该图展示了当 oldCap=16 扩容到 newCap=32 时,桶 j 上的链表 A->B->C 是如何被拆分的。整个拆分操作仅凭一次位运算 (e.hash & oldCap) 即可完成。

b) 逐元素分解

  • 旧数组 :桶 j 上有一个包含 A, B, C 三个节点的链表。它们的哈希值各不相同。
  • 拆分逻辑 :对每个节点的哈希值执行 hash & 16。假设 A 和 C 的结果为 0,B 的结果为 16
  • 新数组 :A 和 C 形成"低位链表",被放置在新数组的桶 j ;B 形成"高位链表",被放置在新数组的桶 j + 16

c) 设计原理映射 :此机制的数学基础是 2 的幂。2 的幂使得新旧数组长度的差异仅在一个二进制位(oldCap 对应的位)。判断该位是 0 还是 1,就能决定节点去向。这是一种 O(n) 时间完成所有节点 rehash 的零额外开销算法,无需为每个节点重新计算 hash % newCap

d) 工程联系与关键结论"高低位拆分"是 JDK 8 HashMap 性能优化的另一大支柱。 它将最耗时的扩容操作的成本压到了理论最低,并且通过尾插法 保持了链表顺序,为并发场景下的安全性改进提供了基础(尽管 HashMap 本身仍不是线程安全的)。


5. JDK 7→8 演进:头插法死链推演与尾插法改进

这是 HashMap 演进史上最著名的案例,一个由"优化"引发的并发灾难。

5.1 JDK 7 的 Entry 与头插法 transfer

在 JDK 7 中,链表节点是 Entry<K,V>,其结构与 Node 类似。扩容的核心方法是 transfer

java 复制代码
// JDK 7 HashMap.transfer 方法源码(简化)
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) { // 1. 遍历旧数组
        while(null != e) {
            Entry<K,V> next = e.next; // 2. 记录下一个节点
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity); // 3. 计算在新数组中的索引
            // 4. 【头插法】:新节点插入到新桶链表的头部
            e.next = newTable[i];
            newTable[i] = e;
            // 5. 处理下一个节点
            e = next;
        }
    }
}

关键点e.next = newTable[i];newTable[i] = e; 这两行代码实现了头插法 ------新转移过来的节点总是成为新链表的头节点。这意味着,扩容完成后,原链表顺序会在新数组中被反转

5.2 死链推演:环形链表的诞生

形成条件 :两个或多个线程同时对 HashMap 执行 put 并触发 resize / transfer

经典案例推演 :假设旧 table[1] 上存在一个链表 A -> B

  1. 初始状态 :旧表 [1]: A -> B -> null
  2. 线程 B 率先完成 :线程 B 获得了时间片,完整地执行了 transfer 方法。由于头插法,新表 [1] 上的链表变成了 B -> A -> null顺序反转)。
  3. 线程 A 被挂起 :线程 A 在执行 transfer 前,记录了其局部变量 e = Anext = B。然后它被操作系统挂起,它的"视野"还停留在旧链表顺序 A -> B
  4. 线程 A 恢复 :线程 A 恢复执行,它从自己的局部变量开始。
    • 轮次 1e = Anext = B。它将 A 用头插法插入到线程A自己的新表(或主内存,取决于内存可见性,这里简化操作,假设它在操作同一片内存)的某个位置。此时,A.next = null
    • 轮次 2e = B,此时关键问题来了:B.next 是多少?在线程 A 的局部变量中,它原以为是 null。但因为线程 B 已经修改了内存,B.next 现在指向 A。线程 A 将 B 用头插法插入,执行 B.next = ... 然后置为当前头节点。
    • 关键一步 :线程 A 下一步处理 e = next,即 A。但 AB 之间的 next 关系已形成 B.next = AA.next = B环形引用 。此后再执行 getput 操作,遍历这个桶的链表时,就会陷入 A -> B -> A -> B... 的死循环,CPU 瞬间飙升至 100%。

5.3 JDK 8 的解决方案:尾插法

JDK 8 的 resize 方法彻底抛弃了 transfer,也没有了"头插"和"顺序反转"的概念。取而代之的是尾插法高低位链表拆分

  • loTail.next = e; loTail = e; :新节点总是被追加到链表的尾部,局部变量 loHead/loTail 维护了链表的头和尾。
  • hiTail.next = e; hiTail = e;:同上。
  • loTail.next = null; / hiTail.next = null; :在循环结束后,显式地将尾节点的 next 置空,切断与任何潜在后续节点的联系。

为什么尾插法解决了死链问题? 尾插法在转移过程中,保持了链表的原始相对顺序 。它不会造成链表反转,因而不会出现"后来者指向前驱"的情况。即使多个线程并发执行 resize,由于 HashMap 本身的设计就不是线程安全的,数据一致性无法保证(可能导致节点丢失),但至少不会再出现结构上的环形链表和随之而来的死循环。这是一个从"灾难性故障"到"数据不一致"的改进,极大提升了系统的鲁棒性。

图 5:JDK 7 头插法死链推演时序图

sequenceDiagram participant T_Old as 旧table[1] participant T_A as 线程A (局部变量) participant T_B as 线程B participant T_New as 新table[1] Note over T_Old: 初始链表 A -> B -> null T_B->>T_Old: 获取链表 A->B T_A->>T_Old: 获取链表,记录 e=A, next=B T_A-->>T_A: 被挂起 T_B->>T_B: 转移 A (头插) T_B->>T_New: B->null T_B->>T_B: 转移 B (头插) T_B->>T_New: A->B->null (反转) T_B-->>T_New: 完成,链表变为 B->A->null T_A->>T_A: 恢复执行,使用 e=A, next=B T_A->>T_New: 轮次1: 头插A,A.next=null,表头A T_A->>T_A: e=next=B Note over T_New: 此时B.next已被线程B改为A T_A->>T_New: 轮次2: 头插B,B.next=A,表头B T_A->>T_A: e=next=A (因为B.next是A) T_A->>T_New: 轮次3: 头插A,A.next=B,表头A Note over T_New: 至此,A.next=B, B.next=A
环形链表形成! T_New-->>T_New: 后续get(1)进入死循环,CPU 100%

a) 主旨概括 :该时序图推演了 JDK 7 下两个线程并发执行 transfer 导致环形链表的核心步骤。

b) 逐元素分解

  • 初始状态 :链表 A->B
  • 线程 B 完成 :由于头插法,新链表变为 B->A
  • 线程 A 恢复 :它的局部变量仍是 e=A, next=B。它先处理 A,然后处理 B。由于 B.next 已被改为 A,处理完 B 后,AB 就互相指向对方。
  • 死循环 :任何需要遍历该链表的操作都将陷入 AB 之间的无限循环。

c) 设计原理映射 :根本原因是 JDK 7 的头插法优化 假设了单线程环境。这个优化可能考虑了新加入的元素更可能被再次访问(LRU 思想),但在并发扩容时,头插法导致的链表顺序反转并发线程的局部变量快照产生了致命的交互。

d) 工程联系与关键结论这是"为了性能而在非线程安全类中进行投机优化"的经典失败案例。 JDK 8 通过回归到尾插法 这种更直观、顺序保持的方式,牺牲了一点微小的理论性能,换取了对并发操作导致灾难性故障的免疫能力。即使只使用单线程,也强烈建议升级到 JDK 8,以避免任何潜在的此类风险。

图 6:JDK 7 transfer 头插法 vs JDK 8 resize 尾插法对比图

flowchart LR subgraph JDK7 [JDK 7 transfer 头插法] direction TB Old7[旧链表: A->B->C] --> |transfer| New7[新链表: C->B->A] Note7[顺序反转] end subgraph JDK8 [JDK 8 resize 尾插法] direction TB Old8[旧链表: A->B->C] --> |resize拆分| Lo[低位链表: A->C] Old8 --> |resize拆分| Hi[高位链表: B] Note8[顺序保持一致] end style JDK7 fill:#ffe6e6,stroke:#ff3333 style JDK8 fill:#e6ffe6,stroke:#33cc33

a) 主旨概括:该图直观对比了 JDK 7 与 JDK 8 在扩容转移数据时,处理链表节点的两种不同策略及其结果。

b) 逐元素分解

  • JDK 7 头插法:采用倒序插入,新链表的头是原链表的尾,链表顺序发生反转。这是死链问题的根源。
  • JDK 8 尾插法:在高低位拆分逻辑中,节点被按顺序追加到两条新链表的尾部,原链表的相对顺序得以完整保留。

c) 设计原理映射 :JDK 7 的头插法实现简单,可能在局部性原理上有微弱优势。JDK 8 的尾插法实现稍复杂(需要维护头尾指针),但它直接拆分了链表,避免了单个链表的反转,并奠定了红黑树拆分(TreeNode.split)的逻辑基础。

d) 工程联系与关键结论顺序保持是数据结构安全性的重要一环。 JDK 8 的改进不仅仅是修了一个 Bug,更是设计哲学上向**健壮性(Robustness)**的倾斜。


6. loadFactor 调优:泊松分布依据与工程最佳实践

loadFactor(负载因子)是 HashMap 唯一的性能调优旋钮,它控制着时间与空间的平衡。

6.1 默认值 0.75 的泊松分布数学依据

JDK 文档中明确提到了负载因子与泊松分布的关系。理想情况下,在随机哈希码下,桶中节点的频率遵循泊松分布(Poisson distribution)

  • 平均负载参数 λ :当 loadFactor = 0.75 时,HashMap 会在 size 达到 0.75 * capacity 时扩容。因此,在扩容发生前,桶中节点的平均数量(λ)大约是 0.75。
  • 概率计算 :根据泊松分布概率质量函数 P(X = k) = (λ^k * e^{-λ}) / k!,可以计算出桶中节点数量为 k 的概率。

JDK 8 的 HashMap 源码注释给出了在 λ = 0.5 时(为什么是 0.5?这可能是考虑了不均匀性的一个更保守的估计,或者是在某个特定扩容触发点附近桶的平均填充度)的概率值:

text 复制代码
* 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 // 小于千万分之一
  • 选择 0.75 的时间侧 :由概率表可知,当平均负载较低时,链表长度达到 8 的概率极低(小于千万分之一)。这意味着,在绝大多数情况下,get 操作在遍历链表时,只需要极少的次数(0次或1次)就能命中,查询性能接近 O(1)
  • 选择 0.75 的空间侧 :如果 loadFactor 设为 0.5,threshold 会减半,扩容更频繁,内存中会有更多的空闲桶被浪费。设为 0.75,意味着只有 25% 的内存是"浪费"的。这是一个被工程界广泛接受的平衡点。如果设为 1.0,虽然空间利用达到极致,但碰撞的概率会急剧上升,链表变长,查询性能退化。

6.2 工程最佳实践:预估初始容量

避免扩容(尤其是大规模的 rehash)是提升 HashMap 性能的最有效手段。已知确切或大概的键值对数量时,应在构造时指定初始容量。

最佳实践公式

java 复制代码
int initialCapacity = (int) (expectedSize / 0.75f + 1);
  • expectedSize / 0.75f:这部分将期望元素数量换算成"存储这些元素并且在 0.75 负载下不至于触发扩容所需的最小容量"。
  • + 1:向上取整并处理边界情况,确保不会因为小数截断导致容量不足。

示例 :预计放入 100 个元素。100 / 0.75f ≈ 133.33,加 1 并取整得 134。tableSizeFor(134) 会找到 256,即实际容量将初始化为 256,阈值 threshold 为 192。在放入 100 个元素的过程中,完全不会触发扩容。

图 7:loadFactor 与碰撞概率 / 内存占用关系图

flowchart TD subgraph LowLF["低 Load Factor e.g., 0.5"] direction LR T1["桶1"] & T2["桶2"] & T3["桶3"] & Td1["..."] & Tn["桶n"] T1 --> N1["Node"] T2 --> Empty2["(空)"] T3 --> Empty3["(空)"] Tn --> Nn["Node"] Result1{"内存浪费多
碰撞概率低"} end subgraph IdealLF["理想 Load Factor e.g., 0.75"] direction LR I1["桶1"] & I2["桶2"] & I3["桶3"] & Id1["..."] & In["桶n"] I1 --> IN1["Node"] I1 --> IN2["Node"] I2 --> IEmpty1["(空)"] I3 --> IN3["Node"] Result2{"时间与空间平衡"} end subgraph HighLF["高 Load Factor e.g., 0.9"] direction LR H1["桶1"] & H2["桶2"] & H3["桶3"] & Hd1["..."] & Hn["桶n"] H1 --> HN1["Node"] H1 --> HN2["Node"] H1 --> HN3["Node"] H1 --> HN4["Node"] H2 --> HEmpty1["(空)"] Result3{"内存浪费少
碰撞概率高"} end classDef lowSub fill:#eef2ff,stroke:#6366f1,stroke-width:2px classDef idealSub fill:#ecfdf5,stroke:#10b981,stroke-width:2px classDef highSub fill:#fef2f2,stroke:#ef4444,stroke-width:2px classDef nodeStyle fill:#ffffff,stroke:#cbd5e1,stroke-width:1.5px,color:#1e293b classDef emptyStyle fill:#f8fafc,stroke:#cbd5e1,stroke-width:1px,color:#94a3b8 classDef resultStyle fill:#f1f5f9,stroke:#475569,stroke-width:1.5px,color:#1e293b,shape:rounded class LowLF lowSub class IdealLF idealSub class HighLF highSub class T1,T2,T3,Td1,Tn,N1,Nn,I1,I2,I3,Id1,In,IN1,IN2,IN3,H1,H2,H3,Hd1,Hn,HN1,HN2,HN3,HN4 nodeStyle class Empty2,Empty3,IEmpty1,HEmpty1 emptyStyle class Result1,Result2,Result3 resultStyle

a) 主旨概括 :该图通过对比不同负载因子下 HashMap 内部的状态,直观展示了"时间-空间"权衡。

b) 逐元素分解

  • 低负载 (0.5):数组中大量空桶,浪费内存,但每个桶中元素很少,查询速度快。
  • 理想负载 (0.75):空桶与占用桶比例适中,桶内平均元素数量很少,达到了时间与空间的平衡。
  • 高负载 (0.9):数组很"满",空桶很少,内存利用率高。但大量元素挤在某些桶中,形成长链表,查询性能退化严重。

c) 设计原理映射 :负载因子本质上是在控制哈希表允许达到的最大密度。密度低则性能好,空间差;密度高则空间好,性能差。0.75 是 JDK 在概率模型和通用场景下,经过大量工程实践得出的"甜蜜点"(sweet spot)。

d) 工程联系与关键结论不要轻易修改默认的 0.75,除非你有明确的、可度量的极端场景。 对于内存极其敏感的嵌入式系统,可适当提高;对于要求极致查询性能的低延迟系统,可适当降低。但变更前,务必进行全链路压测,用数据说话。最有效的调优,永远是预估初始容量,完全避免扩容。


7. 工程实战:内存占用计算与常见反模式

7.1 HashMap 内存占用估算

了解 HashMap 的内存开销,有助于在性能敏感场景下做出准确决策。以 64位 JVM、开启压缩指针(-XX:+UseCompressedOops,默认开启)为例:

  • Node 对象
    • Mark Word: 8 字节
    • Klass Pointer: 4 字节(压缩)
    • int hash: 4 字节
    • K key: 4 字节(压缩指针)
    • V value: 4 字节(压缩指针)
    • Node<K,V> next: 4 字节(压缩指针)
    • 总计 : 28 字节。由于 JVM 内存对齐为 8 的倍数,实际占用 32 字节
  • table 数组
    • 数组头部: 16 字节(Mark Word + Klass Pointer + length等)
    • 每个桶(Bucket): 4 字节(压缩指针,指向 Node 或为 null)。
  • 一个键值对的总内存成本 ≈ 32 (Node) + 额外存储 Key 和 Value 对象的内存。

结论HashMap 的内存开销不小。存储一个 Integer-Integer 的键值对,HashMap 本身的壳和 Node 就需要 32 + 4 ≈ 36 字节,而两个 Integer 对象本身仅占 16+16=32 字节。

7.2 常见反模式

  1. 使用可变对象作为 Key :这是最经典的陷阱。如果 key 对象在放入 HashMap 后,其 hashCode() 或用于 equals() 比较的字段被修改,会导致无法再定位到该元素,造成内存泄漏或逻辑错误。始终使用不可变对象(如 String, Integer)作为 Key。 如果必须用自定义对象,确保其哈希值和 equals 行为在作为 Key 期间严格不变。
  2. 不覆盖 hashCode() :如果一个类作为 Key 但没有覆盖 hashCode(),将使用 ObjecthashCode(),通常基于内存地址。即使 equals() 返回 true 的两个不同对象,也会被放到不同的桶中,无法实现 Map 的"去重"和"查找"功能。覆盖 equals() 必须覆盖 hashCode()
  3. 并发使用 HashMap :在多线程环境下,直接用 HashMapput/get 可能导致数据不一致(如 size 不准确)、死链(JDK 7)甚至节点丢失。应使用 ConcurrentHashMapCollections.synchronizedMap(new HashMap())

8. 系统设计视角:模板方法模式与并发环境设计取舍

HashMap 并非孤立设计,它巧妙地运用了模板方法模式(Template Method Pattern) ,为子类(尤其是 LinkedHashMap)预留了扩展点,展示了框架设计的前瞻性。

8.1 模板方法模式在 HashMap 中的应用

putVal 和其他核心方法中,我们可以看到三个空方法调用:

  • afterNodeAccess(Node<K,V> p):在访问节点后调用(如 put 覆盖旧值、get 命中时)。
  • afterNodeInsertion(boolean evict):在插入新节点后调用。
  • afterNodeRemoval(Node<K,V> p):在删除节点后调用。

这些方法在 HashMap 中都是空的,但它们正是为 LinkedHashMap 量身定制的挂钩(hooks)。LinkedHashMap 通过重写这些方法,在维护哈希表的基础上,增加了双向链表来记录插入顺序或访问顺序,从而实现了 LRU 缓存等高级功能。

java 复制代码
// HashMap 中的挂钩示例
void afterNodeAccess(Node<K,V> p) { }          // 允许 LinkedHashMap 记录访问顺序
void afterNodeInsertion(boolean evict) { }      // 允许 LinkedHashMap 移除最老的条目
void afterNodeRemoval(Node<K,V> p) { }          // 允许 LinkedHashMap 更新双向链表

设计考量 :这种设计使得 HashMap 将核心的哈希表操作与顺序维护逻辑完全解耦。HashMap 专注于高效存储与查找,而顺序管理完全交给子类决定,完美符合"开闭原则"(对扩展开放,对修改封闭)。

8.2 并发环境下的设计取舍:从 Hashtable 到 ConcurrentHashMap

在系统设计中,线程安全性是核心考量。HashMap 本身不支持并发,而历史上有多种并发 Map 方案,ConcurrentHashMap 的演进体现了并发编程思想的深刻变革。

特性 Hashtable (JDK 1.0) Collections.synchronizedMap ConcurrentHashMap (JDK 5, 分段锁) ConcurrentHashMap (JDK 8, CAS+synchronized)
锁粒度 整个表(方法级synchronized) 整个表(同步代理对象) Segment 数组,每个Segment一把锁 桶级别(首个节点),CAS 无锁插入
并发度 很低 很低 中(默认16个Segment) 高(理论上并发度等于桶数)
扩容时并发 阻塞所有操作 阻塞所有操作 分段扩容,不影响其他段 多线程协同扩容(transferIndex)
实现复杂度 简单 简单 较高 很高

JDK 8 ConcurrentHashMap 的设计哲学

  • CAS 操作 :对于空桶的插入,使用 Unsafe.compareAndSwapObject 进行无锁插入,极大提升了轻竞争下的吞吐量。
  • synchronized 细化 :仅对桶的首节点加锁(synchronized (f)),锁粒度极细,冲突概率低。
  • 协同扩容 :当某线程发现需要扩容时,它会参与扩容,分配一部分桶的转移任务给其他线程,实现多线程并行扩容,完全规避了 HashMap 的单线程扩容性能瓶颈。
  • 红黑树支持 :同 HashMap JDK 8,链表长度超阈值转红黑树。

ConcurrentHashMap 的完整分析将在本系列第6篇展开,但在此必须指出,理解 HashMap扰动函数、2的幂约束、高低位拆分 是理解 ConcurrentHashMap 并发扩容算法(ForwardingNodetransfer)的绝对前提。


面试高频专题(深度解析版)

1. HashMap 的底层数据结构是怎样的?为什么数组容量必须为 2 的幂?

详细解析:

HashMap 的存储核心是一个 Node<K,V>[] table 数组,每个数组位置称为一个桶(bucket)。Node 是一个单向链表节点,包含四个字段:final int hashfinal K keyV valueNode<K,V> next。当发生哈希碰撞时,碰撞的节点会以链表形式挂在同一个桶上。JDK 8 引入了红黑树,当某个桶的链表长度 ≥ 8 且 table 容量 ≥ 64 时,该链表会被转换为 TreeNode 红黑树结构,以将最坏情况下的查找复杂度从 O(n) 降至 O(log n)

容量必须为 2 的幂 ,这是 HashMap 所有优化的基石,原因有两点:

  1. 索引计算 :桶下标的计算公式为 index = (n - 1) & hash。当 n 为 2 的幂时,n-1 的二进制形式为全 1 的低位掩码(如 n=16n-1=15 = 0b1111)。此时 & 运算等价于 hash % n,但位运算比取模快数十倍。这是第一个性能关键点。
  2. 扩容拆分 :扩容时容量翻倍,新旧容量只差一个二进制位。通过 (e.hash & oldCap) == 0 这一判断,就能将原链表拆分为低位链(留在原索引)和高位链(移到原索引 + oldCap),无需对每个节点重新计算 hash % newCap。这同样是依赖 n=2^k 的性质。

源码佐证tableSizeFor(int cap) 方法通过五次位移与或操作,将任意整数规整为大于等于它的最小 2 的幂,确保了在任何构造函数下容量都满足 2 的幂约束。


2. HashMap 的哈希扰动函数 (h = key.hashCode()) ^ (h >>> 16) 的设计意图是什么?为什么使用异或而不是与或?

详细解析:

扰动函数位于 hash(Object key) 方法中,源码为:

java 复制代码
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

意图 :当 table 容量较小时(如默认 16),索引计算只用到哈希值的低 4 位,高 28 位完全被忽略。如果键的 hashCode() 实现不佳,仅低位变化或高低位完全无关,会导致严重的碰撞。扰动函数将高 16 位右移 16 位后与原值异或,使得高位信息混合进低位,从而让高位参与索引决定,显著降低碰撞概率。

为什么是异或 ^

  • & (与):结果中 0 的比例是 75%,1 的比例 25%,信息偏向 0。
  • | (或):结果中 1 的比例是 75%,0 的比例 25%,信息偏向 1。
  • ^ (异或):0 和 1 的比例各 50%,最大程度保留原始比特的,分布最均匀。

因此异或是最优选择。这一设计是在哈希函数速度和均匀性之间的极佳权衡------只需一次移位和一次异或,代价极小,效果显著。


3. (n - 1) & hash 为什么等价于 hash % n?给出数学证明,并说明性能优势。

详细解析:

数学证明 :令 n = 2^k(k 为非负整数)。hash 可写为:hash = q * 2^k + r,其中 0 ≤ r < 2^k,即 r = hash % n。二进制视角下,n 的第 k 位为 1,其余为 0;n-1 的低 k 位全为 1,高位全为 0。hash & (n-1) 的操作仅保留低 k 位,结果正是余数 r,因此与 hash % n 等价。

性能优势 :在现代 CPU 上,整数除法指令(IDIV)延迟可达 20~100 个时钟周期,而按位与指令(AND)延迟通常为 1 个周期。在哈希表这种频繁取模的场景中,这个优化带来的吞吐量提升非常可观。

边界情况n 非 2 的幂时,n-1 不是全 1 掩码,& 结果不再等价于取模,索引分布会极不均匀,故 HashMap 强制容量为 2 的幂。


4. HashMap 的 put 方法完整流程是怎样的?null key 存在哪里?

详细解析:

put 方法最终调用 putVal,流程如下:

  1. 计算扰动哈希hash(key),若 key 为 null 则返回 0。
  2. 懒初始化 :若 table 为 null 或长度为 0,调用 resize() 分配数组。默认容量 16,阈值 12。
  3. 索引定位i = (n - 1) & hash
  4. 空桶 :若 tab[i] == null,直接 newNode 放入。
  5. 桶不空
    • 首节点匹配:若首节点的 hash 相等且 key 相等(引用相等或 equals 为 true),记录该节点用于后续替换。
    • 红黑树节点 :若首节点是 TreeNode,调用 putTreeVal 插入或替换。
    • 链表遍历 :遍历链表,采用尾插法 查找匹配 key;若无匹配,新建节点追加到链表尾部。插入后检查 binCount >= TREEIFY_THRESHOLD - 1,尝试 treeifyBin 树化(实际转换还受 MIN_TREEIFY_CAPACITY = 64 约束)。
  6. 替换旧值 :若找到已存在节点 e,根据 onlyIfAbsent 决定是否覆盖 value,返回旧值。
  7. 结构调整与扩容++modCount 记录结构变化;++size 若超过 threshold,调用 resize() 扩容。最后执行 afterNodeInsertion 回调(为 LinkedHashMap 预留)。

null key 处理hash(null) 返回 0,因此 null key 始终存放在 table[0]HashMap 允许一个 null key 和多个 null value。


5. HashMap 的扩容机制是怎样的?为什么扩容时链表要拆分为高低位两条链表?

详细解析:

扩容发生在 size > threshold 时(threshold = capacity * loadFactor)。 核心逻辑在 resize() 方法:

  • 新容量 = 旧容量 × 2,新阈值 = 旧阈值 × 2(容量不超过最大值的前提下)。
  • 创建新数组 newTable,然后遍历旧数组的每个桶:
    • 单节点:直接 newTable[e.hash & (newCap - 1)] = e
    • 红黑树:调用 TreeNode.split(this, newTab, j, oldCap)
    • 链表:执行高低位拆分

高低位拆分原理 :扩容后容量翻倍,由 n 变为 2n。旧索引 i = hash & (n-1)。新索引有两种可能:

  • hash第 k 位n 对应的位)为 0,新索引等于旧索引 i
  • 若为 1,新索引为 i + n。 判断该位是 0 还是 1 只需 (e.hash & oldCap),为 0 则放入低位链(loHead/loTail),否则放入高位链(hiHead/hiTail)。最后低位链放回 newTable[j],高位链放回 newTable[j + oldCap]

为何如此设计 :避免对每个节点重新计算 hash % newCap,只需位运算和简单的指针操作,即可在 O(n) 时间内完成全部迁移,并且通过尾插法保持了节点顺序,为并发安全性改进(JDK 8)和红黑树拆分提供了良好基础。


6. JDK 7 的 HashMap 为什么会出现环形链表导致 CPU 100%?JDK 8 是如何解决的?请进行完整的场景推演。

详细解析:

JDK 7 扩容机制 :使用 transfer() 方法,遍历旧表,对每个桶的链表采用头插法 ------转移过来的节点总是插入到新链表的头部。这导致扩容后链表顺序反转

死链形成推演(设原链表为 A → B):

  1. 线程 A 和线程 B 同时执行 put 触发扩容。
  2. 线程 B 抢先完成 transfer(),由于头插法,新数组对应桶变为 B → A。
  3. 线程 A 挂起前,局部变量 e = A, next = B,它的"视野"仍是旧顺序 A → B。
  4. 线程 A 恢复,执行:
    • 处理 A:头插 A,A.next = null(此时新桶中只有 A)。
    • 处理 next(即 B):由于线程 B 已修改内存,B.next 现在是 A。线程 A 将 B 头插,B.next = A,新桶变为 B → A。
    • 线程 A 继续取 next,但在它看来 B 的 next 是 A(从内存中读到)。于是 e = A,将 A 再次头插:A.next = B,桶变为 A → B → A,环形形成
  5. 此后任何访问该桶的 getput 都会在遍历链表时陷入 A ↔ B 的死循环,CPU 100%。

JDK 8 的解决resize() 彻底弃用头插法,采用尾插法 + 高低位拆分。节点被追加到 loTail 或 hiTail 之后,全程保持链表原始顺序。即使多线程并发执行,也不会出现反转,从而无法形成环,将"灾难性死循环"降级为"数据不一致"(如丢失节点),虽仍非线程安全,但已不会造成 CPU 死循环。


7. 为什么 HashMap 的 loadFactor 默认是 0.75?调大或调小有什么影响?

详细解析:

loadFactor = 0.75 是基于泊松分布 的概率论选择。JDK 8 的源码注释给出了在随机哈希码下,桶中元素个数为 k 的概率(λ≈0.5 时):

  • k=0: 60.6%
  • k=1: 30.3%
  • k=2: 7.58%
  • k=3: 1.26%
  • ...
  • k=8: 0.00000006(低于千万分之一)

为什么是 0.75?

  • 时间侧 :0.75 的负载因子保证了在发生扩容前,桶的平均元素数较低(约 0.75),链表长度达到 8 的概率极低,查找操作几乎不会退化到线性扫描,维持了近似 O(1) 的性能。
  • 空间侧:1/0.75 ≈ 1.33,即只有约 25% 的桶是空闲的,内存浪费在可接受范围。若设为 0.5,一半的桶空闲,内存浪费严重;若设为 1.0,虽然空间利用率高,但碰撞剧烈,链表变长,查询性能骤降。

调优方向

  • 增大 loadFactor(如 0.9):更省内存,扩容频率低,但碰撞概率上升,适合对内存极其敏感且查询延迟容忍度高的场景。
  • 减小 loadFactor(如 0.5):查询更快,但更多内存被空桶浪费,扩容也更频繁,适合低延迟、高吞吐的场景。 实践建议 :绝大多数场景不应修改此值。最有效的优化是预估初始容量,避免扩容。

8. 为什么 HashMap 采用懒初始化?new HashMap()new HashMap(initialCapacity)table 何时创建?

详细解析:

HashMap 的无参构造器仅设置了 loadFactor = 0.75f,带容量参数的构造器则通过 tableSizeFor 计算并存储了阈值 threshold(暂存容量),但两者均不创建 table 数组。

懒初始化的设计考量 :与 JDK 8 ArrayList 的懒初始化逻辑一致------避免内存浪费。在许多应用中,HashMap 被创建后可能从未被真正使用,立即分配一个默认 16 长度的 Node 数组(在 64 位 JVM 下数组本身加上指针至少消耗约 80 字节)是没必要的。

table 创建时机 :首次调用 putputAllget 等触发结构性操作的方法时,内部会检查 table == null 并调用 resize() 完成分配。resize() 既是初始化也是扩容的统一入口。

结论 :即使在构造函数中传入了初始容量,table 也并非立即分配,而是推迟到真正需要时。


9. 使用可变对象作为 HashMap 的 Key 会有什么问题?为什么推荐使用 String、Integer 等不可变类?

详细解析:

当可变对象作为 Key 放入 HashMap 后,其哈希值和位置已被确定。如果随后修改了影响 hashCode()equals() 结果的字段:

  1. 查找失败 :再次用该对象去 get 时,计算出的哈希值可能不同,定位到别的桶,找不到原值。
  2. 内存泄漏 :元素"丢失"后无法通过 remove 删除,原 Node 对象会一直保留在 table 中,导致内存泄漏。
  3. 破坏重复键语义:两个原本"相等"的可变对象在修改后不再相等,会破坏 Map 的键唯一性。

为什么推荐不可变类(如 StringInteger)?

  • 它们的哈希值在对象构造时即确定并可缓存,不会变化。
  • 值本身就是状态,任何修改都会产生新对象,原对象作为 Key 的稳定性不变。
  • 符合 HashMap 对 Key 的不可变契约,也符合《Effective Java》的建议。

工程反例:若必须用可变对象作为 Key,必须保证其作为 Key 的期间,状态严格不变,否则风险极大。


10. (故障排查题)线上服务 CPU 突然飙升至 100%,jstack 发现大量线程卡在 HashMap.get() 的链表遍历中(at java.util.HashMap.getEntry)。请分析可能原因,给出系统化排查步骤和修复方案。

详细解析:

可能原因

  1. 哈希碰撞严重 :自定义 Key 的 hashCode() 实现质量极差,如恒定返回固定值,导致所有元素坠入同一个桶,链表长度极大,get 操作退化为 O(n),CPU 消耗在长链表遍历上。
  2. 负载因子设置不当:如负载因子被设置为过高(接近 1.0),导致桶内元素密度过大,链表变长,查询性能下降。
  3. JDK 7 并发扩容死链 :若运行环境为 JDK 7,多线程并发 put 触发扩容形成环形链表,get 遍历时陷入死循环,CPU 100%。

排查步骤

  1. 确认 JDK 版本java -version,若为 JDK 7,高度怀疑死链。
  2. 线程堆栈分析jstack <pid>,观察堆栈是否停在 HashMap.getEntryHashMap.transfer(JDK 7)。如果出现 transfer,基本确认为并发扩容死链。
  3. 堆对象分布jmap -histo:live <pid>,查看 HashMap 内部的 Key 类的实例数。若 Key 实例数与 Map 大小严重不符,可能存在大量"丢失"元素,指向死链或碰撞。
  4. 检查 hashCode 实现 :审查 Key 类的 hashCode() 方法,编写单元测试验证其离散程度。可用多个样本模拟散列,计算方差。若大量样本返回同一值,则确定为碰撞问题。
  5. 内存堆转储jmap -dump:format=b,file=heap.hprof <pid>,用 MAT 分析,查看 HashMap 内部各桶的链表长度分布。若某个桶链表长度异常达到数万甚至数十万,且 JDK 7,则环形链表无疑;若 JDK 8 也出现极长链表,则为严重碰撞。

修复方案

  • 优化 hashCode() :使用 IDE 生成或 Objects.hash() 重写高质量的哈希函数,确保分布均匀。
  • 调整负载因子或初始容量 :若 loadFactor 被修改,恢复为 0.75;若已知元素数量,按公式 (int)(expectedSize / 0.75f + 1) 指定初始容量。
  • JDK 7 环境必须升级到 JDK 8 ,或替换为 ConcurrentHashMap,从根本上杜绝死链问题。

11. HashMap 和 Hashtable 的区别是什么?为什么 Hashtable 不允许 null key/value 而 HashMap 允许?

详细解析:

主要区别

  • 线程安全Hashtable 每个方法都用 synchronized 修饰,是线程安全的,但并发性能极差;HashMap 非线程安全。
  • null 支持Hashtable 不允许 null key 或 null value,否则抛出 NullPointerExceptionHashMap 允许一个 null key 和多个 null value。
  • 迭代器Hashtable 使用 Enumeration(非 fail-fast),HashMap 使用 fail-fast 迭代器。
  • 继承关系Hashtable 继承自古老的 Dictionary 类,HashMap 继承自 AbstractMap
  • 初始容量与扩容Hashtable 默认初始容量为 11,扩容为 2n+1(无 2 的幂约束);HashMap 容量始终为 2 的幂。

为什么 Hashtable 不允许 null? Hashtable 的设计哲学是"安全第一"。它认为 null 会导致歧义:get(key) 返回 null 时,你无法区分是该 key 不存在,还是其 value 就是 null。而 HashMap 通过提供 containsKey 方法解决了这一歧义,且特意将 null key 的哈希值设为 0,放在 table[0],实现了支持 null 的灵活设计。


12. 如何预估一个 HashMap 的最佳初始容量?为什么公式是 expectedSize / loadFactor + 1

详细解析:

公式int initialCapacity = (int) (expectedSize / 0.75f + 1);

推导 :我们希望放入 expectedSize 个元素的过程中完全不触发扩容 。扩容条件是 size > threshold = capacity * loadFactor。因此需要:

复制代码
capacity * loadFactor >= expectedSize
capacity >= expectedSize / loadFactor

使用 0.75f 作为默认负载因子时,capacity >= expectedSize / 0.75f+1 的作用是向上取整补偿浮点截断误差,并确保在元素恰好达到 expectedSize 时尚未超过阈值。

后续处理 :该 initialCapacity 传入构造函数后,会经 tableSizeFor 上调为最近的 2 的幂。例如 expectedSize=100,计算得 100/0.75+1≈134.33 取整为 134,tableSizeFor(134) 得到 256,实际容量为 256,阈值 192,远大于 100,不会触发扩容。

最佳实践:在任何明确知道元素数量的场景,都应使用此公式指定初始容量,这是 HashMap 最有效的性能优化手段。


13. HashMap 的 modCount 字段是做什么的?为什么它的迭代器是 fail-fast 的?

详细解析:

modCountAbstractHashMap(由 AbstractMap 定义,HashMap 继承)中一个 transient int 字段,记录 HashMap 结构性修改的次数。所谓结构性修改,是指改变了 HashMap 内部映射关系的操作,如 putremoveclearresize 等,而单纯的 set 覆盖 value 不修改 modCount

fail-fast 机制 :HashMap 的迭代器(EntrySet.iteratorKeySet.iteratorValues.iterator)在创建时都会记录当时的 expectedModCount = modCount。在每次调用 next()remove() 时,会检查 modCount != expectedModCount,若发现不一致,立即抛出 ConcurrentModificationException

设计目的:快速、干净地报告并发修改错误。在迭代器遍历期间,如果其他线程(甚至是同一线程)通过非迭代器的方法修改了 HashMap,迭代器能够尽早探测到并失败,而不是继续运行并产生不可预知的结果(如跳过元素或重复元素)。这是"尽早失败,干净失败"设计原则的体现。


14. 为什么 JDK 8 引入红黑树?树化阈值为什么是 8?退化阈值为什么是 6?为什么树化前还要检查容量是否 ≥ 64?

详细解析:

引入红黑树的原因 :极端哈希碰撞下,链表长度可能变得非常大,导致 getput 操作退化为 O(n)。为了解决这一最坏情况,JDK 8 引入了红黑树,将最坏时间复杂度降低到 O(log n)

树化阈值为 8 的依据 :在随机哈希码和负载因子 0.75 下,根据泊松分布,一个桶中元素达到 8 的概率小于千万分之一(0.00000006)。因此,正常情况下几乎永远不会触发树化。阈值设为 8 既保证了在攻击或错误哈希函数下能够自我保护,又避免了在正常场景下付出树化的额外开销(红黑树节点 TreeNode 的内存是普通 Node 的约两倍)。

退化阈值为 6 :当红黑树节点数降到 6 时,转换回链表。设置 6 而不是 8 是为了避免在临界值反复树化和退化(震荡),留出一个缓冲区间。

容量 ≥ 64 的检查 :在 treeifyBin 方法中,如果 table.length < MIN_TREEIFY_CAPACITY (64),会优先进行扩容,而不是树化。因为扩容能将大链表拆分,比树化的成本更低且整体收益更大。只有在容量足够大,单桶链表仍超长时才树化。


15. 为什么使用 String 作为 Key 特别常见?StringhashCode() 有什么特点?

详细解析:

StringHashMap 中最常用的 Key 类型,原因如下:

  1. 不可变String 对象一旦创建,其值不可改变,哈希值可被安全缓存,符合 Key 稳定性的最高要求。
  2. hashCode() 设计优良String 的哈希算法是按位加权:s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]。选择 31 作为乘数,因为 31 是奇素数,散列分布性好,且乘法可被 JVM 优化为 (i << 5) - i(移位和减法),计算极快。
  3. equals() 精确可靠:先判断长度,再逐个字符比较,性能高且逻辑正确。
  4. 普遍性:业务中的键往往是字符串,如用户名、配置键、URL 等,天然适合。

工程启发 :自定义 Key 类应效仿 String 的实现原则:hashCode 计算快且分布均匀,equals 严格且与 hashCode 保持契约一致,且类本身不可变。


Demo 代码

1. tableSizeFor 验证

java 复制代码
import java.lang.reflect.Method;

public class HashMapAnalysis {
    // 模拟 HashMap.tableSizeFor,因其为 package-private,这里复现以便测试
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= 1 << 30) ? 1 << 30 : n + 1;
    }

    public static void main(String[] args) {
        System.out.println("tableSizeFor(10): " + tableSizeFor(10)); // 16
        System.out.println("tableSizeFor(16): " + tableSizeFor(16)); // 16
        System.out.println("tableSizeFor(31): " + tableSizeFor(31)); // 32
        System.out.println("tableSizeFor(100): " + tableSizeFor(100)); // 128
    }
}

2. 扩容观察与碰撞复现

java 复制代码
import java.util.HashMap;
import java.util.Map;

public class CollisionDemo {
    // 一个"邪恶"的 Key 类,hashCode 永远返回 1,但 equals 正常
    static class EvilKey {
        private final String id;
        public EvilKey(String id) { this.id = id; }
        @Override public int hashCode() { return 1; } // 故意制造碰撞
        @Override public boolean equals(Object obj) {
            if (this == obj) return true;
            if (obj instanceof EvilKey) return id.equals(((EvilKey) obj).id);
            return false;
        }
    }

    public static void main(String[] args) {
        Map<EvilKey, String> map = new HashMap<>();
        // 向同一个桶(索引为1)疯狂插入,观察退化
        for (int i = 0; i < 20; i++) {
            map.put(new EvilKey("Key" + i), "Value" + i);
        }
        // 在此处打上断点,使用 IDE 的 Debug 工具查看 HashMap 的内部结构 table
        // 可以清晰看到 table[1] 上有一个长达20的链表,证实了碰撞退化为链表
        System.out.println("Size: " + map.size()); // 输出 20,数据没丢,但性能已退化
    }
}

3. JDK 7 死链复现(概念性示例,切勿在生产环境运行)

java 复制代码
// 此代码仅供理解概念,实际复现依赖JDK环境和CPU时序,极难稳定重现
// 但在 JDK 7 下高并发 put 确实可能导致此问题
public class JDK7DeadLoopSim {
    public static void main(String[] args) {
        // 必须使用 JDK 7 运行此代码
        final Map<Integer, String> map = new HashMap<>(2, 0.75f);
        // 预先放入一些元素,使扩容在并发时发生
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    map.put(j, "value");
                }
            }).start();
        }
        // 如果运行在 JDK 7,此程序可能陷入死循环,CPU 100%
        System.out.println("If you see this on JDK 7, you might be lucky. Check CPU usage.");
    }
}

延伸阅读

  • 《Effective Java(第3版)》第11条 :覆盖 hashCode 时总要覆盖 equals
  • 《Java 编程思想(第4版)》第17章:容器深入研究。
  • OpenJDK 源码HashMap.java (JDK 8), HashMap.java (JDK 7)。强烈建议阅读官方源码注释,尤其是关于泊松分布和红黑树的部分。
  • 论文:T. H. Cormen, 《Introduction to Algorithms》第11章(散列表),提供了哈希表理论的坚实基础。

产出自查清单

  • HashMap 底层 Node[] 结构 + 2 的幂容量约束讲解完整
  • 哈希扰动函数设计意图和索引计算数学原理清晰
  • put 流程(null key、碰撞处理、树化条件)完整,含时序图
  • resize 扩容机制(尾插法、高低位拆分)讲解透彻,数学证明完整
  • JDK 7 头插法死链推演完整,对比 JDK 8 尾插法改进清晰,含时序图
  • loadFactor 0.75 的泊松分布依据和调优方向到位
  • 图表 ≥ 7 张(结构图、哈希流程图、put时序图、拆分图、死链时序图、JDK7/8对比图、负载因子图),每张四层说明
  • 面试题 ≥ 10 题,含故障排查题,每题附深度解析
  • 字数远超 9000 字,涵盖系统设计视角
  • 全文 Markdown,关键术语加粗,数字编号
  • 基于 JDK 8,完整对比 JDK 7 差异
  • 文末附"HashMap 速查表"(初始容量、扩容倍数、loadFactor、树化阈值、扰动函数)

HashMap 速查表

特性 备注
底层结构 Node[] 数组 + 链表/红黑树 JDK 8 引入红黑树
默认初始容量 16 容量始终为 2 的幂
最大容量 1 << 30 大约 10.7 亿
扩容倍数 2 倍 newCap = oldCap << 1
默认负载因子(loadFactor) 0.75f 时间-空间权衡点
树化阈值(TREEIFY_THRESHOLD) 8 链表长度达到此值时尝试树化
退化阈值(UNTREEIFY_THRESHOLD) 6 红黑树节点小于此值时退化为链表
最小树化容量(MIN_TREEIFY_CAPACITY) 64 table 长度小于此值时优先扩容而非树化
哈希扰动函数 h ^ (h >>> 16) 高 16 位混合进低 16 位
索引计算公式 hash & (capacity - 1) 等价于 hash % capacity
null key 存储位置 table[0] null key 的哈希值强制为 0
扩展点 afterNodeAccess, afterNodeInsertion, afterNodeRemoval 模板方法模式,为 LinkedHashMap 预留
并发安全 建议使用 ConcurrentHashMap

相关推荐
Yeats_Liao2 小时前
物联网接入层技术剖析(二):epoll到底是怎么工作的
java·linux·网络·物联网·信息与通信
DevOpenClub2 小时前
职教高考及高职分类招生控制线 API 接口
java·数据库·高考
Tsuki_tl2 小时前
【总结】Java的线程状态
java·后端·面试·多线程·并发编程·线程状态
苦逼的猿宝2 小时前
springboot的网页时装购物系统
java·毕业设计·springboot·计算机毕业设计
WL_Aurora2 小时前
Java多线程编程基础与实践
java·多线程
再写一行代码就下班2 小时前
根据给定word模板,动态填充指定内容,并输出为新的word文档。(${aa}占位符方式且支持循环动态表格)
java·开发语言
西安邮电大学2 小时前
SpringMVC执行流程
java·后端·spring·面试
i220818 Faiz Ul2 小时前
智慧养老平台|基于SprinBoot+vue的智慧养老平台系统(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·毕设·智慧养老平台
AI砖家2 小时前
每日一个skill:web-artifacts-builder,构建复杂 Claude.ai HTML Artifact 的生产力工具包
java·前端·人工智能·python