HashMap面试知识点

前言

本文为自用复习笔记,核心用于梳理Java中HashMap的核心知识点,方便后续回顾、巩固重点,避免遗忘关键细节。

本次笔记将围绕HashMap的核心数据结构、put方法执行流程、哈希冲突、扩容机制、树化与反树化,以及线程不安全原因等核心知识点展开。
PS:

最近想跳槽回家了,但是现在这个工作用的时python+django,不是用Java,现在好多Java的基础概念都忘记了,下份工作还是想找Java全栈,偏向架构一点,但是Java基础的知识点还是要重新过一遍才好。

此文章根据自己整理与ai生成部分内容相结合。

一、HashMap 核心数据结构

1.1 整体结构:数组 + 链表 + 红黑树

HashMap由数组+链表/红黑树组成

保证了存储的高效性,又解决了哈希冲突带来的查询效率下降问题

1.1.1 数组(哈希桶):核心存储载体

数组在 HashMap 中也被称为"哈希桶",是整个存储结构的基础,类型为 Node[] table

Node 是 HashMap 中存储键值对的核心节点。

在源码中每个Node包含 hash、key、value、next 四个属性

桶的扩容与特点

  1. 数组长度默认是 16(初始容量),且始终保持为 2 的幂(后续扩容会翻倍),这是为了配合哈希值计算桶下标,提升查询效率;
  2. 数组的每个下标对应一个"桶",每个桶中可存放一个 Node 节点(无冲突时),或一串 Node 节点(冲突时,以链表/红黑树形式存在);
  3. 数组的作用是快速定位键值对的存储位置,通过哈希计算得到下标后,可直接访问对应桶,时间复杂度接近 O(1)。

1.1.2 链表:解决哈希冲突的基础方式

当不同的 Key 经过哈希计算后(后面会说到如何计算hash值),得到的桶下标(数组下标)相同时,就会产生哈希冲突,此时这些冲突的 Node 节点会以单向链表的形式,挂在同一个桶的末尾,这就是链表在 HashMap 中的核心作用------解决哈希冲突。

核心特点:

  1. 链表采用单向链表结构,每个 Node 节点通过 next 属性指向后续节点,便于新增节点(直接挂载在末尾);
  2. 链表的查询效率为 O(n),即查询时需要从链表头开始,逐个遍历节点,直到找到目标 Key;
  3. 当冲突节点较少时(链表长度小于8),链表的查询、插入效率足够,且占用空间少,适合处理少量冲突场景。

1.1.3 红黑树:优化长链表查询效率

当同一个桶中的链表长度过长时(默认阈值为 8),链表的 O(n) 查询效率会严重影响 HashMap 的整体性能,此时 JDK 1.8 会将该链表转换为红黑树,以此优化查询效率。

核心特点:

  1. 红黑树是一种平衡二叉树,遵循"红黑规则"(根节点为黑、叶子节点为黑、红节点的子节点为黑等),保证树的高度始终接近 log2(n),查询时间复杂度降至 O(logn),远优于链表的 O(n);
  2. 红黑树的节点类型为 TreeNode(继承自 Node 节点),新增了左右子节点、父节点等属性,用于维护树的平衡;
  3. 红黑树仅在链表长度≥8 且数组长度≥64 时才会触发转换(避免数组过小时,树化反而浪费空间,当数组没有太长时会扩容)。

1.2 JDK 1.8 与 1.7 数据结构差异

1.7无红黑树、使用头插法;1.8有红黑树、使用尾插法

二、HashMap put 方法执行流程(核心重点)

2.1 核心流程拆解

源码:

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

    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;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

2.1.1 步骤1:计算Key的哈希值(扰动函数解析)

废话少说,上源码:

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

从源码中可以看出来,如果key为null时,桶的下标就直接为0
hashCode值简介:

  • 整数类型(byte、short、int、char):直接返回该值本身。例如,int类型的hashCode就是其数值。
  • 长整型(long):返回(int)(value ^ (value >>> 32)),即通过将高32位和低32位异或运算得到
  • 浮点型(float):返回Float.floatToIntBits(value),即将浮点数的二进制表示转换为整数后计算hashCode
  • 双精度浮点型(double):先通过Double.doubleToLongBits(value)转换为long,再按照long类型的规则计算hashCode
  • 布尔型(boolean):true返回1231,false返回1237
  • 如果对象**未重写hashCode()**方法,则默认使用Object类的实现,返回对象的内存地址(通过JVM内部算法转换)
  • 如果重写了hashCode()方法,就依据重写的实现计算。

