当前位置: 首页 > news >正文

面试之HashMap

JDK1.7

public V put(K key, V value) {// 1. 如果table为空,则进行初始化创建(惰性加载)if (table == EMPTY_TABLE) {inflateTable(threshold);}// 2. 如果key为null,专门处理,放在table[0]的链表中if (key == null)return putForNullKey(value);// 3. 计算key的hash值int hash = hash(key);// 4. 根据hash值计算其在数组中的下标iint i = indexFor(hash, table.length);// 5. 【核心】遍历table[i]处的链表for (Entry<K,V> e = table[i]; e != null; e = e.next) {Object k;// 5.1 如果找到了已存在的key(哈希值相同且equals为true)if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {V oldValue = e.value;e.value = value; // 覆盖旧值e.recordAccess(this); // 空方法,用于LinkedHashMapreturn oldValue; // 返回旧值}}// 6. 如果未找到相同的key,执行添加操作modCount++;// 7. 将新Entry添加到链表头部,同时可能需要扩容addEntry(hash, key, value, i);return null;
}void addEntry(int hash, K key, V value, int bucketIndex) {// 1. 判断是否需要扩容:size达到阈值,并且目标桶的位置已经有元素(轻微优化)if ((size >= threshold) && (null != table[bucketIndex])) {resize(2 * table.length); // 扩容为原来的2倍// 扩容后重新计算hash和新的桶下标hash = (null != key) ? hash(key) : 0;bucketIndex = indexFor(hash, table.length);}// 2. 创建新Entry节点,并插入到链表的头部createEntry(hash, key, value, bucketIndex);
}void createEntry(int hash, K key, V value, int bucketIndex) {Entry<K,V> e = table[bucketIndex]; // 获取当前链表的头节点// 创建新节点,其next指向原来的头节点e -> 头插法!table[bucketIndex] = new Entry<>(hash, key, value, e);size++; // 大小增加
}

插入流程总结:

  1. 计算索引: 计算 key 的 hash 值,再通过 indexFor (即 hash & (table.length-1)) 得到桶下标 i

  2. 遍历链表: 遍历 table[i] 位置上的链表,检查 key 是否已存在。

  3. 覆盖或插入:

    • 存在: 覆盖 value,返回旧值。

    • 不存在: 调用 addEntry

  4. 判断扩容: 在 addEntry 中,先判断是否需要扩容。如果需要,先进行扩容并重新计算 key 的新位置。

  5. 头插法插入: 无论是否扩容,最后都会调用 createEntry,将新节点作为头节点插入到链表中(new Entry<>(..., next) 的 next 参数指向原头节点)。

扩容流程总结:

  1. 创建新数组: 容量为指定的 newCapacity(通常是旧容量的 2 倍)。

  2. 数据迁移(Transfer): 这是最核心的步骤。

    • 遍历旧数组的每一个桶(链表)。

    • 遍历桶中的每一个节点 e

    • 对每个节点 e重新计算其在新数组中的下标 i

    • 使用头插法,将当前节点 e 插入到新数组 [i] 位置链表的头部。

  3. 更新引用和阈值: 将 HashMap 内部的 table 引用指向新数组,并更新扩容阈值 threshold

头插法(Head-Insertion):

  • 优点: 实现简单,新插入的元素很可能被再次访问,放在头部可以更快被找到(借鉴了 LRU 思想)。

  • 致命缺点: 在并发扩容时,会导致链表形成环(死链)。一旦后续有查询操作定位到这个环状链表,就会陷入死循环,导致 CPU 100%。  假设线程 A 和 B 同时执行 transfer(将旧数组中的所有元素转移到新数组中),在操作同一个链表时,由于头插法会改变节点的顺序,两个线程交替执行,很容易使节点的 next 指针形成循环引用。

JDK1.8

添加元素 + 扩容 + 链表转红黑树

插入触发扩容,扩容可能触发树化或反树化,而树化的最终目的是为了解决因扩容或哈希冲突导致的链表过长问题,以提升查询效率。

1. 元素插入 (put)

当调用 map.put(key, value) 时,大致步骤如下:

  1. 计算哈希值: 对 key 的 hashCode() 进行二次哈希((h = key.hashCode()) ^ (h >>> 16)),目的是让高位也参与运算,减少哈希冲突。

  2. 计算桶索引: 使用 (n - 1) & hash 计算键值对应该放入的数组下标(n 是数组长度)。

  3. 处理桶内元素: 根据目标桶位置的情况,分为多种处理方式:

    • 桶为空: 直接创建一个新的 Node 对象放入该位置。

    • 桶不为空(哈希冲突):

      • 首节点匹配: 如果首节点的 key 与待插入 key 相同(hash 相等且 equals 为 true),则直接覆盖 value。

      • 首节点是 TreeNode: 说明该桶已经是红黑树结构,则调用红黑树的 putTreeVal 方法进行插入。

      • 首节点是普通 Node: 说明是链表结构。遍历链表:

        • 如果找到相同的 key,则覆盖 value

        • 如果到链表尾部都没找到,则将新节点插入链表尾部(JDK 1.7 是头插法,1.8 改为尾插法)。

        • 插入后,如果链表长度达到 8,则调用 treeifyBin(tab, hash) 方法尝试进行树化或者扩容

2. 树化 (treeifyBin)

