编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • JVM内存模型与对象
      • 类加载与双亲委派
      • 垃圾回收与GC调优
      • 异常体系与JVM机制
      • 字节码指令集javap实战
      • JIT编译与去优化机制
      • JVM性能诊断工具链
      • OOM八大现场全景剖析
      • JVM参数调优全景图
      • GraalVM与AOT编译原理
      • HashMap底层哈希设计
        • 4.1 开篇疑问
        • 4.2 哈希表的核心思想
          • 4.2.1 什么是哈希表
          • 4.2.2 哈希冲突的本质
          • 4.2.3 冲突解决策略对比
        • 4.3 HashMap数据结构演进
          • 4.3.1 JDK7数组加链表
          • 4.3.2 JDK8引入红黑树
          • 4.3.3 为什么引入红黑树而非AVL树
          • 4.3.4 Node节点的数据结构
        • 4.4 核心源码深度剖析
          • 4.4.1 hash方法的扰动设计
          • 4.4.2 put流程完整源码分析
          • 4.4.3 get流程分析
          • 4.4.4 扩容机制resize详解
          • 4.4.5 树化与退化机制
        • 4.5 关键设计决策的数学原理
          • 4.5.1 容量为什么是2的幂
          • 4.5.2 tableSizeFor保证2的幂
          • 4.5.3 负载因子为什么是0.75
          • 4.5.4 链表转树阈值为什么是8
          • 4.5.5 为什么退化阈值是6而不是8
        • 4.6 并发安全问题深度分析
          • 4.6.1 JDK7的死循环详解
          • 4.6.2 JDK8的数据覆盖问题
          • 4.6.3 ConcurrentHashMap的演进
          • 4.6.4 ConcurrentHashMap的size计算
        • 4.7 HashMap的遍历与序列化
          • 4.7.1 遍历方式与性能对比
          • 4.7.2 fail-fast机制原理
          • 4.7.3 序列化的特殊处理
        • 4.8 HashMap家族对比
          • 4.8.1 LinkedHashMap有序实现
          • 4.8.2 TreeMap排序实现
          • 4.8.3 WeakHashMap的缓存场景
        • 4.9 生产环境最佳实践
        • 4.10 总结与核心要点
      • String不可变与常量池
      • ArrayList与LinkedList源码
      • ConcurrentHashMap并发
      • TreeMap与红黑树原理
      • LinkedHashMap与LRU实现
      • Java数字类型原理
      • Object通用方法的契约
      • 泛型擦除与类型系统
      • 枚举原理与最佳实践
      • 注解原理与编译期处理
      • Lambda与引用底层原理
      • Stream原理与流水线设计
      • Optional设计原理
      • Record密封类与模式
      • 反射机制与动态代理
      • MethodHandle与VarHandle
      • 三大字节码框架对比
      • JavaAgent与Instrumentation机制
      • AOP三种实现路线对比
      • synchronized与锁升级
      • volatile与JMM内存模型
      • 线程池核心源码设计
      • Thread线程生命周期
      • AQS同步框架源码
      • 并发锁三剑客
      • CAS和Atomic深入分析
      • 五大同步器对比
      • CompletableFuture异步
      • IO模型演进BIO到AIO
      • ByteBuffer与堆外内存
      • 序列化原理与替代方案
      • 文件IO与NIO.2
      • 面向对象的真意
      • JDK设计模式上
      • JDK设计模式下
      • SPI与模块化设计
  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Java入门精通
  • 专栏博客
杨充
2026-06-02
目录

HashMap底层哈希设计

# 11.HashMap底层哈希设计

