学习笔记05——HashMap实现原理及源码解析(JDK8)

news/2025/2/27 4:36:20

HashMap实现原理及源码解析(JDK8)


一、核心设计思想
  1. 数组+链表+红黑树:桶数组存储Node节点,哈希冲突时形成链表,链表长度≥8且桶数量≥64时转红黑树
  2. 扰动函数(h = key.hashCode()) ^ (h >>> 16) 消除高位变化的影响
  3. 懒加载:首次put时初始化数组
  4. 负载因子:默认0.75,空间与时间的权衡

二、put()方法源码全解析

代码路径:java.util.HashMap#put

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

核心方法putVal()流程

  1. 初始化检测if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
  2. 计算桶下标(n - 1) & hash
  3. 处理空桶:直接创建新节点
  4. 处理哈希冲突
    • 红黑树节点:putTreeVal()
    • 链表节点:遍历至尾节点插入,同时检测链表长度
  5. 值替换检测if (e != null) { ... return oldValue; }
  6. 扩容检测if (++size > threshold) resize();

源码解读

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; // 当前哈希桶数组
    Node<K,V> p;      // 目标桶的第一个节点
    int n, i;         // n: 数组长度,i: 计算出的桶下标
    
    // ------------------------ 1. 初始化检测 ------------------------
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length; // 首次插入触发初始化
    
    // ------------------------ 2. 计算桶下标 ------------------------
    i = (n - 1) & hash; // 等价于 hash % n(n是2的幂时的优化)
    p = tab[i]; // 获取目标桶的第一个节点
    
    // ------------------------ 3. 处理空桶 ------------------------
    if (p == null)
        tab[i] = newNode(hash, key, value, null); // 直接插入新节点
    
    else {
        Node<K,V> e; // 用于记录已存在的节点(若找到相同key)
        K k;
        
        // ------------------- 4.1 处理首节点匹配 -------------------
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p; // 记录已存在的节点
        
        // ------------------- 4.2 处理红黑树节点 -------------------
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        
        // ------------------- 4.3 处理链表节点 ---------------------
        else {
            for (int binCount = 0; ; ++binCount) {
                // 到达链表尾部
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null); // 尾插法
                    // 检查是否触发树化
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 发现重复key
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e; // 继续遍历
            }
        }
        
        // ------------------- 5. 处理值替换 ------------------------
        if (e != null) { // 存在重复key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value; // 覆盖旧值
            afterNodeAccess(e);  // LinkedHashMap回调
            return oldValue;    // 返回旧值
        }
    }
    
    ++modCount; // 修改计数器(用于快速失败机制)
    
    // ------------------------ 6. 扩容检测 ------------------------
    if (++size > threshold)
        resize(); 
    
    afterNodeInsertion(evict); // LinkedHashMap回调
    return null; // 插入成功返回null
}

三、扩容机制深度剖析

触发条件:元素数量 > 容量 × 负载因子

resize()核心步骤

  1. 计算新容量:原容量×2(保证始终为2的幂)
  2. 创建新数组Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
  3. 数据迁移
    • 单节点:newTab[e.hash & (newCap - 1)] = e;
    • 红黑树:split()方法处理
    • 链表:拆分为低位链和高位链(利用(e.hash & oldCap) == 0判断)

扩容优化点

  • 无需重新计算哈希:通过位运算快速确定新位置
  • 链表保持原始顺序(JDK8改进,避免循环链表问题)

源码解析

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;          // 旧数组
    int oldCap = (oldTab == null) ? 0 : oldTab.length; // 旧容量
    int oldThr = threshold;              // 旧阈值
    int newCap, newThr = 0;              // 新容量、新阈值
    
    // -------------------- 1. 计算新容量和阈值 --------------------
    if (oldCap > 0) { // 扩容场景
        // 容量已达最大值(2^30),不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) { 
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 新容量 = 旧容量 * 2
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // 阈值 * 2
    }
    else if (oldThr > 0) // 初始化场景(指定初始容量)
        newCap = oldThr; // 使用构造方法中计算的阈值作为初始容量
    else { // 无参构造初始化(默认容量16)
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    // 处理新阈值为0的情况(兜底逻辑)
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
                  (int)ft : Integer.MAX_VALUE;
    }
    threshold = newThr; // 更新全局阈值
    
    // -------------------- 2. 创建新数组 --------------------
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab; // 更新全局数组引用
    
    // -------------------- 3. 数据迁移(旧数组→新数组) --------------------
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) { // 遍历旧数组的每个桶
            Node<K,V> e;
            if ((e = oldTab[j]) != null) { // 桶中有数据
                oldTab[j] = null; // 清空旧桶(帮助GC)
                
                // ----------- 3.1 处理单节点 -----------
                if (e.next == null) 
                    newTab[e.hash & (newCap - 1)] = e; // 直接计算新位置
                
                // ----------- 3.2 处理红黑树节点 -----------
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                
                // ----------- 3.3 处理链表节点(关键优化) -----------
                else { 
                    Node<K,V> loHead = null, loTail = null; // 低位链表(原索引)
                    Node<K,V> hiHead = null, hiTail = null; // 高位链表(原索引+oldCap)
                    do {
                        Node<K,V> next = e.next;
                        // 判断是否需要移动位置
                        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);
                    
                    // 将拆分后的链表放入新数组
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead; // 原索引位置
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead; // 新索引位置
                    }
                }
            }
        }
    }
    return newTab;
}

