HashMap实现原理及扩容机制

什么是 HashMap?

HashMap 是 Java 中的一个集合类,它存储的内容是键值对(key-value)映射 。HashMap 实现了Map 接口,根据键的 HashCode 存储数据,具有很快的访问速度。HashMap 允许一条记录 的键为 null,但不支持线程同步,也就是说HashMap 是非线程安全的 。此外,HashMap 是无序的,不会记录插入的顺序。

简单来说,HashMap 是基于 哈希表(Hash Table) 实现的 Map 接口非线程安全 版本。它用于存储键值对(Key-Value) 映射,允许使用 null 键和 null

它的核心特点是:存取速度快 ,且不保证映射的顺序(特别是顺序不随时间推移而保持不变)。

HashMap 的 key 与 value 类型可以相同也可以不同,可以是字符串(String)类型的 key 和 value,也可以是整型(Integer)的 key 和字符串(String)类型的 value。

底层原理与数据结构

HashMap 的核心原理是数组 + 链表 (JDK 1.8 引入了红黑树),采用链地址法解决哈希冲突。

核心流程

  1. Hash 计算: 当使用 put(key, value) 操作时,HashMap 会先调用 key 的 hashCode(),并通过扰动函数(高位异或低位)计算出最终的 hash 值,目的是让 hash 值分布更均匀。
  2. 索引定位: 通过 (n - 1) & hash 计算出元素在数组中的下标(n 为数组长度)。
  3. 冲突处理: 如果计算出的位置已经有元素(哈希冲突),则以链表 形式连接;如果冲突严重,JDK 1.8 会将其转化为红黑树

核心指标(关键常量)

常量名 默认值 含义与作用
DEFAULT_INITIAL_CAPACITY 1 << 4 (即 16) 默认初始容量。必须是 2 的幂次方。
MAXIMUM_CAPACITY 1 << 30 (即 2^30) 最大容量。受限于 int 的最大值,防止溢出。
DEFAULT_LOAD_FACTOR 0.75f 默认负载因子。空间与时间的平衡点。
TREEIFY_THRESHOLD 8 树化阈值。当链表长度超过 8 时,可能转为红黑树。
UNTREEIFY_THRESHOLD 6 退化阈值。当红黑树节点少于 6 个时,退化为链表。
MIN_TREEIFY_CAPACITY 64 最小树化容量。只有当数组长度 ≥ 64 且链表长度 ≥ 8 时,才转红黑树;否则优先扩容。

关键函数源码解析(以JDK22版本为例)

1. 构造与初始化:懒加载机制

在 JDK 22 中,使用无参构造器 new HashMap<>() 时,并不会立即创建数组

java 复制代码
public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

此时 table 依然是 null。只有第一次调用 put() 方法时,才会真正初始化数组(默认大小为 16)。这叫懒加载,目的是节省内存。

2. 哈希计算:扰动函数

这是 HashMap 的灵魂。JDK 22 依然沿用 JDK 8 的扰动算法。

java 复制代码
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 为什么要这样做?
    • 数组长度通常较小(如 16, 32),计算索引时只用到了 hash 的低位(hash & (n-1))。
    • 如果 hashCode 的高位差异很大而低位相似,会导致严重的哈希冲突。
    • 通过 (h >>> 16) 将高位信息"混合"到低位,让低位也具备高位的变化特征,从而使索引分布更均匀。

3. 插入逻辑 (putVal)

这是最复杂的部分。JDK 22 的插入流程如下:

  1. 计算索引i = (n - 1) & hash
  2. 桶为空 :直接 new Node 放入。
  3. 桶不为空(冲突)
    • Key 相同:覆盖旧值。
    • 是红黑树节点 :调用 putTreeVal 插入树中。
    • 是链表 :遍历链表。
      • 如果是链表尾部,插入新节点。
      • 检查树化 :如果插入后链表长度 > TREEIFY_THRESHOLD (8),调用 treeifyBin
  4. 检查扩容size > threshold,调用 resize()