# 目录介绍

  • 4.1 开篇疑问
  • 4.2 哈希表的核心思想
    • 4.2.1 什么是哈希表
    • 4.2.2 哈希冲突的本质
    • 4.2.3 冲突解决策略对比
  • 4.3 HashMap数据结构演进
    • 4.3.1 JDK7数组加链表
    • 4.3.2 JDK8引入红黑树
    • 4.3.3 为什么引入红黑树而非AVL树
    • 4.3.4 Node节点的数据结构
  • 4.4 核心源码深度剖析
    • 4.4.1 hash方法的扰动设计
    • 4.4.2 put流程完整源码分析
    • 4.4.3 get流程分析
    • 4.4.4 扩容机制resize详解
    • 4.4.5 树化与退化机制
  • 4.5 关键设计决策的数学原理
    • 4.5.1 容量为什么是2的幂
    • 4.5.2 tableSizeFor保证2的幂
    • 4.5.3 负载因子为什么是0.75
    • 4.5.4 链表转树阈值为什么是8
    • 4.5.5 为什么退化阈值是6而不是8
  • 4.6 并发安全问题深度分析
    • 4.6.1 JDK7的死循环详解
    • 4.6.2 JDK8的数据覆盖问题
    • 4.6.3 ConcurrentHashMap的演进
    • 4.6.4 ConcurrentHashMap的size计算
  • 4.7 HashMap的遍历与序列化
    • 4.7.1 遍历方式与性能对比
    • 4.7.2 fail-fast机制原理
    • 4.7.3 序列化的特殊处理
  • 4.8 HashMap家族对比
    • 4.8.1 LinkedHashMap有序实现
    • 4.8.2 TreeMap排序实现
    • 4.8.3 WeakHashMap的缓存场景
  • 4.9 生产环境最佳实践
  • 4.10 总结与核心要点

# 4.1 开篇疑问

疑惑:HashMap 几乎是 Java 面试必问、工作必用的数据结构。为什么容量必须是 2 的幂次方?负载因子默认是 0.75?JDK 8 为什么引入红黑树而不是 AVL 树?多线程下 HashMap 会出什么问题?JDK 7 的扩容真的会导致死循环吗?

答疑:这些问题的答案都藏在 HashMap 的源码设计中。本篇从哈希表原理出发,逐步拆解每一个关键设计决策背后的深层逻辑,包括泊松分布的数学推导、位运算的精妙设计、并发场景下的致命问题。

# 4.2 哈希表的核心思想

# 4.2.1 什么是哈希表

哈希表(Hash Table)是根据键直接访问内存存储位置的数据结构。核心思想:

Key → hash(Key) → index → Value
1

理想情况下查找、插入、删除操作时间复杂度为 O(1):

数据结构 查找 插入 删除
数组(按值查找) O(n) O(n) O(n)
有序数组(二分查找) O(log n) O(n) O(n)
平衡二叉搜索树 O(log n) O(log n) O(log n)
哈希表 O(1) 平均 O(1) 平均 O(1) 平均

哈希函数的核心要求:

  1. 确定性:同一个 key 必须映射到同一个 index
  2. 均匀性:不同的 key 应尽量均匀地分布到不同的 index
  3. 高效性:计算过程要快

# 4.2.2 哈希冲突的本质

鸽巢原理(Pigeonhole Principle):如果有 n+1 只鸽子要放进 n 个巢中,必然有一个巢至少有两只鸽子。

哈希表容量为 16,可能存入的 key 有无穷多个。无论哈希函数设计得多好,冲突不可避免。

// 不同的 key 可能有相同的 hashCode
"Aa".hashCode();    // 2112
"BB".hashCode();    // 2112
// 更极端:任意两个字符串都可能碰撞到同一个桶
1
2
3
4

# 4.2.3 冲突解决策略对比

策略 原理 优点 缺点 典型实现
链地址法 同一桶的元素组成链表 实现简单、删除方便 缓存不友好 Java HashMap
开放寻址法 冲突后探测下一个空位 缓存友好 删除复杂、负载因子不能太高 Python dict
再哈希法 用多个哈希函数 分散性好 计算开销大 布隆过滤器
公共溢出区 冲突元素放到单独区域 冲突少时高效 冲突多时性能差 —

HashMap 选择链地址法,JDK 8 在链表过长时升级为红黑树,是两种策略的结合。

# 4.3 HashMap数据结构演进

# 4.3.1 JDK7数组加链表

JDK 7 的 HashMap 结构是数组 + 链表:

table[] (Entry数组)
  [0] → null
  [1] → Entry(k1,v1) → Entry(k5,v5) → Entry(k9,v9) → null
  [2] → Entry(k2,v2) → null
  [3] → null
  ...
  [15] → Entry(k7,v7) → null
1
2
3
4
5
6
7

每个 Entry 包含四个字段:

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;    // 指向下一个冲突元素
    int hash;           // 缓存 hashCode
}
1
2
3
4
5
6

