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)
-
性能极高 :在理想状态(哈希分布均匀)下,查找、插入和删除的时间复杂度均为 O(1)。
-
空间利用率高 :相比于
TreeMap,它不需要为每个节点维护树结构的额外引用(除非触发红黑树)。 -
支持 Null :提供了比某些不允许
null键的集合(如TreeMap或Hashtable)更大的灵活性。 -
工业级标准:经过了大量的优化(如 Java 8 的位运算扰动函数和红黑树引入),是处理海量键值数据的首选。
缺点 (Cons)
-
无序性 :如果你需要按插入顺序或按大小排序获取数据,
HashMap无法直接满足需求。 -
并发风险:在没有外部同步的情况下,多线程操作可能会导致程序崩溃或数据丢失。
-
哈希冲突开销 :如果 Key 的
hashCode设计得不好,导致大量数据堆积在同一个桶中,性能会从 O(1) 退化到 O(n) 或 O(\\log n)。 -
内存敏感:扩容时需要重新计算所有元素的哈希位置(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分析
扰动处理-》数组初始化-》寻址与定位-》处理冲突策略-》替换旧值-》扩容检查
- 扰动处理 (Hash Calculation)
直接调用 key.hashCode() 容易导致哈希分布不均。
-
做法 :将
hashCode的高 16 位与低 16 位进行异或 (^) 运算。 -
目的:混合高位和低位特征,减少后续在小容量数组上的哈希冲突。
- 数组初始化 (Initialization)
HashMap 采用延迟加载机制(Lazy Load)。
-
判断 :如果底层的
Node<K,V>[] table为null。 -
执行 :调用
resize()方法分配初始容量(默认为 16)。
- 寻址与定位 (Indexing)
通过位运算快速计算元素在数组中的"桶"位置。
-
公式:i = (n - 1) \& hash
-
原理:由于 n(数组长度)始终是 2 的幂,这个位运算等价于对 n 取模,但效率极高。
- 冲突处理策略 (Collision Handling)
这是 put 方法最复杂的环节,分为三种情况:
-
无冲突 :该位置为空,直接创建
Node放入。 -
完全相同 :如果桶中第一个节点的
hash和key与待插入的一致,则记录该节点准备后续更新value。 -
结构化冲突:
-
红黑树 :如果当前桶已经是
TreeNode,调用putTreeVal按照树逻辑插入或找回。 -
链表 :遍历链表。如果找到相同 key 则停止;否则在尾部插入新节点。
注意树化触发点 :插入后若链表长度 \\ge 8,会触发
treeifyBin方法。该方法内会再次检查:若数组长度 \< 64,只扩容不转树;若 \\ge 64,则正式转为红黑树。 -
- 替换旧值 (Value Overlay)
-
逻辑 :如果第 4 步中找到了匹配的 Key,且
onlyIfAbsent为false(默认),则用新value替换旧value。 -
返回值 :返回被替换的旧
value。
- 扩容检查 (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。
- 为什么查找时也要比较 hash?
在 getNode 中,先比较 e.hash == hash 再调用 equals()。
-
性能 :
int类型的比较效率极高。 -
短路原则 :如果
hash不同,那么两个对象一定不相等,可以直接跳过equals()。
- get 方法的时间复杂度
-
理想状态:O(1)(直接命中数组首位)。
-
最坏状态(链表):O(n)(所有元素都在一个桶的链表里)。
-
最坏状态(红黑树):O(\\log n)(链表转树后的性能保障)。
- 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),仅涉及引用遍历 |