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 键的集合(如 TreeMapHashtable)更大的灵活性。

  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>[] tablenull

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

  1. 寻址与定位 (Indexing)

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

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

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

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

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

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

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

  • 结构化冲突

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

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

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

  1. 替换旧值 (Value Overlay)
  • 逻辑 :如果第 4 步中找到了匹配的 Key,且 onlyIfAbsentfalse(默认),则用新 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 指针循环遍历。逐个比较 hashkey,直到找到匹配项或遍历到末尾。

返回结果 :找到则返回 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 允许 keynull

  • 如果 keynull,它的 hash 值固定为 0

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

put vs get
特性 put 方法 get 方法
主要任务 计算位置、处理冲突、插入/更新、扩容 计算位置、快速定位、搜索、返回
结构影响 可能触发链表转树、可能触发数组扩容 只读操作,不改变结构和 modCount
复杂度 O(1)O(n),涉及对象创建 O(1)O(n),仅涉及引用遍历
相关推荐
ejjdhdjdjdjdjjsl2 小时前
C#文件流操作技巧
java·开发语言·spring
lkbhua莱克瓦242 小时前
反射3-反射获取构造方法
java·开发语言·反射
wanghowie2 小时前
02.04.01 Java Stream API 进阶指南:从底层实现到性能优化
java·开发语言·性能优化
专注于大数据技术栈2 小时前
java学习--Date
java·学习
青莲8432 小时前
Java基础篇——第三部
java·前端
这周也會开心2 小时前
Map集合的比较
java·开发语言·jvm
while(1){yan}3 小时前
SpringIoc
java·spring boot·spring·java-ee
苏叶新城3 小时前
SpringBoot 3.5 JPA投影
java·spring boot·后端