java 复制代码
    public V put(K key, V value) {
        // 1. 调用 hash(key) 计算扰动后的哈希值
        //    目的:混合高位和低位,减少哈希冲突
        // 2. 调用 putVal 执行真正的插入逻辑
        //    onlyIfAbsent = false: 表示如果 Key 已存在,允许覆盖旧值
        //    evict = true: 表示处于普通模式(非创建模式),允许淘汰节点(虽然 HashMap 默认不淘汰,但在 LinkedHashMap 中用到)
        return putVal(hash(key), key, value, false, true);
    }

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        // 定义局部变量:
        // tab: 引用当前的哈希表数组
        // p:   用于遍历的节点指针(通常指向当前桶的头节点或当前遍历到的节点)
        // n:   数组的长度
        // i:   计算出的数组索引(下标)
        Node<K,V>[] tab; Node<K,V> p; int n, i;

        // ==========================================
        // 步骤 1:初始化或扩容
        // ==========================================
        // 如果数组为空(第一次 put) 或者 数组长度为 0
        if ((tab = table) == null || (n = tab.length) == 0)
            // 调用 resize() 进行初始化(默认长度 16)
            // 并将返回的新数组赋值给 tab,同时获取其长度 n
            n = (tab = resize()).length;

        // ==========================================
        // 步骤 2:计算索引并判断桶是否为空
        // ==========================================
        // (n - 1) & hash: 相当于 hash % n,计算元素应该放在哪个桶
        // 如果该位置为空,说明没有哈希冲突,直接创建新节点放入
        if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    
        // ==========================================
        // 步骤 3:处理哈希冲突(该位置已有元素)
        // ==========================================
        else {
            Node<K,V> e; K k; // e: 临时节点引用; k: 临时 Key 引用

            // 情况 A:Key 已存在(哈希相同 且 Key 相等)
            // p 是当前桶的头节点。如果头节点的 hash 和 key 都与新插入的一致
            if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            // 将 e 指向这个已存在的节点 p,稍后用于覆盖 value
            e = p;

            // 情况 B:当前桶已经是红黑树
            // 如果头节点是 TreeNode 类型,说明该桶已经树化
        else if (p instanceof TreeNode)
            // 调用红黑树的插入方法
            // 返回值 e 如果不为空,说明 Key 已存在;如果为空,说明是新插入
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

        // 情况 C:链表插入
        // 既不是空桶,Key 也不重复,也不是红黑树,那就是普通的链表
        else {
            // binCount 用于统计链表长度(从 0 开始计数)
            for (int binCount = 0; ; ++binCount) {
                // 遍历到链表尾部(e 为 null)
                if ((e = p.next) == null) {
                    // 尾插法:将新节点挂在链表末尾
                    p.next = newNode(hash, key, value, null);
                    
                    // ==========================================
                    // 步骤 4:检查是否需要树化
                    // ==========================================
                    // 如果链表长度 >= 7 (TREEIFY_THRESHOLD - 1)
                    // 注意:binCount 从 0 开始,当插入第 8 个元素时,binCount 为 7
                    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:处理 Key 重复的情况(覆盖 Value)
        // ==========================================
        // 如果 e 不为空,说明在上面的情况 A、B 或 C 中发现了相同的 Key
        if (e != null) { 
            V oldValue = e.value; // 保存旧值
            // onlyIfAbsent 为 false 时,或者旧值为 null 时,才覆盖
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 回调方法,用于 LinkedHashMap 等子类扩展
            afterNodeAccess(e);
            return oldValue; // 返回旧值
        }
    }

    
    /**
     * 将指定桶(Bucket)中的链表转换为红黑树结构
     *
     * @param tab 哈希表数组
     * @param hash 当前插入元素的 hash 值(用于定位桶的索引)
     */
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; // n 用于存储数组长度,index 用于存储桶的索引位置
        Node<K,V> e;  // 用于遍历链表的临时节点指针

        // 【关键步骤 1:判断是否需要扩容】
        // 如果数组为空,或者数组长度 < 64 (MIN_TREEIFY_CAPACITY)
        // 即使链表长度达到了 8,我们也优先选择扩容,而不是转红黑树
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize(); // 调用扩容方法,扩容后可能会重新散列,长链表可能就变短了
        else {
            // 【关键步骤 2:定位到具体的桶】
            // 只有当数组长度 >= 64 时,才会执行这里的逻辑
            // 计算索引:(n - 1) & hash
            if ((e = tab[index = (n - 1) & hash]) != null) {
            
                // 【关键步骤 3:将普通 Node 链表转换为 TreeNode 双向链表】
                // hd: 红黑树的头节点 (Head)
                // tl: 红黑树的尾节点 (Tail),用于尾插法构建链表
                TreeNode<K,V> hd = null, tl = null;
            
                // 遍历该桶下的所有节点(原本是单向链表)
                do {
                    // 将普通的 Node 节点转换为 TreeNode 节点
                    // replacementTreeNode 方法会创建一个新 TreeNode,复制 key, value, hash 等信息
                    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);
            
                // 【关键步骤 4:真正构建红黑树】
                // 此时,该桶位置已经变成了一个 TreeNode 的双向链表
                // 接下来调用 hd.treeify(tab) 将这个双向链表整理成标准的红黑树结构(平衡二叉树)
                // 这一步会涉及复杂的旋转、变色操作,以保证查询效率为 O(log n)
                if ((tab[index] = hd) != null)
                    hd.treeify(tab);
              }
        }
    }

核心对比:JDK 1.7 vs JDK 1.8

特性 JDK 1.7 JDK 1.8
底层结构 数组 + 链表 数组 + 链表 + 红黑树
插入方式 头插法 (Head Insertion) 尾插法 (Tail Insertion)
扩容逻辑 重新计算 Hash,链表逆序 2倍扩容,链表顺序不变
Hash冲突 仅链表,冲突多时性能差 链表长度>8 且 数组≥64 时转为红黑树
线程安全 扩容可能死循环 (CPU 100%) 仍存在数据覆盖风险,但无死循环

重点解析

  • 为什么要引入红黑树?
    在 JDK 1.7 中,如果 Hash 冲突严重,链表会变得很长,查询效率退化为 O(n)。JDK 1.8 引入红黑树,当链表长度超过 8 时转换为红黑树,查询效率提升为 O(log n),大大优化了性能。
  • 为什么要从头插法改为尾插法?
    JDK 1.7 的头插法在多线程扩容时,容易导致链表形成环形结构(死循环) 。JDK 1.8 改为尾插法,保持了元素顺序,解决了死循环问题(但多线程下仍可能数据覆盖,建议并发场景使用 ConcurrentHashMap)。

扩容机制 (Resize)

扩容是 HashMap 中最"重"的操作。

  1. 触发条件: 当元素数量 > 容量 × 负载因子 时触发。
    • 默认初始容量:16
    • 默认负载因子:0.75(平衡空间与时间的经验值)
    • 默认阈值:16 * 0.75 = 12
  2. 扩容过程:
    • 创建新数组: 新容量是旧容量的 2倍
    • 数据迁移 (Rehash):
      • JDK 1.7: 重新计算每个元素的 Hash 值,插入新数组。
      • JDK 1.8(优化): 不需要重新计算 Hash。利用 2倍扩容的特性,元素在新数组中的位置只有两种可能:
        1. 留在原索引位置。
        2. 移动到 原索引 + 旧容量 的位置。
    • 判断依据: 通过 (e.hash & oldCap) 是否为 0 来快速判断。
java 复制代码
    /**
     * 初始化或调整哈希表的大小
     * 主要做两件事:
     * 1. 如果表为空,初始化容量。
     * 2. 如果表已满,将容量翻倍,并重新分配元素。
     */
    final Node<K,V>[] resize() {
        // 1. 备份旧表和旧参数
        Node<K,V>[] oldTab = table; // 指向旧的数组
        int oldCap = (oldTab == null) ? 0 : oldTab.length; // 旧容量
        int oldThr = threshold;     // 旧的扩容阈值

        int newCap, newThr = 0;     // 新容量和新阈值,初始为0

        // 2. 计算新容量和新阈值
        if (oldCap > 0) {
            // --- 情况 A:旧表已存在(即扩容场景) ---
            if (oldCap >= MAXIMUM_CAPACITY) {
                // 如果旧容量已经达到最大值 (2^30),不再扩容
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 如果新容量翻倍后未超限,且旧容量已达默认值(16)
            // 则新阈值也翻倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) {
            // --- 情况 B:旧表为空,但阈值 > 0 ---
            // 这通常发生在使用带参构造器时,例如 new HashMap<>(64)
            // 此时 table 为空,但 threshold 被设置为了初始容量
            newCap = oldThr;
        }
        else {
            // --- 情况 C:初次创建(无参构造器) ---
            // 旧表空,阈值也为0,使用默认值
            newCap = DEFAULT_INITIAL_CAPACITY; // 16
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
        }

        // 3. 计算/校准新阈值
        // 如果上面没有计算出 newThr (例如情况 B),这里进行计算
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                    (int)ft : Integer.MAX_VALUE);
        }

        // 更新全局阈值
        threshold = newThr;

        // 4. 创建新数组
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab; // 将全局 table 指向新数组

        // 5. 数据迁移(核心逻辑)
        if (oldTab != null) {
            // 遍历旧表的每一个桶
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                // 如果当前桶有元素
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null; // 帮助 GC 回收

                    // --- 情况 5.1:单个节点(无冲突) ---
                    if (e.next == null)
                        // 直接重新计算索引放入新表
                        // 注意:虽然不用重算 hash,但索引位置可能变化
                        newTab[e.hash & (newCap - 1)] = e;

                        // --- 情况 5.2:红黑树节点 ---
                    else if (e instanceof TreeNode)
                        // 调用红黑树的拆分方法
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);

                        // --- 情况 5.3:链表节点(有冲突) ---
                    else {
                        // 【JDK 1.8 的核心优化】
                        // 不需要重新计算 hash,而是利用 (e.hash & oldCap) 判断位置
                        // 将链表拆分为两条:
                        // lo (Low): 索引不变的部分
                        // hi (High): 索引变为 原索引+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
                            // 说明扩容后索引不变,留在原位置 j
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            // 如果 hash 值在 oldCap 这一位上是 1
                            // 说明扩容后索引变为 j + oldCap
                            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;
    }
  • 核心优化:扩容时,不需要重新计算 Hash 值。
  • 原理 :因为新容量是旧容量的 2 倍(例如 16 -> 32,二进制 10000 -> 100000)。
    • 元素在新数组的位置只有两种可能:
      1. 原位索引 i
      2. 迁移位索引 i + oldCap
  • 判断依据 :只需看 Hash 值的 oldCap 对应位是 0 还是 1。
    • (e.hash & oldCap) == 0 -> 留在原位。
    • (e.hash & oldCap) != 0 -> 移动到 i + oldCap

常见面试题与深度问答

Q1: 为什么 HashMap 的容量必须是 2 的幂次方?

答:

  1. 运算效率: 只有当容量是 2 的幂次方时,(n - 1) & hash 才能等价于 hash % n。位运算 & 的效率远高于取模运算 %
  2. 扩容优化: 2倍扩容时,高位只需增加一位,配合上述的位运算可以快速判断元素是留在原地还是移动到新位置,避免全量重算 Hash。

Q2: 为什么负载因子默认是 0.75?

答:

这是一个时间与空间的权衡:

  • 太大(如 1.0): 空间利用率高,但哈希冲突概率增加,链表/树变长,查询变慢。
  • 太小(如 0.5): 冲突少,查询快,但数组空间浪费严重,且频繁触发扩容,消耗性能。
  • 0.75: 根据泊松分布统计,这是冲突概率和空间成本的最佳平衡点。

Q3: 什么时候链表会转为红黑树?反之呢?

答:

  • 转红黑树: 链表长度 > 8 数组容量 ≥ 64
    • 注: 如果链表长度达到8,但数组容量小于 64时,HashMap 会优先选择扩容而不是转树 ,因为扩容能更有效地减少冲突。这背后是 HashMap 一个非常明确的"设计哲学":在数组容量较小时,优先通过扩容来"治本"地解决问题,而不是急于转换成更复杂的红黑树。
  • 转回链表: 当红黑树节点数减少到 6 以下时,会退化为链表。

Q4: HashMap 线程安全吗?如何解决?

答:
不安全。

  • JDK 1.7: 多线程扩容可能导致死循环(CPU 100%)。
  • JDK 1.8: 虽然解决了死循环,但多线程 put 仍可能导致数据覆盖。
  • 解决方案: 推荐使用 ConcurrentHashMap
    • JDK 1.7 使用分段锁(Segment)。
    • JDK 1.8 使用 CAS + synchronized 锁定链表/树的头节点,粒度更细,效率更高。

Q5: 为什么重写 equals 时必须重写 hashCode?

答:

HashMap 依赖 hashCode 定位桶位置。如果两个对象 equals 相等但 hashCode 不同,它们会被放在不同的桶里,导致无法通过 Key 找到对应的 Value,违反了 Map 的约定。

相关推荐
专注API从业者18 分钟前
Open Claw 京东商品监控选品实战:一键抓取、实时监控、高效选品
java·服务器·数据库
摇滚侠35 分钟前
DBeaver 导入数据库 导入 SQL 文件 MySQL 备份恢复
java·数据库·mysql
keep one's resolveY1 小时前
SpringBoot实现重试机制的四种方案
java·spring boot·后端
天空属于哈夫克32 小时前
企业微信API常见的错误和解决方案
java·数据库·企业微信
摇滚侠2 小时前
VMvare 虚拟机 Oracle19c 安装步骤,远程连接 Oracle19c,百度网盘安装包
java·oracle
梁萌2 小时前
idea报错找不到XX包的解决方法
java·intellij-idea·启动报错·缺少包
Agent产品评测局2 小时前
生产排期与MES/ERP系统打通,实操方法详解 —— 2026企业级智能体自动化选型与实战指南
java·运维·人工智能·ai·chatgpt·自动化
阿丰资源3 小时前
基于Spring Boot的电影城管理系统(直接运行)
java·spring boot·后端
呱牛do it3 小时前
企业级门户网站设计与实现:基于SpringBoot + Vue3的全栈解决方案(Day 8)
java
消失的旧时光-19434 小时前
Spring Boot 工程化进阶:统一返回 + 全局异常 + AOP 通用工具包
java·spring boot·后端·aop·自定义注解