JDK 7 关键特点:

  • 新元素使用头插法插入链表(时间复杂度 O(1))
  • 扩容时链表会反转(因为头插法重新插入)

# 4.3.2 JDK8引入红黑树

JDK 8 的结构演进为数组 + 链表 + 红黑树:

table[] (Node数组)
  [0] → null
  [1] → Node → Node → null                    (链表,≤8个)
  [2] → TreeNode(红黑树根)                      (链表长度>8 且数组长度≥64 时转树)
  ...
1
2
3
4
5

JDK 8 两个重大改变:

  1. 链表长度 > 8 且数组长度 ≥ 64 时转红黑树,< 6 退化为链表
  2. 插入从头插法改为尾插法(解决并发扩容死循环)

# 4.3.3 为什么引入红黑树而非AVL树

疑惑:AVL 树是严格平衡的,查找效率更高,为什么不用 AVL 树?

论证:HashMap 的使用场景决定了选择:

特性 AVL 树 红黑树
平衡度 严格平衡(左右子树高度差≤1) 近似平衡(最长路径≤最短路径的2倍)
查找性能 略优(树更矮) 稍逊(树略高)
插入/删除 旋转操作多(最多 O(log n) 次) 旋转操作少(最多 3 次)
适用场景 查找密集型 增删改频繁型

HashMap 中的树节点需要频繁插入和删除(put/remove),红黑树的旋转操作更少(插入最多 2 次旋转,删除最多 3 次旋转),维护平衡的开销更低。AVL 树虽然查找略快,但在 HashMap 这种增删频繁的场景下,综合性能不如红黑树。

# 4.3.4 Node节点的数据结构

// JDK 8 的 Node 节点
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;       // hashCode 的扰动结果
    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;
    }
}

// 红黑树节点(继承自 LinkedHashMap.Entry,间接继承 Node)
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;   // 用于删除时连接
    boolean red;           // 颜色标记
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

设计细节:TreeNode 继承链为 TreeNode → LinkedHashMap.Entry → HashMap.Node,这意味着 TreeNode 同时保留了链表结构(next 指针),方便树退化为链表时使用。

# 4.4 核心源码深度剖析

# 4.4.1 hash方法的扰动设计

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
1
2
3
4

逐步分析:

假设 key.hashCode() = 0b 1111_1111_1010_0110_0000_0001_0011_0101

原始 hashCode:    1111 1111 1010 0110 | 0000 0001 0011 0101
                  ← 高16位 →           ← 低16位 →

h >>> 16:         0000 0000 0000 0000 | 1111 1111 1010 0110
                                        ← 高16位右移到低16位 →

XOR 结果:         1111 1111 1010 0110 | 1111 1110 1001 0011
                                        ← 低16位融合了高16位信息 →

桶索引计算: (n-1) & hash
当 n=16:  n-1 = 0000 0000 0000 0000 0000 0000 0000 1111
          只用到了低4位!
          
如果不做扰动:低4位 = 0101 → index = 5
做了扰动后:  低4位 = 0011 → index = 3

高16位的信息参与了桶定位,减少了只靠低位分布不均的冲突
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

疑惑:JDK 7 的 hash 方法做了 4 次移位和 4 次异或,JDK 8 为什么只做了 1 次?

论证:JDK 8 引入了红黑树兜底极端冲突场景,不再需要过度依赖 hash 分散来防止性能退化。1 次右移 + 1 次异或已经足够将高位信息混入低位,多次扰动的额外收益不大,却增加了计算开销。

null key 的特殊处理:hash(null) 返回 0,即 null 键固定放在 table[0]。HashMap 允许一个 null key 和多个 null value。

# 4.4.2 put流程完整源码分析

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: 数组为空则初始化(懒加载)
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    
    // 步骤2: 计算桶位置,桶为空直接放入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        
        // 步骤3: 桶的第一个节点就是目标key
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        
        // 步骤4: 桶是红黑树
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        
        // 步骤5: 桶是链表
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);   // 尾插法
                    // 链表长度 >= 8 时尝试树化
                    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;
            }
        }
        
        // 步骤6: 如果找到了相同key的节点,更新value
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);     // LinkedHashMap 的回调
            return oldValue;
        }
    }
    
    ++modCount;  // 结构修改次数(fail-fast 机制)
    
    // 步骤7: 超过阈值则扩容
    if (++size > threshold)
        resize();
    
    afterNodeInsertion(evict);  // LinkedHashMap 的回调
    return null;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