右移计算有点抽象,上图解释:

^异或计算是根据二进制计算的:

来个例子:

复制代码
      A:  0 1 0 1 1 1 0 0
      B:  0 0 1 1 0 1 0 1
----------------------- (异或运算)
    结果:  0 1 1 0 0 0 0 1

如此,hash值就计算出来了

源码中,让最终的hash值既有低16位的特征,也有高16位的特征,分布更均匀,从而大幅减少哈希冲突。

2.1.2 步骤2:通过哈希值定位桶下标((n-1) & hash 原理)

HashMap采用(n-1) & hash 的方式计算下标,而非传统的取模运算(hash % n),核心原因是效率更高

一、核心计算公式与源码关联

下标计算公式:index = (n - 1) & hash(n 为HashMap数组当前长度,且始终是2的幂;hash为步骤1中通过扰动函数计算出的最终hash值)

结合put方法源码片段(简化版,聚焦下标计算):

java 复制代码
public V put(K key, V value) {
    int hash = hash(key); // 步骤1:计算hash值(扰动函数处理)
    int n = table.length; // 获取当前数组长度n(2的幂)
    int index = (n - 1) & hash; // 步骤2:计算桶下标
    // 后续步骤:定位桶、处理插入逻辑...
}

此时当n为2的次幂时,n-1的二进制才全为1,这时使用 (n - 1) & hash计算才能使下标计算的更均匀,从而降低hash冲突的机率

二、面试易错点

  1. 下标计算的核心依赖两个前提:n是2的幂 + 经过扰动函数处理的hash值,两者缺一不可,缺少任何一个都会导致下标分布不均,增加哈希冲突;

  2. 举例辅助记忆(便于复习):当n=16(n-1=15,二进制 0000 1111),hash值为 0001 0101(十进制21),则 (15 & 21) = 0000 0101(十进制5),下标为5;若hash值为 0010 0101(十进制37),则 (15 & 37) = 0000 0101(十进制5),此时产生哈希冲突,后续会通过链表/红黑树处理(衔接步骤3)。

2.1.3 步骤3:判断桶状态,处理插入逻辑

  • 桶为空的时候,新建节点插入
  • 桶不为空的时候,判断节点类型
    • key重复的时候,通过"hash值相等 + Key相等"的方式,判断当前插入的Key是否已存在(这是避免数据覆盖的关键)

      java 复制代码
      Node<K,V> p = table[index]; // 获取桶中第一个节点
      // 判断当前Key是否与第一个节点的Key重复
      if (p.hash == hash && (p.key == key || (key != null && key.equals(p.key)))) {
          // Key重复,记录该节点,后续执行Value替换(对应步骤4)
          e = p;
      }
    • 若Key不重复,根据节点类型处理插入

2.1.4 步骤4:Key重复判断与Value替换

  • 如果判断出当前插入的Key已存在:"hash值相等+equals相等"
  • 则不会新增节点,而是执行Value替换操作

2.1.5 步骤5:扩容检查(触发条件判断)

  • 负载因子(loadFactor)默认值为0.75
  • 因此初始扩容阈值为 16 * 0.75 = 12;当元素个数超过12时,数组长度会翻倍至32
  • 扩容会调用resize()方法,(该方法在第四章详细解释)

2.2 put 流程简化示例(辅助理解)

为了更直观地理解上述put方法的完整流程,这里补充一段简化示例:

假设我们向一个初始容量为16、负载因子0.75的HashMap中,依次put("name", "Java")和put("age", 25),