核心设计亮点

  1. 避免重新计算哈希
    通过 (e.hash & oldCap) == 0 判断节点位置,时间复杂度从 O(n) 降为 O(1)
  2. 高低位链表拆分
    • 低位链表:保持原索引位置 j
    • 高位链表:新索引位置为 j + oldCap
    • 顺序保留:JDK8 使用尾插法保持链表原始顺序,避免循环链表问题(对比 JDK7 头插法)
  3. 红黑树退化机制
    当红黑树节点数 ≤6 时退化为链表,避免空间浪费
  4. 延迟初始化
    首次调用 put() 时才会初始化数组(懒加载)

Q1:为什么HashMap扩容是2倍?

  • 保证容量始终为2的幂,使得 (n - 1) & hash 等效于取模运算,且位运算效率更高
  • 扩容时可通过位运算快速确定新位置(无需重新计算哈希)

Q2:JDK8扩容如何避免死循环?

  • JDK7 使用头插法,多线程扩容可能导致循环链表
  • JDK8 改为尾插法,并保持链表原始顺序,彻底解决该问题

Q3:链表拆分的数学原理是什么?
假设旧容量为 oldCap = 16(二进制 10000):

  • hash & oldCap == 0 → 节点新位置 = 原位置
  • hash & oldCap != 0 → 节点新位置 = 原位置 + oldCap
    本质是通过哈希值的第 log2(oldCap) 位是否为1决定位置

Q4:多线程环境下resize()的风险

  • 数据丢失:两个线程同时触发扩容可能导致部分节点丢失
  • 链表断裂:并发修改可能导致链表结构损坏
  • 解决方案:使用 ConcurrentHashMap
四、关键代码
1.哈希扰动函数
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 设计目的:将高16位与低16位异或,减少低位相同导致的哈希碰撞
  • 示例:若 hashCode()0x12345678,扰动后为 0x12345678 ^ 0x00001234 = 0x1234444C
