HashMap详解

HashMap详解

HashMap是什么?

HashMap 是 Java 程序员最常用的集合类,它存储的是 键值对(Key-Value Pair) 。其核心优势在于能够根据 键(Key) 极快地找到对应的 值(Value)。

为什么会有HashMap?
需求维度 HashMap 的解决方案
速度 通过 Hash 算法实现常数级时间查找。
映射 完美支持键值对(Key-Value)逻辑。
去重 强制 Key 唯一,自动处理重复输入。
规模 自动扩容,适应从小规模到海量数据的变化。
hashmap 实现类的特点
  • 数据结构 (Java 8+) :由 数组 + 链表 + 红黑树 共同组成。当链表长度超过 8 且数组长度达到 64 时,链表会进化为红黑树以优化查询效率。

  • 无序性:它不保证键值对的存储顺序,存入和取出的顺序可能完全不同,且顺序可能会随着扩容而发生改变。

  • 键值规则:

    • 键 (Key) 唯一 :通过 hashCode() 和 equals() 确保 Key 的唯一性。

    • 允许 Null :允许存储一个为 null 的键和多个为 null 的值。

  • 线程安全性 :它是 非线程安全 的。在多线程并发修改时,可能会导致数据不一致。

  • 扩容机制:默认初始容量为 16,加载因子为 0.75。当元素个数超过阈值(容量 × 加载因子)时,会触发 2 倍扩容。

容量为什么是2^n

性能优化 :如果 n 是 2 的幂,那么 (n-1) 的二进制全为 1(例如 16-1=15,二进制 1111)。位运算 & 的效率远高于取模运算 %。

分布均匀 :当 (n-1) 为全 1 时,进行 & 运算能最大程度保留 hash 值的位特征。如果 n 不是 2 的幂,(n-1) 某些位为 0,会导致数组中的某些位置永远无法被随机存取,造成严重的空间浪费和冲突。

为什么加载因子默认是 0.75?

这是一个在空间和时间成本之间的折中(Trade-off):

  • 如果设为 1:空间利用率高,但哈希冲突会非常严重,查询效率下降。

  • 如果设为 0.5:查询效率极高,但数组扩容频繁,空间浪费严重。

  • 数学依据 :根据泊松分布(Poisson Distribution),在加载因子为 0.75 时,同一个桶中元素达到 8 个的概率极其微小(约千万分之六),这保证了红黑树不会被频繁触发,但在极端情况下又能兜底。

Resize 的"高低位迁移"逻辑 (JDK 1.8)

在 JDK 1.8 扩容时,不需要重新计算每个元素的 hash。这是一个非常聪明的优化:

  • 原理 :由于容量翻倍,元素的新索引要么在原位置 ,要么在原位置 + 旧容量。

  • 判断方式 :通过 (e.hash & oldCap) == 0 来判断。

    • 结果为 0:留在低位区(原索引)。

    • 结果不为 0:移动到高位区(原索引 + 旧容量)。

  • 优点:避免了重新计算 hash 的 CPU 消耗,且迁移后的元素在新的桶中依然保持均匀分布。

优缺点:

优点 (Pros)

  1. 性能极高 :在理想状态(哈希分布均匀)下,查找、插入和删除的时间复杂度均为 O(1)。

  2. 空间利用率高 :相比于 TreeMap,它不需要为每个节点维护树结构的额外引用(除非触发红黑树)。

  3. 支持 Null :提供了比某些不允许 null 键的集合(如 TreeMap 或 Hashtable)更大的灵活性。

  4. 工业级标准:经过了大量的优化(如 Java 8 的位运算扰动函数和红黑树引入),是处理海量键值数据的首选。


缺点 (Cons)

  1. 无序性 :如果你需要按插入顺序或按大小排序获取数据,HashMap 无法直接满足需求。

  2. 并发风险:在没有外部同步的情况下,多线程操作可能会导致程序崩溃或数据丢失。

  3. 哈希冲突开销 :如果 Key 的 hashCode 设计得不好,导致大量数据堆积在同一个桶中,性能会从 O(1) 退化到 O(n) 或 O(\\log n)。

  4. 内存敏感:扩容时需要重新计算所有元素的哈希位置(Rehash),在大规模数据量下,扩容瞬间会产生明显的性能抖动。