流程图:

put(key, value)
  → 计算 hash = hash(key)
    → table == null?→ resize() 初始化
    → 计算桶位置 i = (n-1) & hash
      → tab[i] == null?→ 直接放入新节点
      → tab[i] 不为空:
        → 第一个节点 key 相同?→ 记录为 e,待更新
        → 是 TreeNode?→ 红黑树的 putTreeVal()
        → 是链表?→ 尾插法遍历
          → 找到相同 key?→ 记录为 e,待更新
          → 到尾部?→ 插入新节点
            → 链表长度 ≥ 8?→ treeifyBin()
      → e != null?→ 更新旧值
    → ++size > threshold?→ resize() 扩容
1
2
3
4
5
6
7
8
9
10
11
12
13
14

关键设计细节:

  1. 懒加载:首次 put 才创建数组,节省内存
  2. 先比较 hash 再比较 equals:hash 比较是 int 比较(O(1)),equals 可能耗时
  3. 尾插法:链表新节点插到末尾,避免了 JDK 7 头插法的并发死循环
  4. modCount:每次结构修改递增,Iterator 用它检测并发修改

# 4.4.3 get流程分析

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;
    
    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) {
            // 红黑树查找
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 链表遍历查找
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

性能分析:

  • 最好情况 O(1):桶中只有一个元素(负载因子 0.75 时约 60% 的桶为空,30% 只有 1 个元素)
  • 链表情况 O(k):k 为链表长度
  • 红黑树情况 O(log k):k 为树中节点数

# 4.4.4 扩容机制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;
    
    if (oldCap > 0) {
        // 已达最大容量,不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 容量翻倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;  // 阈值也翻倍
    }
    // ...初始化逻辑省略
    
    // 重新分配所有元素
    Node<K,V>[] newTab = new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;  // help GC
                
                if (e.next == null)
                    // 桶中只有一个元素,直接放到新位置
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    // 红黑树的拆分
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else {
                    // 链表的拆分——JDK 8 的精妙优化
                    Node<K,V> loHead = null, loTail = null;  // 低位链表
                    Node<K,V> hiHead = null, hiTail = null;  // 高位链表
                    Node<K,V> next;
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            // 留在原位置
                            if (loTail == null) loHead = e;
                            else loTail.next = e;
                            loTail = e;
                        } else {
                            // 移到新位置 = 原位置 + oldCap
                            if (hiTail == null) hiHead = e;
                            else hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67

精妙的位运算判断:

旧容量 oldCap = 16 = 0001 0000

hash = 5  (0000 0101):  hash & oldCap = 0000 0000 = 0  → 留在 index=5
hash = 21 (0001 0101):  hash & oldCap = 0001 0000 ≠ 0  → 移到 index=5+16=21

原理:容量翻倍意味着 (n-1) 多了一个高位 1
旧 n-1 = 0000 1111  (只看低4位)
新 n-1 = 0001 1111  (看低5位)

多出来的那个 bit(第5位)决定了元素去向:
  该 bit 为 0 → 留在原位
  该 bit 为 1 → 移到 原位置 + oldCap
1
2
3
4
5
6
7
8
9
10
11
12

对比 JDK 7 的扩容:JDK 7 需要重新计算每个元素的 hash & (newCap-1) 索引。JDK 8 只需一次位运算判断即可,更高效。

# 4.4.5 树化与退化机制

树化条件(两个条件都要满足):

  1. 链表长度 ≥ 8(TREEIFY_THRESHOLD)
  2. 数组长度 ≥ 64(MIN_TREEIFY_CAPACITY)
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,然后构建红黑树
        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);  // 构建红黑树
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

疑惑:为什么数组长度 < 64 时不树化?

论证:数组小的时候,冲突严重通常是因为数组太小而非 hash 分布差。扩容就能解决问题,而树化开销大(TreeNode 占用内存是普通 Node 的 2 倍多)。只有数组已经足够大,链表还很长,才说明确实需要树化。

退化条件:

  • 扩容 split 后,树节点数 ≤ 6 时退化为链表
  • 红黑树的根节点为 null、根的左右子树或左子树的左子树为 null 时退化

# 4.5 关键设计决策的数学原理

# 4.5.1 容量为什么是2的幂

原因一:位运算代替取模

// 取模运算:hash % n
// 等价位运算:hash & (n-1),仅在 n 是 2 的幂时成立

// 证明:当 n = 2^k 时,n-1 的二进制是 k 个 1
// n = 16 = 10000, n-1 = 01111
// hash & 01111 ≡ hash % 10000
// 因为 AND 操作只保留低 k 位,等于对 2^k 取模

// 性能差异:位运算比取模快 5-10 倍
1
2
3
4
5
6
7
8
9

原因二:扩容时元素位置可预测(前面已详述)

原因三:保证哈希分布均匀

n-1 全是 1:      1111 1111 1111 1111
hash & (n-1):     每一位都有效,分布均匀

如果 n 不是 2 的幂,比如 n = 12:
n-1 = 1011
hash & 1011:     第2位始终为0
桶 index 为 x1xx 的永远不会被命中
有效桶数只有 8 个而非 12 个
1
2
3
4
5
6
7
8

# 4.5.2 tableSizeFor保证2的幂

即使你传入的初始容量不是 2 的幂,HashMap 也会自动调整:

// 将传入的 cap 向上取整到最近的 2 的幂
static final int tableSizeFor(int cap) {
    int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

// JDK 8 的经典位运算写法:
static final int tableSizeFor(int cap) {
    int n = cap - 1;   // 防止 cap 已经是 2 的幂时多扩一倍
    n |= n >>> 1;      // 最高位的 1 扩展到相邻低位
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;     // 最终 n 的有效位全变成 1
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

// 示例:cap = 10
// n = 9 = 1001
// n |= n>>>1 → 1001 | 0100 = 1101
// n |= n>>>2 → 1101 | 0011 = 1111
// n |= n>>>4 → 1111 | 0000 = 1111
// ...
// n + 1 = 10000 = 16
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

精妙之处:通过 5 次位或运算,将最高位的 1 传播到所有低位,然后 +1 得到 2 的幂。整个过程没有循环,固定 5 步完成。

# 4.5.3 负载因子为什么是0.75

负载因子 = 元素个数 / 数组长度。0.75 是空间利用率和查找效率的最佳平衡点。

数学推导:假设哈希函数理想均匀,每个桶的元素数服从泊松分布 P(X=k) = (λ^k × e^(-λ)) / k!,其中 λ = 负载因子。

当 λ = 0.75 时,桶中元素个数的概率分布:
P(0) = 0.47237    → 47% 的桶为空
P(1) = 0.35428    → 35% 的桶有1个元素
P(2) = 0.13285    → 13% 的桶有2个元素
P(3) = 0.03321    →  3% 的桶有3个元素
P(4) = 0.00623    → 0.6%
P(5) = 0.00093    → 0.09%
P(6) = 0.00012    → 0.01%
P(7) = 0.00001    → 0.001%
P(8) = 0.00000006 → 千万分之六
1
2
3
4
5
6
7
8
9
10

如果负载因子过小(如 0.5):

  • 47% → 60% 的桶为空,空间严重浪费
  • 扩容更频繁,每次扩容都要重新分配和复制

如果负载因子过大(如 1.0):

  • 每个桶平均 1 个元素,冲突概率显著增加
  • 平均链表长度增加,查找退化

0.75 是实验和理论兼顾的选择:空间利用率约 75%,平均链表长度约 0.5(大部分桶 0~1 个元素),查找性能接近 O(1)。

# 4.5.4 链表转树阈值为什么是8

结合上面泊松分布的计算,负载因子 0.75 时链表长度达到 8 的概率是 0.00000006(千万分之六)。

选择 8 的理由:
1. 正常情况下不会触发树化 → 避免红黑树的额外开销
   - TreeNode 占用内存约是 Node 的 2.5 倍
   - 红黑树需要维护平衡(旋转操作)
   
2. 极端情况下能兜底 → 防止 Hash DoS 攻击
   - 攻击者构造大量哈希碰撞的 key
   - 链表退化为 O(n),红黑树保证 O(log n)
   
3. 与退化阈值 6 配合,留出缓冲区间
1
2
3
4
5
6
7
8
9
10

# 4.5.5 为什么退化阈值是6而不是8

疑惑:为什么不在链表长度降到 8 以下时就退化?

论证:如果树化和退化的阈值相同,当链表长度在阈值附近频繁增减时,会反复在链表和红黑树之间切换,即"乒乓效应"(thrashing)。

假设阈值都是 8:
插入第8个元素 → 树化(开销大)
删除1个元素   → 退化为链表(开销大)
再插入1个元素 → 又树化
...频繁切换,性能严重下降

设置为 6:
插入第8个元素 → 树化
删除到7个     → 仍是树(不退化)
删除到6个     → 退化为链表
// 中间有 2 个元素的缓冲区间,避免频繁切换
1
2
3
4
5
6
7
8
9
10
11

# 4.6 并发安全问题深度分析

# 4.6.1 JDK7的死循环详解

JDK 7 的 resize() 使用头插法迁移链表元素,多线程并发扩容时可能形成环形链表。

详细过程:

初始状态,table[1] 的链表:A → B → C → null

线程1 开始扩容,记录了:
  e = A, next = B

线程1 被挂起(时间片用完)

线程2 完成扩容,由于头插法,新链表变为:
  newTable[1]: C → B → A → null

线程1 恢复执行,但线程1 还记得 e=A, next=B:
  第1轮:头插法,A 放到 newTable[1] 头部
         newTable[1]: A → null
  
  第2轮:e = B, next = B.next
         但此时 B.next 已被线程2 改为 A!
         next = A
         头插法,B 放到头部
         newTable[1]: B → A → null
  
  第3轮:e = A(next 记的)
         A.next = newTable[1] 头部 = B
         A → B → A → B → ...  死循环!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
最终结构:
newTable[1]: B ⟷ A  (A.next=B, B.next=A,形成环)

后续任何 get(key) 如果 hash 到 table[1],遍历链表时就会死循环
CPU 飙升到 100%
1
2
3
4
5

# 4.6.2 JDK8的数据覆盖问题

JDK 8 改为尾插法,解决了死循环,但并发 put 仍有问题:

问题一:数据覆盖

// 两个线程同时 put 不同的 key,但 hash 到同一个空桶
// 线程A 和线程B 都执行到:
if ((p = tab[i = (n - 1) & hash]) == null)
// 两者都发现桶为空

tab[i] = newNode(hash, key, value, null);
// 线程A 先放入,线程B 后放入,覆盖了线程A 的数据
1
2
3
4
5
6
7

问题二:size 计数不准确

// ++size 不是原子操作
// 两个线程同时 put,各自 ++size
// 可能 size 只增加了 1 而不是 2
// 导致扩容时机不准确
1
2
3
4

问题三:树化过程中数据不一致

// 一个线程在树化,另一个线程在同一个桶上操作
// 可能导致树结构损坏
1
2

# 4.6.3 ConcurrentHashMap的演进

JDK 7:分段锁

┌──────────────────────────────────────────┐
│ Segment[0]    Segment[1]    ...  Segment[15] │
│ ┌──────┐    ┌──────┐           ┌──────┐     │
│ │Lock+│    │Lock+│           │Lock+│     │
│ │Entry│    │Entry│           │Entry│     │
│ │数组  │    │数组  │           │数组  │     │
│ └──────┘    └──────┘           └──────┘     │
└──────────────────────────────────────────┘
每个 Segment 独立加锁,默认 16 段,最多支持 16 个线程并发
1
2
3
4
5
6
7
8
9

JDK 8:CAS + synchronized 锁单个桶

final V putVal(K key, V value, boolean onlyIfAbsent) {
    // ...
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();  // CAS 初始化
        
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 桶为空,CAS 无锁插入
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                break;
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);  // 协助扩容
        
        else {
            synchronized (f) {  // 锁住桶的头节点
                // 链表/红黑树操作
            }
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

演进对比:

特性 JDK 7 ConcurrentHashMap JDK 8 ConcurrentHashMap
锁粒度 Segment(段锁) Node(桶锁)
并发度 默认 16 数组长度(可达数万)
数据结构 数组+链表 数组+链表+红黑树
空桶插入 段锁 CAS 无锁
hash 计算 Wang/Jenkins hash spread()

# 4.6.4 ConcurrentHashMap的size计算

JDK 8 的 ConcurrentHashMap 使用类似 LongAdder 的方式计算 size:

// 核心字段
private transient volatile long baseCount;        // 基础计数
private transient volatile CounterCell[] counterCells;  // 分段计数

// 计数逻辑
// CAS 更新 baseCount,失败时分散到 CounterCell 数组
// size() 返回 baseCount + Σ(counterCells[i].value)

// 这种设计避免了所有线程竞争同一个计数器
// 类似于 JDK 8 的 LongAdder
1
2
3
4
5
6
7
8
9
10

# 4.7 HashMap的遍历与序列化

# 4.7.1 遍历方式与性能对比

HashMap<String, Integer> map = new HashMap<>();

// 方式1:entrySet 遍历(推荐,一次获取 key 和 value)
for (Map.Entry<String, Integer> entry : map.entrySet()) {
    String key = entry.getKey();
    Integer value = entry.getValue();
}

// 方式2:keySet 遍历(每次 get 一次额外查找)
for (String key : map.keySet()) {
    Integer value = map.get(key);  // 额外的 hash + 查找
}

// 方式3:values 遍历(只需要 value 时)
for (Integer value : map.values()) {
    // ...
}

// 方式4:JDK 8 forEach
map.forEach((key, value) -> {
    // ...
});

// 方式5:Stream
map.entrySet().stream()
    .filter(e -> e.getValue() > 10)
    .forEach(e -> System.out.println(e.getKey()));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

性能对比:entrySet ≈ forEach > keySet(keySet 多了一次 get 查找)。

# 4.7.2 fail-fast机制原理

// HashMap 内部维护 modCount,每次结构修改(put/remove)递增
transient int modCount;

// Iterator 初始化时记录当前 modCount
HashIterator() {
    expectedModCount = modCount;
    // ...
}

// 每次 next() 时检查
final Node<K,V> nextNode() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
    // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

注意:fail-fast 不是线程安全机制,它只是尽力检测并发修改。在多线程环境下应使用 ConcurrentHashMap。

// 常见错误:遍历时删除元素
for (String key : map.keySet()) {
    if (key.startsWith("temp")) {
        map.remove(key);  // ConcurrentModificationException!
    }
}

// 正确做法1:使用 Iterator.remove()
Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator();
while (it.hasNext()) {
    if (it.next().getKey().startsWith("temp")) {
        it.remove();  // 安全删除
    }
}

// 正确做法2:JDK 8 removeIf
map.entrySet().removeIf(e -> e.getKey().startsWith("temp"));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 4.7.3 序列化的特殊处理

HashMap 的 table 数组声明为 transient,不参与默认序列化:

transient Node<K,V>[] table;
1

疑惑:为什么不直接序列化 table?

论证:

  1. hashCode 不可移植:不同 JVM 版本的 Object.hashCode() 实现可能不同,序列化后反序列化到另一台机器,元素可能在错误的桶中
  2. 稀疏数组浪费空间:table 中大部分桶为 null,序列化这些 null 很浪费
  3. 自定义序列化:HashMap 重写了 writeObject/readObject,只序列化 key-value 对,反序列化时重新 hash 放入桶中

# 4.8 HashMap家族对比

# 4.8.1 LinkedHashMap有序实现

LinkedHashMap 在 HashMap 基础上维护了一条双向链表,保证插入顺序或访问顺序:

// LinkedHashMap.Entry 额外维护双向链表
static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;  // 双向链表指针
}

// accessOrder = false(默认):按插入顺序
// accessOrder = true:按访问顺序(LRU 缓存)
LinkedHashMap<String, Integer> lru = new LinkedHashMap<>(16, 0.75f, true);
1
2
3
4
5
6
7
8

实现 LRU 缓存:

public class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int maxSize;
    
    public LRUCache(int maxSize) {
        super(maxSize, 0.75f, true);  // accessOrder = true
        this.maxSize = maxSize;
    }
    
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > maxSize;  // 超过容量时移除最久未访问的
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 4.8.2 TreeMap排序实现

TreeMap 基于红黑树实现,按 key 的自然顺序或自定义 Comparator 排序:

TreeMap<String, Integer> map = new TreeMap<>();
map.put("banana", 2);
map.put("apple", 1);
map.put("cherry", 3);
// 遍历顺序:apple → banana → cherry(字典序)

// 支持范围查询
map.subMap("b", "d");     // banana, cherry
map.headMap("c");          // apple, banana
map.tailMap("b");          // banana, cherry
1
2
3
4
5
6
7
8
9
10

# 4.8.3 WeakHashMap的缓存场景

WeakHashMap 的 key 是弱引用,当 key 没有其他强引用时会被 GC 回收:

WeakHashMap<Object, String> cache = new WeakHashMap<>();
Object key = new Object();
cache.put(key, "value");

System.out.println(cache.size());  // 1
key = null;  // 断开强引用
System.gc();
System.out.println(cache.size());  // 0(key 被回收,entry 也被清除)
1
2
3
4
5
6
7
8

适用场景:缓存元数据、ClassLoader 相关的映射等。

# 4.9 生产环境最佳实践

1. 初始容量预估:

// 错误:使用默认容量,可能多次扩容
Map<String, Object> map = new HashMap<>();
for (int i = 0; i < 1000; i++) {
    map.put("key" + i, value);
}
// 扩容:16→32→64→128→256→512→1024→2048,共7次扩容

// 正确:预估容量
// 目标容量 = 预期元素数 / 负载因子 + 1
Map<String, Object> map = new HashMap<>(1334);  // 1000/0.75+1 ≈ 1334
// 实际容量 tableSizeFor(1334) = 2048,0次扩容

// 或者使用 Guava
Map<String, Object> map = Maps.newHashMapWithExpectedSize(1000);
1
2
3
4
5
6
7
8
9
10
11
12
13
14

2. key 的选择:

// 最佳 key:不可变对象(String、Integer 等包装类型)
// 原因:hashCode 不会变化,equals 行为一致

// 危险:可变对象作为 key
List<String> list = new ArrayList<>();
list.add("a");
map.put(list, "value1");
list.add("b");                    // 修改了 key
map.get(list);                    // null!hashCode 变了,找不到了
1
2
3
4
5
6
7
8
9

3. 合理使用 computeIfAbsent:

// JDK 8 之前
Map<String, List<String>> map = new HashMap<>();
List<String> list = map.get(key);
if (list == null) {
    list = new ArrayList<>();
    map.put(key, list);
}
list.add(value);

// JDK 8+:一行搞定
map.computeIfAbsent(key, k -> new ArrayList<>()).add(value);
1
2
3
4
5
6
7
8
9
10
11

# 4.10 总结与核心要点

HashMap 设计哲学:

  1. 位运算代替算术运算:hash 扰动、容量 2 的幂、扩容位判断,处处用位运算提升性能
  2. 概率论指导参数选择:负载因子 0.75、树化阈值 8,都基于泊松分布的数学推导
  3. 渐进式优化:懒加载数组、链表→红黑树的渐进树化、扩容时的原地分裂
  4. 空间与时间的平衡:负载因子就是空间和时间的权衡器

技术演变总结:

版本 数据结构 冲突处理 插入方式 并发安全 扩容方式
JDK 7 数组+链表 链地址法 头插法 不安全(死循环) 重新 hash 每个元素
JDK 8 数组+链表+红黑树 链地址法+树化 尾插法 不安全(数据覆盖) 位运算判断高低位
CHM 7 分段数组+链表 分段锁 头插法 Segment 锁 段内扩容
CHM 8 数组+链表+红黑树 CAS+sync 尾插法 桶级锁 多线程协助扩容

核心源码方法速览:

方法 作用 关键逻辑
hash() 扰动函数 高16位异或低16位
putVal() 插入元素 懒加载+尾插法+树化
getNode() 查找元素 先检查首节点+链表/树查找
resize() 扩容 容量翻倍+位运算分裂链表
treeifyBin() 链表转红黑树 数组≥64才树化
tableSizeFor() 容量取整 5次位或运算

理解 HashMap 的底层原理,是掌握 Java 集合框架乃至数据结构设计思维的关键一步。

上次更新: 2026/06/10, 11:13:41
GraalVM与AOT编译原理
String不可变与常量池

← GraalVM与AOT编译原理 String不可变与常量池→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式