final void treeifyBin(Node<K,V>[] tab, int hash) {int n, index; Node<K,V> e;// 关键判断:如果数组 table 为 null 或它的长度小于 MIN_TREEIFY_CAPACITY(默认64)if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)resize(); // 优先进行扩容!else if ((e = tab[index = (n - 1) & hash]) != null) {// 只有当数组长度 >= 64 且链表长度 >= 8 时,才真正进行树化TreeNode<K,V> hd = null, tl = null;do {// 将普通 Node 节点替换为 TreeNode 节点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); // 调用 TreeNode 的方法构建红黑树}
}

链表长度达到 8 时,并不一定会立刻转为红黑树,这是关键点。

树化的条件有两个,必须同时满足:

  1. 链表长度 >= 8

  2. 数组(桶)的长度 >= 64

设计理念:

  • 如果链表很长(冲突严重),但数组长度还很短(比如 16),这通常意味着扩容不充分。此时优先选择扩容resize)。因为扩容可以重新分散元素,可能直接让这个长链表分裂成两个短链表,问题就解决了,成本比转换为树结构要低。

  • 只有当数组容量已经很大(>=64),但哈希冲突依然非常严重,导致某个桶的链表长度超过 8,这时才认为需要转换为红黑树来保证极端的性能(将查找时间从 O(n) 优化到 O(log n))。

3. 扩容 (resize)

当 HashMap 中的元素数量超过阈值(threshold = capacity * loadFactor),或者遇到上面的未满足树化条件的情况,就会进行扩容。

  1. 创建新数组: 容量扩大为原来的 2 倍(保证容量永远是 2 的幂次)。

  2. 重新哈希(Rehash): 遍历旧数组的每一个桶,将每个节点重新计算索引位置,并移动到新数组中。

    • JDK 1.8 的优化: 由于容量是 2 的幂次,扩容后元素的新位置要么是原位置,要么是原位置 + 旧容量。通过 (e.hash & oldCap) == 0 这个判断可以高效地将一个链表拆分成两个链表。

  3. 扩容对树的影响:

    • 树拆分: 在扩容 resize() 过程中,原本是红黑树的桶会被拆分成两个链表。

    • 反树化(Untreeify): 拆分后,如果新链表的长度小于等于 6,则会调用 untreeify 方法,将 TreeNode 转换回普通的 Node,变回链表结构。这是因为 shorter list 的情况下,链表的性能已经足够好,无需维护更复杂的树结构。

关键参数记忆

  • 链表树化阈值: TREEIFY_THRESHOLD = 8

  • 树退化为链表阈值: UNTREEIFY_THRESHOLD = 6

  • 最小树化容量: MIN_TREEIFY_CAPACITY = 64

  • 默认负载因子: DEFAULT_LOAD_FACTOR = 0.75

补充说明, 扩容后元素的问题为什么是原位置或原位置+旧容量 呢?

原理:
元素的位置是通过 hash & (length - 1) 计算得到的。因为 length 是2的幂次方,所以 length - 1 的二进制就是一连串的 1(比如容量16:...0000 1111)。

扩容后,length 变成了原来的两倍newLength - 1 的二进制比 oldLength - 1 多了一个高位的 1(比如容量32:...0001 1111)。

hash & (newLength - 1) 的结果取决于 hash 对应这个新增位(我们称之为“扰动位”)的值是 0 还是 1

  • 如果扰动位是 0,那么 hash & (newLength - 1) = hash & (oldLength - 1),即新位置 = 原位置

  • 如果扰动位是 1,那么 hash & (newLength - 1) = hash & (oldLength - 1) + oldLength,即新位置 = 原位置 + 旧容量

这个过程避免了重新计算每个元素的 hashCode(),只需判断哈希值新增的参与运算的位是0还是1即可,效率非常高。

http://www.dtcms.com/a/354871.html

相关文章:

  • 面试tips--JVM(3)--类加载过程
  • 【赵渝强老师】MySQL数据库的多实例环境
  • 前端Sentry数据分析与可视化:构建智能化监控仪表板
  • 大数据毕业设计选题推荐-基于大数据的痴呆症预测数据可视化分析系统-Spark-Hadoop-Bigdata
  • 重置 Windows Server 2019 管理员账户密码
  • 基于SamOut的音频Token序列生成模型训练指南
  • 【Rust】 3. 语句与表达式笔记
  • Flask测试平台开发实战-第一篇
  • 安科瑞三相智能安全配电装置在养老院配电系统中的应用
  • Flask测试平台开发,登陆重构
  • F010 Vue+Flask豆瓣图书推荐大数据可视化平台系统源码
  • 新型Zip Slip漏洞允许攻击者在解压过程中操纵ZIP文件
  • 大模型训练推理优化(5): FlexLink —— NVLink 带宽无损提升27%
  • Android Glide插件化开发实战:模块化加载与自定义扩展
  • 使用MySQL计算斐波那契数列
  • 三轴云台之闭环反馈技术篇
  • Vue + ECharts 中 Prop 数据被修改导致图表合并的问题及解决方案
  • Vibe Coding到底是什么:什么是 Vibe Coding?AI编程?
  • SpringCloud OpenFeign 远程调用(RPC)
  • Web网络开发 -- 常见CSS属性
  • 前端RSA加密遇到Java后端解密失败的问题解决
  • 创建uniApp小程序项目vue3+ts+uniapp
  • 文档格式转换软件 一键Word转PDF
  • PDF转长图工具,一键多页转图片
  • 【Deepseek】Windows MFC/Win32 常用核心 API 汇总
  • Spring Boot对访问密钥加解密——HMAC-SHA256
  • Docker Swarm 与 Kubernetes (K8s) 全面对比教程
  • SMU算法与人工智能创新实践班SMU2025 Summer 7th 参考题解
  • 虚幻基础:角色变换角色视角蒙太奇运动
  • Python篇---返回类型