HashMap JDK1.7 和1.8之间的区别
维度 JDK 1.7 JDK 1.8
底层结构 数组 + 链表 数组 + 链表 + 红黑树
数据插入方式 头插法 尾插法
扩容迁移逻辑 迁移时会翻转链表顺序 迁移时保持原有顺序(高低位迁移)
Hash 冲突性能 冲突严重时变为 O(n) 冲突严重时变为 O(\log n)(转红黑树后)
初始扩容 创建对象时即分配空间 第一次 put 时才分配空间(懒加载)
头插法 vs 尾插法

在 HashMap 扩容进行数据迁移(Rehash)时,插入新数组的方式不同:

头插法 (JDK 1.7)

  • 做法:新节点总是插入到链表的头部。

  • 初衷:设计者认为"后插入的数据被访问的概率更高"。

  • 弊端 :在多线程并发扩容时,头插法可能会导致链表形成环形死循环,造成 CPU 100%。

尾插法 (JDK 1.8)

  • 做法:新节点总是插入到链表的尾部。

  • 优势:扩容时保持了链表原本的先后顺序。这种稳定性避免了多线程扩容时产生死循环的问题。

  • 注意 :虽然解决了死循环,但 HashMap 依然不是线程安全的(可能存在数据覆盖)。

头插法和尾插法区别

维度 头插法 (Head) 尾插法 (Tail)
执行效率 略快(无需遍历) 略慢(需找到末尾,但在 1.8 中已有遍历逻辑)
数据顺序 扩容后顺序会倒置 扩容后顺序保持不变
并发安全性 高并发下可能产生死循环 解决了死循环问题,但仍非线程安全
红黑树支持 不兼容(无法准确计数) 完美契合树化逻辑

put get方法的源码实现

关键参数
参数 默认值 作用
DEFAULT_INITIAL_CAPACITY 16 初始数组长度
DEFAULT_LOAD_FACTOR 0.75 空间利用率与查询效率的平衡点
TREEIFY_THRESHOLD 8 链表转树的长度阈值
MIN_TREEIFY_CAPACITY 64 允许转树的最小数组总容量
put分析

扰动处理-》数组初始化-》寻址与定位-》处理冲突策略-》替换旧值-》扩容检查

  1. 扰动处理 (Hash Calculation)

直接调用 key.hashCode() 容易导致哈希分布不均。

  • 做法 :将 hashCode 的高 16 位与低 16 位进行异或 (^) 运算。

  • 目的:混合高位和低位特征,减少后续在小容量数组上的哈希冲突。

  1. 数组初始化 (Initialization)

HashMap 采用延迟加载机制(Lazy Load)。

  • 判断 :如果底层的 Node<K,V>[] table 为 null。

  • 执行 :调用 resize() 方法分配初始容量(默认为 16)。

  1. 寻址与定位 (Indexing)

通过位运算快速计算元素在数组中的"桶"位置。

  • 公式:i = (n - 1) \& hash

  • 原理:由于 n(数组长度)始终是 2 的幂,这个位运算等价于对 n 取模,但效率极高。

  1. 冲突处理策略 (Collision Handling)

这是 put 方法最复杂的环节,分为三种情况:

  • 无冲突 :该位置为空,直接创建 Node 放入。

  • 完全相同 :如果桶中第一个节点的 hash 和 key 与待插入的一致,则记录该节点准备后续更新 value。

  • 结构化冲突:

    • 红黑树 :如果当前桶已经是 TreeNode,调用 putTreeVal 按照树逻辑插入或找回。

    • 链表 :遍历链表。如果找到相同 key 则停止;否则在尾部插入新节点。

    注意树化触发点 :插入后若链表长度 \\ge 8,会触发 treeifyBin 方法。该方法内会再次检查:若数组长度 \< 64,只扩容不转树;若 \\ge 64,则正式转为红黑树。

  1. 替换旧值 (Value Overlay)
  • 逻辑 :如果第 4 步中找到了匹配的 Key,且 onlyIfAbsent 为 false(默认),则用新 value 替换旧 value。

  • 返回值 :返回被替换的旧 value。

  1. 扩容检查 (Resizing)
  • 计算 :插入成功后,size 增加。

  • 触发 :如果 size > threshold(当前容量 \\times 0.75),则启动 resize()。

  • 过程:创建一个 2 倍容量的新数组,并重新映射(Rehash)所有旧节点。