具体流程如下:

  1. 调用hash方法计算"name"的hash值(扰动函数处理后),通过(n-1)&hash计算出桶下标,假设为3;
  2. 判断下标3的桶为空,直接新建Node节点插入,size变为1,未超过阈值12,不扩容;
  3. 继续put("age", 25),计算其hash值和桶下标,假设为5;
  4. 桶5为空,新建节点插入,size变为2,仍未超过阈值,put流程结束。若后续put的Key计算出的下标与已有节点冲突(如下标3),则判断Key是否重复,不重复则插入链表,若链表长度达标则转红黑树,插入后检查是否需要扩容。

通过这个简化示例,可快速串联put流程的5个核心步骤,联动记忆哈希值计算、下标定位、冲突处理、Value替换和扩容检查的逻辑,为后续学习哈希冲突、扩容等知识点打下基础。

三、哈希冲突(Hash Collision)详解

3.1 哈希冲突的定义

哈希冲突就是"不同的Key,最终找到同一个存储位置

3.2 哈希冲突的产生原因

3.2.1 哈希函数的压缩性(必然存在冲突)

  • 哈希函数的核心特性之一是"压缩性"
  • 哈希函数(包括扰动函数)的作用,就是将范围极大的hashCode"压缩"到数组的下标范围内
  • 有限的存储位置,无法承载无限的Key映射

哈希函数的压缩性 → 有限桶映射海量Key → 冲突必然存在

3.2.2 Key哈希值分布不均(人为/逻辑问题)

Key自身哈希值分布不均,也是加剧哈希冲突的重要原因

可通过合理设计Key的hashCode方法

  1. 自定义对象作为HashMap的Key时,必须重写hashCode()和equals()方法,且hashCode()方法要保证"相同对象hash值一定相同,不同对象hash值尽量不同",避免因hash值分布不均加剧冲突;
  2. 举例辅助记忆:若自定义User对象,仅用"age"字段计算hashCode(age取值范围0-100),则大量不同的User对象会因age相同,导致hash值相同,即便经过扰动函数处理,也会集中在少数桶中,引发严重冲突;
  3. 联动记忆:这也是前文扰动函数的核心作用之一------通过混合高16位和低16位,尽量弥补Key原始hashCode分布不均的问题,减少冲突。

3.3 HashMap 解决哈希冲突的方式

3.3.1 链地址法(链表存储冲突节点)

简单说就是像链表末尾中加入数据

  • 当不同 Key 映射到同一个桶下标(产生哈希冲突),且 Key 不重复时,HashMap 会将新节点挂载到该桶已有链表的末尾(JDK 1.8 尾插法),形成单向链表
  • 每个 Node 节点通过 next 属性关联后续冲突节点,新增节点时只需找到链表末尾挂载,操作高效(时间复杂度 O(1))
  • 链地址法是 HashMap 解决哈希冲突的"基础方案",红黑树是"优化方案",只有当链表长度过长时,才会升级为红黑树;

3.3.2 红黑树优化(长链表转树的意义)

链地址法虽能解决哈希冲突,但存在明显短板------当哈希冲突严重,同一个桶中的链表长度过长时(默认阈值为8),链表O(n)的查询效率会大幅下降

  • 当红黑树优化触发时(链表长度≥8且数组长度≥64),HashMap会调用treeifyBin方法,将当前桶中的单向链表转换为红黑树

    java 复制代码
    // 链地址法插入新节点后,检查链表长度是否达标
    if (binCount >= TREEIFY_THRESHOLD - 1) {
        treeifyBin(table, hash); // 链表转红黑树(树化)
    }
    
    // treeifyBin方法核心逻辑(简化版)
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        // 数组长度不足64时,不树化,而是触发扩容(避免树化浪费空间)
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) {
            resize();
        } else if ((e = tab[index = (n - 1) & hash]) != null) {
            // 链表转红黑树,创建红黑树节点,构建红黑树结构
            TreeNode<K,V> hd = null, tl = null;
            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);
            }
        }
    }
  • 优化核心:红黑树是平衡二叉树,遵循"红黑规则"(根黑、叶黑、红节点子节点黑等),能保证树的高度始终接近log2(n),查询时只需遍历log2(n)个节点,效率远高于链表的O(n);

  • 节点差异:红黑树节点为TreeNode,继承自Node节点,新增左右子节点(left、right)、父节点(parent)、颜色标记(red)等属性,用于维护树的平衡;