2.树化逻辑
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize(); // 优先扩容而不是树化
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 将链表转为红黑树
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}
  • 树化条件:链表长度 ≥8 桶数量 ≥64(MIN_TREEIFY_CAPACITY
  • 设计考量:避免在小表时过早树化,空间利用率更重要
3.红黑树插入

putTreeVal() 方法中:

  1. 从根节点开始查找插入位置
  2. 比较哈希值和键对象
  3. 插入后通过 balanceInsertion() 进行红黑树平衡操作
4.核心设计亮点
  1. 尾插法 vs 头插法
    • JDK7 使用头插法,多线程扩容可能产生循环链表
    • JDK8 改为尾插法,解决循环链表问题,保证插入顺序
  2. 延迟树化策略
    • 优先扩容而不是立即树化(当桶数量 <64 时)
    • 树节点占用空间是普通节点的2倍,权衡空间与时间
  3. 高低位链表拆分
    • 扩容时通过 (e.hash & oldCap) == 0 判断节点位置
    • 示例:原容量16(二进制10000),哈希值为10101的节点在扩容后会分配到新位置 10101 & 31(11111) = 21

面试回答话术模板

面试官:请说明HashMap的实现原理

回答结构

  1. 总体结构:“HashMap采用数组+链表+红黑树的存储结构,当链表长度超过8且桶数量达到64时,链表会转换为红黑树优化查询效率”
  2. 哈希计算:“通过二次扰动函数处理键的hashCode,使哈希分布更均匀”
  3. put流程
    • 计算桶下标 → 处理空桶/冲突 → 链表/树操作 → 扩容检测
    • 强调尾插法、树化阈值、modCount记录
  4. 扩容机制
    • 2倍扩容保证容量始终为2的幂
    • 数据迁移时的高低位链表拆分算法
  5. 线程安全:“HashMap非线程安全,多线程环境下建议使用ConcurrentHashMap”,原因是:1.两个线程同时put时可能会覆盖彼此的修改;2.jdk7的头插法扩容时产生循环链表,发生死循环;
  6. 延伸补充(可选):
    • 与HashTable的区别(null键支持、同步机制)
    • 负载因子取值依据(0.75的时间空间平衡点)

加分技巧

  • 手绘数据迁移示意图(高低位链表拆分)
  • 举例说明哈希碰撞场景
  • 对比JDK7的头插法实现差异(循环链表问题)
  • 分析树化阈值的统计学依据(泊松分布)

示例回答:

“HashMap通过数组存储Node节点解决快速定位问题,使用链表和红黑树处理哈希冲突。当执行put操作时,首先计算键的扰动哈希值确定桶位置。若发生哈希冲突,JDK8采用尾插法维护链表,当链表长度达到树化阈值时会转换结构提升查询效率。扩容时采用2倍扩容策略,通过位运算快速重新分配节点位置,这个设计避免了重新计算哈希的开销。需要注意的是,HashMap不是线程安全的,多线程put可能导致数据丢失或死循环(JDK7版本)。”


建议结合手写伪代码/流程图进行说明,展现对底层实现的深入理解。同时可准备时间/空间复杂度分析(理想情况下O(1)操作)作为补充。


http://www.niftyadmin.cn/n/5869447.html

相关文章

springBoot统一响应类型3.0版本

前言&#xff1a;起于环境&#xff0c;先于实践&#xff0c;源于变化&#xff0c;升于思考&#xff0c;再于实践&#xff0c;形于总结——实践至上&#xff01; 简单回顾&#xff1a; /*** 基础统一响应类* param <T>*/Data public class apiResult<T> {private in…

九、数据治理架构流程

一、总体结构 《数据治理架构流程图》&#xff08;Data Governance Architecture Flowchart&#xff09; 水平结构&#xff1a;流程图采用水平组织&#xff0c;显示从数据源到数据应用的进程。 垂直结构&#xff1a;每个水平部分进一步划分为垂直列&#xff0c;代表数据治理的…

在WINDOWS系统使用CMake gui编译NLopt配合VSCode使用

1. 准备工作 安装CMake&#xff1a;从CMake官网下载并安装CMake。下载Nlopt源码&#xff1a;从Nlopt官网或GitHub仓库下载Nlopt源码。安装编译器&#xff1a;确保已安装Visual Studio或其他支持的编译器&#xff08;如MinGW&#xff09;。 2. 配置CMake 方式1 打开CMake GU…

网页五子棋——项目测试

目录 测试用例设计 功能测试 注册功能测试 正常注册 异常注册 登录功能测试 正常登录 异常登录 匹配功能测试 对战功能测试 自动化测试 引入依赖 Utils 注册测试 登录测试 匹配测试 RunTest 界面测试 性能测试 总结 测试用例设计 在本篇文章中&#xff0c;…

Maven 依赖的深入理解(一)

一、Maven 依赖初相识 在 Java 项目开发的广袤天地里&#xff0c;Maven 就如同一位得力的助手&#xff0c;而 Maven 依赖则是其核心的 “魔法棒”&#xff0c;为项目构建带来了前所未有的便利与高效。随着项目规模的日益壮大&#xff0c;依赖的管理变得愈发复杂&#xff0c;手…

html - 手工添加上次阅读的位置, 方便下次阅读

文章目录 html - 手工添加上次阅读的位置, 方便下次阅读概述笔记END html - 手工添加上次阅读的位置, 方便下次阅读 概述 在看一本电子书&#xff0c;有pdf格式的&#xff0c;但是比较喜欢看html格式的(复制比较方便)。 但是有个缺点&#xff0c;如果看到一半&#xff0c;关掉…

LangChain教程 - RAG - 支持的100种向量数据库

系列文章索引 LangChain教程 - 系列文章 随着人工智能和机器学习应用的快速发展&#xff0c;尤其是在自然语言处理&#xff08;NLP&#xff09;、图像识别、推荐系统等领域&#xff0c;对高效的向量存储和检索需求日益增长。向量存储用于保存来自深度学习模型或其他机器学习算…

SVT-AV1接入ffmpeg说明

一 编译集成 Files v2.3.0 Alliance for Open Media / SVT-AV1 GitLab cd /SVT-AV1/Build/linux/ ./build.sh make install GitHub - FFmpeg/FFmpeg: Mirror of https://git.ffmpeg.org/ffmpeg.git ./configure --enable-libsvtav1 --enable-gpl --extra-ldflags-L/usr/loca…