get分析

相对于 put 方法的复杂逻辑,get 方法的流程更为直接。它的核心目标是:根据 Key 快速定位到具体的"桶(Bucket)",然后在桶内搜索对应的 Value。

流程:计算哈希值-》检查数组有效性-》检查首个节点-》桶内搜索-》返回结果

计算哈希值 :调用 hash(key)。与 put 一样,先进行高低位异或处理,确保分布均匀。

检查数组有效性 :判断底层 table 是否为空,且其长度是否大于 0。同时计算索引 i = (n - 1) \\text{ \& } \\text{hash},确认该位置是否有元素。

  • 如果数组为空或该位置没有节点,直接返回 null。

检查首个节点:

  • 比较第一个节点的 hash 值和 key(使用 == 或 equals())。

  • 如果匹配成功,直接返回该节点。

桶内搜索(存在冲突时):

  • 红黑树查找 :如果节点是 TreeNode 类型,调用红黑树的查找方法 getTreeNode,其时间复杂度为 O(\\log n)。

  • 链表查找 :如果是普通的链表,则通过 next 指针循环遍历。逐个比较 hash 和 key,直到找到匹配项或遍历到末尾。

返回结果 :找到则返回 node.value,未找到则返回 null。

  1. 为什么查找时也要比较 hash?

在 getNode 中,先比较 e.hash == hash 再调用 equals()。

  • 性能 :int 类型的比较效率极高。

  • 短路原则 :如果 hash 不同,那么两个对象一定不相等,可以直接跳过 equals()。

  1. get 方法的时间复杂度
  • 理想状态:O(1)(直接命中数组首位)。

  • 最坏状态(链表):O(n)(所有元素都在一个桶的链表里)。

  • 最坏状态(红黑树):O(\\log n)(链表转树后的性能保障)。

  1. null 的处理

HashMap 允许 key 为 null。

  • 如果 key 是 null,它的 hash 值固定为 0。

  • 因此,null 键始终存储在数组的第 0 个桶(index = 0)中。

put vs get
特性 put 方法 get 方法
主要任务 计算位置、处理冲突、插入/更新、扩容 计算位置、快速定位、搜索、返回
结构影响 可能触发链表转树、可能触发数组扩容 只读操作,不改变结构和 modCount
复杂度 O(1) 到 O(n),涉及对象创建 O(1) 到 O(n),仅涉及引用遍历
相关推荐
xcl09255 小时前
北京24小时自助健身房系统软件开发实战:从架构到部署全流程指南
java·spring boot·架构
一 乐7 小时前
动漫书销售商城|基于springboot + vue动漫书销售商城(源码+数据库+文档)
java·数据库·vue.js·spring boot·毕业设计
卓怡学长7 小时前
w214基于jsp知道特产网
java·intellij-idea
步行cgn7 小时前
Spring 注解使用详解
java·spring
一条小小yu7 小时前
Spring IoC的理解
java·后端·spring
落魄实习生8 小时前
Agent Scope Java 2.x 系列【7】工具使用
java·开发语言·ai
旺仔学长 哈哈8 小时前
springboot钓鱼爱好者交流平台APP设计与实现
java·spring boot·mysql·充电桩管理系统
自强的小白9 小时前
核心功能(Service接口)
java·mybatis
乌暮10 小时前
深入理解 Java 泛型:把「万能盒子」用对、用稳
java·开发语言·后端·学习