ps:记牢两个触发条件和O(logn)的查询效率即可

3.4 如何减少哈希冲突

  • 合理设计Key的hashCode()和equals()方法
  • 合理设置初始容量和负载因子
    • 若提前知道存储的键值对数量,可手动设置合适的初始容量(需为2的幂),避免频繁扩容(扩容会消耗性能)
    • 例如,若预计存储100个键值对,可设置初始容量为128(128*0.75=90,接近100,减少扩容次数)
    • 负载因子:如追求极致查询效率、内存充足,可适当降低负载因子(如0.5)

面试提醒

  1. 减少哈希冲突的核心是"让hash值分布均匀",所有技巧都围绕这一核心展开;
  2. 自定义Key重写hashCode和equals是高频考点,务必掌握"一致性原则";
  3. 初始容量和负载因子的设置的是性能优化点,无需死记硬背,理解"平衡空间与冲突"的逻辑即可;
  4. 设置初始容量可减少扩容,优化hashCode可减少冲突,形成完整的性能优化逻辑链。

四、HashMap 扩容机制(resize 方法)

4.1 扩容的触发条件

4.1.1 负载因子(默认0.75)的作用

  • 负载因子越大,扩容阈值越高,数组的空间利用率越高,但哈希冲突的概率也会随之上升;
  • 负载因子越小,扩容阈值越低,哈希冲突越少,但数组空间浪费越严重,频繁扩容也会消耗更多性能。

4.1.2 扩容阈值(threshold)的计算逻辑

  • 因此初始扩容阈值为 16 * 0.75 = 12;当HashMap中的元素个数(size)超过12时,就会触发第一次扩容
  • 每次扩容时,数组长度会翻倍(n变为2n,始终保持2的幂)。
  • 第一次扩容后数组长度变为32,新的扩容阈值为 32 * 0.75 = 24,以此类推

4.2 扩容的核心流程

4.2.1 数组长度翻倍(保证为2的幂)

  • 核心操作:数组长度翻倍(n → 2n)

  • 例如原数组长度为16,扩容后变为32;原长度为32,扩容后变为64,以此类推。

    java 复制代码
    // 扩容核心逻辑:创建新数组,长度为原数组的2倍
    int newCap = oldCap << 1; // 左移1位,等价于oldCap * 2,保证是2的幂
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab; // 将新数组赋值给原数组引用,完成数组扩容

为什么必须翻倍且保持2的幂?

  • 前文我们重点记忆了下标计算公式 (n-1) & hash,该公式能高效计算下标、保证分布均匀的核心前提是"n为2的幂"
  • 简化扩容计算:左移1位(oldCap << 1)的方式实现翻倍,属于位运算,执行效率远高于乘法运算(oldCap × 2),这是HashMap的性能优化点之一
  • 衔接阈值更新:数组长度翻倍后,扩容阈值(threshold)也会同步更新为新数组长度 × 负载因子(如原n=16、threshold=12,扩容后n=32、threshold=24)

4.2.2 节点重新哈希与迁移(JDK 1.8 优化点)

JDK 1.8 对节点迁移逻辑做了大幅优化,摒弃了 JDK 1.7 中复杂的重新计算下标方式,采用更高效的"位运算判断"逻辑,减少计算量,提升扩容效率

一、核心迁移逻辑(JDK 1.8 优化重点)

  • JDK 1.8 发现:原节点的hash值与"旧n"做与运算(hash & 旧n),结果只有0和旧n两种情况,这两种情况直接决定了节点在新数组中的新下标,无需重新计算完整的下标,大幅提升效率。

  • 举例:原数组长度旧n=16(二进制 10000),某节点原下标为5(hash & 15 = 5);若 hash & 16 == 0,新下标仍为5;若 hash & 16 != 0,新下标 = 5 + 16 = 21,完美适配新数组长度32的下标范围(0-31)

二、源码解析(简化版,聚焦迁移核心)

java 复制代码
// 遍历原数组的每个桶,迁移节点
for (int j = 0; j < oldCap; ++j) {
    Node<K,V> e;
    if ((e = oldTab[j]) != null) { // 当前桶有节点,需要迁移
        oldTab[j] = null; // 清空原桶,避免内存泄漏
        // 判断节点类型,分链表/红黑树分别迁移
        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 { // 链表节点,按优化后的逻辑迁移(分成两个链表)
            // 作用为保存两个链表一个是头,一个是尾,保证链表相连接
            Node<K,V> loHead = null, loTail = null; // 新下标=原下标的链表
            Node<K,V> hiHead = null, hiTail = null; // 新下标=原下标+旧n的链表
            Node<K,V> next;
            do {
                next = e.next;
                if ((e.hash & oldCap) == 0) { // 分组1:新下标不变
                    if (loTail == null) loHead = e;
                    else loTail.next = e;
                    loTail = e;
                } else { // 分组2:新下标=原下标+旧n
                    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;
            }
        }
    }
}

三、核心特点解析

  1. 效率优化:JDK 1.8 无需对每个节点重新计算 (n-1) & hash,仅通过 hash & 旧n 判断分组,减少计算量,且将链表拆分为两个小链表,迁移后直接挂载到新桶,避免了 JDK 1.7 中链表反转导致的死循环问题;

  2. 节点类型适配:迁移时会区分链表节点和红黑树节点,红黑树节点会调用split方法,按红黑树逻辑拆分、迁移,迁移后若红黑树节点数量减少,会触发反树化(后续第五章详细讲解);

  3. 内存安全:迁移时会将原数组的桶置为null(oldTab[j] = null),避免原节点引用导致的内存泄漏,这是容易忽略的细节,记牢即可。

四、总结

  • 迁移核心优化:JDK 1.8 用"hash & 旧n"分组,替代重新计算下标,效率提升,同时避免死循环,这是与 JDK 1.7 迁移逻辑的核心差异;
  • "分组迁移、无需重新计算下标、避免死循环"这三个为核心优化点

4.3 扩容的性能影响(注意事项)

一、扩容的核心性能开销(重点记忆)

  1. 数组创建开销:扩容时需要创建一个新的数组(长度为原数组的2倍),若原数组容量较大(如1024、2048),新数组的创建会占用一定的内存空间,同时初始化数组也会消耗少量CPU资源,这是扩容的基础开销。

  2. 节点迁移开销:这是扩容中最耗时的性能开销------原数组中的所有节点(链表节点、红黑树节点)都需要重新分组、迁移到新数组,遍历原数组、拆分链表/红黑树、挂载节点等操作,时间复杂度接近O(n)(n为原数组中节点总数),节点数量越多,迁移耗时越长。

  3. 红黑树拆分/反树化开销:若原桶中存在红黑树节点,迁移时会调用split方法拆分红黑树,拆分后若红黑树节点数量少于反树化阈值(默认6),还会触发反树化(红黑树转链表),这部分操作比普通链表迁移更复杂,会额外消耗性能。

HashMap扩容对性能的影响问题核心:

  • "扩容的主要开销(节点迁移)""避免频繁扩容的方法(合理设置初始容量)"

未完待续...

相关推荐
发现一只大呆瓜2 小时前
Vue2:数组/对象操作避坑大全
前端·vue.js·面试
发现一只大呆瓜2 小时前
Vue3:ref 与 reactive 超全对比
前端·vue.js·面试
诺浅2 小时前
聊聊@DSTransactional的坑
java·多数据源·dstransavtional
菜鸟‍2 小时前
【后端项目】苍穹外卖day01-开发环境搭建
java·开发语言·spring boot
lzksword2 小时前
C++ Builder XE OpenDialog1打开多文件并显示xls与xlsx二种格式文件
java·前端·c++
青槿吖2 小时前
【保姆级教程】Spring事务控制通关指南:XML+注解双版本,避坑指南全奉上
xml·java·开发语言·数据库·sql·spring·mybatis
victory04312 小时前
Agent 面试知识树 + 高频追问答案库
网络·面试·职场和发展
mygljx3 小时前
spring-ai 下载不了依赖spring-ai-openai-spring-boot-starter
java·人工智能·spring
jaysee-sjc3 小时前
【练习十二】Java实现年会红包雨小游戏
java·开发语言·算法·游戏·intellij-idea