feat: 新增子分类功能,美化profile

This commit is contained in:
2025-07-19 15:09:40 +08:00
parent 2b2666dd13
commit 31741bba41
22 changed files with 2404 additions and 1778 deletions

View File

@@ -0,0 +1,610 @@
---
title: HashMap原理
published: 2025-07-18
description: ''
image: ''
tags: [HashMap,Java]
category: 'Java > 集合框架'
draft: false
lang: ''
---
# 说说HashMap的原理
HashMap是基于哈希表的数据结构用于存储键值对。
核心是将键的哈希值映射到数组索引位置,通过数组+链表+红黑树来解决哈希冲突。
HashMap使用键的hashCode()方法计算哈希值,通过`(n-1) &hash`确定元素在数组中的存储位置。
哈希值是经过一定的扰动处理的,防止哈希值分布不均,从而减少冲突,
HashMap的默认初始容量为16负载因子为0.75也就是说当存储的元素数量超过16 * 0.75 = 12个的时候HashMap会触发扩容操作容量x2并重新分配元素位置这种扩容是比较耗时的操作频繁扩容会影响性能。
# 通过源码深入了解HashMap
```java
// 默认初始容量 - 必须是 2 的幂次方。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 即 16
// 最大容量,如果构造函数中通过参数隐式指定了更高的值,则使用此最大容量。
// 必须是小于等于 1 << 30 的 2 的幂次方。
// 由于你可以随时指定非常大甚至超过了1亿的值为了防止内存溢出或数组长度无效HashMap内部通过MAXIMUM_CAPACITY做了一个“保险”来确保容量不会超过某个安全极限。
static final int MAXIMUM_CAPACITY = 1 << 30;
// 构造函数中未指定时使用的负载因子。
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 在向存储单元添加元素时,存储单元使用树结构而不是链表结构的存储单元计数阈值。
// 当向存储单元添加元素,且该存储单元至少有此数量的节点时,存储单元将转换为树结构。
// 该值必须大于 2并且应该至少为 8以与移除元素时转换回普通存储单元的假设相匹配。
static final int TREEIFY_THRESHOLD = 8;
// 在调整大小操作期间将(拆分的)存储单元转换为非树结构存储单元的存储单元计数阈值。
// 应该小于 TREEIFY_THRESHOLD并且最多为 6以与移除元素时的收缩检测相匹配。
static final int UNTREEIFY_THRESHOLD = 6;
// 存储单元可以树化的最小表容量。
// (否则,如果存储单元中有太多节点,表将进行扩容。)
// 应该至少是 4 * TREEIFY_THRESHOLD以避免扩容和树化阈值之间的冲突。
static final int MIN_TREEIFY_CAPACITY = 64;
```
![](https://blog.meowrain.cn/api/i/2025/06/13/nm8tvy-0.webp)
# HashMap的存储结构
从源码上看HashMap的每个存储单元都是一个链表或者红黑树也就是下面的Node类那么我们可以用下面的图来展示一个完成初始化的HashMap的存储结构。
![](https://blog.meowrain.cn/api/i/2025/06/13/nnlf4v-0.webp)
### 为什么采用数组?
因为数组的随机访问速度非常快HashMap通过哈希函数将键映射到数组索引位置从而实现快速查找。
数组的每一个元素称为一个桶bucket对于一个给定的键值对key,valueHashMap会计算出一个哈希值计算的是key的hash然后通过`(n-1) & hash`来确定该键值对在数组中的位置。
### 如何定位key value该存储在桶数组的哪个位置上获取index
HashMap通过`(n - 1) & hash`来计算索引位置其中n是数组的长度hash是键的哈希值。
### 如何计算hash值
HashMap使用键的`hashCode()`方法计算哈希值,然后对哈希值进行扰动处理,最后通过`(n-1) & hash`来确定元素在数组中的存储位置。
### 为什么要扰动处理?
扰动处理是为了减少哈希冲突防止哈希值分布不均。HashMap会对哈希值进行扰动处理以确保不同的键能够更均匀地分布在数组中从而减少冲突。
在Java 8中HashMap使用了一个扰动函数来优化hash值的分布
```java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
```
这个函数的作用是:
1. 首先获取key的hashCode()值
2. 将hashCode的高16位与低16位进行异或运算
### 为什么用的是&运算而不是取模运算?
在java中我们会让HashMap的容量是2的幂次方这样可以通过`(n-1) & hash`来快速计算出索引位置,避免了取模运算的性能开销。
这里`(n - 1) & hash` == `hash % n`,但&运算比取模运算更高效。
n是数组的长度hash是键的哈希值。
### 为什么要让HashMap的容量是2的幂次方
因为当容量是2的幂次方时`(n-1) & hash`可以快速计算出索引位置,而不需要进行取模运算。
![](https://blog.meowrain.cn/api/i/2025/06/13/nqocqh-0.webp)
### 为什么会用到链表?
我们在HashMap的使用过程中可能会遇到哈希冲突的情况也就是不同的键经过哈希函数计算后得到了相同的索引位置使用链表我们可以把这些冲突的键值对存储在同一个桶中用链表连接在一起jdk8开始链表节点不再使用头插法而是使用尾插法这样可以减少链表的长度提升查找效率。
头插法还可能造成链表形成环形,导致死循环。
![](https://blog.meowrain.cn/api/i/2025/06/13/nva4ft-0.webp)
## Node
```java
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
return o instanceof Map.Entry<?, ?> e
&& Objects.equals(key, e.getKey())
&& Objects.equals(value, e.getValue());
}
}
```
# HashMap的Put方法
HashMap的put方法是用来添加键值对到HashMap中的核心方法。它的实现逻辑如下
```java
/**
* 实现 Map.put 和相关方法。
*
* @param hash key的哈希值
* @param key 键
* @param value 要放入的值
* @param onlyIfAbsent 如果为 true则不更改现有值
* @param evict 如果为 false则表处于创建模式。
* @return 返回先前的值,如果没有则返回 null
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) { // 📌 定义 putVal 方法,用于将键值对放入 HashMap
Node<K,V>[] tab; Node<K,V> p; int n, i; // 🏷️ 声明局部变量tab (哈希表数组), p (当前节点), n (数组长度), i (数组索引)
// 检查哈希表是否为空或长度为0
if ((tab = table) == null || (n = tab.length) == 0)
// 🏗️ 如果为空,则调用 resize() 方法初始化或扩容哈希表,并获取新的长度
n = (tab = resize()).length;
// 🎯 计算键在哈希表中的索引位置 i并检查该位置是否为空
if ((p = tab[i = (n - 1) & hash]) == null)
// ✨ 如果为空,直接在该位置创建一个新节点
tab[i] = newNode(hash, key, value, null);
else { // 🤔 如果该位置不为空(发生哈希冲突)
Node<K,V> e; K k; // 🏷️ 声明局部变量e (用于找到的已存在节点或新节点), k (临时键)
// 🔑 检查桶中第一个节点的哈希值和键是否与要插入的键值对匹配
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// ✅ 如果匹配,将 e 指向该节点 p (表示键已存在)
e = p;
// 🌳 检查桶中的节点是否为 TreeNode (红黑树节点)
else if (p instanceof TreeNode)
// 🌲 如果是红黑树,调用 putTreeVal 方法将键值对插入红黑树
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);
// 🌲❓ 检查链表长度是否达到树化阈值 (TREEIFY_THRESHOLD - 1 因为 binCount 从0开始计数且当前p是尾部的前一个节点)
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; // ✅ 如果匹配,跳出循环 (表示键已存在e 指向该节点)
// 👉 将 p 指向下一个节点,继续遍历
p = e;
}
}
// 🔑❓ 检查 e 是否不为 null (表示键已存在于哈希表中,或者在红黑树中找到了/插入了节点)
if (e != null) { // existing mapping for key
// 💾 获取旧值
V oldValue = e.value;
// 🔄❓ 根据 onlyIfAbsent 参数决定是否更新值 (如果 onlyIfAbsent 为 false或者旧值为 null则更新)
if (!onlyIfAbsent || oldValue == null)
// ⬆️ 更新节点的值
e.value = value;
// 🔗 回调方法,用于 LinkedHashMap 等子类记录节点访问
afterNodeAccess(e);
// ↩️ 返回旧值
return oldValue;
}
}
// 🛠️ 修改计数器加1用于迭代器快速失败机制
++modCount;
// 📈 检查当前元素数量是否超过阈值 (threshold = capacity * loadFactor)
if (++size > threshold)
// 🏗️ 如果超过阈值,调用 resize() 方法扩容哈希表
resize();
// 🔗 回调方法,用于 LinkedHashMap 等子类记录节点插入
afterNodeInsertion(evict);
// ↩️ 如果是新插入的键值对,返回 null
return null;
}
```
![](https://blog.meowrain.cn/api/i/2025/06/13/nzkmzk-0.webp)
# HashMap的Get方法
```java
/**
* 实现 Map.get 和相关方法。
*
* @param key 要查找的键
* @return 返回找到的节点,如果没有找到则返回 null
*/
final Node<K,V> getNode(Object key) { // 📌 定义 getNode 方法,用于根据键查找节点
Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k; // 🏷️ 声明局部变量tab (哈希表数组), first (桶中第一个节点), e (当前节点), n (数组长度), hash (键的哈希值), k (临时键)
// 🔍 检查哈希表是否不为空且长度大于0并且根据键的哈希值计算出的桶位置有节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & (hash = hash(key))]) != null) {
// 🎯 首先检查桶中第一个节点的哈希值和键是否与要查找的键匹配
if (first.hash == hash && // always check first node 总是先检查第一个节点
((k = first.key) == key || (key != null && key.equals(k))))
// ✅ 如果匹配,直接返回第一个节点
return first;
// 🔗 检查第一个节点是否有下一个节点(链表或红黑树)
if ((e = first.next) != null) {
// 🌳 如果第一个节点是 TreeNode红黑树节点
if (first instanceof TreeNode)
// 🌲 在红黑树中查找并返回节点
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 🔄 如果是链表,遍历链表查找节点
do {
// 🔑 检查当前节点的哈希值和键是否与要查找的键匹配
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// ✅ 如果匹配,返回当前节点
return e;
// 👉 移动到下一个节点,继续遍历直到链表末尾
} while ((e = e.next) != null);
}
}
// ❌ 如果没有找到匹配的节点,返回 null
return null;
}
```
![](https://blog.meowrain.cn/api/i/2025/06/13/o2aa3y-0.webp)
# HashMap的扩容
HashMap的扩容是指当存储的元素数量超过负载因子所允许的最大数量时HashMap会自动增加其容量。
扩容的过程包括以下几个步骤:
1. **计算新的容量**:新的容量通常是当前容量的两倍。
2. **创建新的数组**:创建一个新的数组来存储扩容后的元素。
3. **重新计算索引位置**:对于每个元素,重新计算其在新数组中的索引位置,并将其移动到新数组中。
源码中是resize()函数
```java
/**
* 初始化或将表大小扩大一倍。如果为null则根据字段threshold中保存的初始容量目标进行分配。
* 否则因为我们使用的是2的幂次方扩展每个桶中的元素必须保持在相同的索引位置
* 或者在新表中以2的幂次方偏移量移动。
*
* @return 返回新的哈希表
*/
final Node<K,V>[] resize() { // 📏 定义扩容方法
Node<K,V>[] oldTab = table; // 🗂️ 保存旧的哈希表引用
int oldCap = (oldTab == null) ? 0 : oldTab.length; // 📊 获取旧表的容量如果为null则容量为0
int oldThr = threshold; // 📋 保存旧的阈值
int newCap, newThr = 0; // 🆕 声明新容量和新阈值变量
// 🔍 如果旧容量大于0表已初始化
if (oldCap > 0) {
// ⚠️ 如果旧容量已达到最大值,则不再扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE; // 🔢 将阈值设为最大整数值
return oldTab; // ↩️ 直接返回旧表,不扩容
}
// 🔢 新容量 = 旧容量 * 2且不超过最大容量且旧容量 >= 默认初始容量
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 📈 新阈值 = 旧阈值 * 2
}
// 🎯 如果旧容量为0但旧阈值大于0通过构造函数指定了初始容量
else if (oldThr > 0)
newCap = oldThr; // 🆕 新容量等于旧阈值
// 🌟 如果旧容量和旧阈值都为0使用默认值初始化
else {
newCap = DEFAULT_INITIAL_CAPACITY; // 🔢 新容量设为默认初始容量16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 📊 新阈值 = 0.75 * 16 = 12
}
// 🔧 如果新阈值为0需要重新计算
if (newThr == 0) {
float ft = (float)newCap * loadFactor; // 📐 计算新阈值 = 新容量 * 负载因子
// ✅ 确保新阈值不超过最大值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr; // 📋 更新阈值
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 🏗️ 创建新的哈希表数组
table = newTab; // 🔄 将新表赋值给table字段
// 📦 如果旧表不为空,需要转移元素
if (oldTab != null) {
// 🔄 遍历旧表的每个桶
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e; // 🏷️ 当前节点
// 🔍 如果当前桶不为空
if ((e = oldTab[j]) != null) {
oldTab[j] = null; // 🧹 清空旧桶帮助GC
// 🔗 如果桶中只有一个节点(没有链表或红黑树)
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; // 🔺 高位链表的头和尾节点
Node<K,V> next; // ➡️ 下一个节点
// 🔄 遍历链表中的所有节点
do {
next = e.next; // 📍 保存下一个节点
// 🎲 通过 (e.hash & oldCap) 判断节点应该放在哪个位置
if ((e.hash & oldCap) == 0) {
// 🔻 放在原位置(低位链表)
if (loTail == null)
loHead = e; // 🎯 如果低位链表为空,设置头节点
else
loTail.next = e; // 🔗 连接到低位链表尾部
loTail = e; // 📍 更新尾节点
}
else {
// 🔺 放在原位置+oldCap的位置高位链表
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; // 📍 放入新表的 j + oldCap 位置
}
}
}
}
}
return newTab; // ↩️ 返回新的哈希表
}
```
## 扩容的时候高位和低位链表详解
```java
else {
Node<K,V> loHead = null, loTail = null; // 🔻 低位链表的头和尾节点
Node<K,V> hiHead = null, hiTail = null; // 🔺 高位链表的头和尾节点
Node<K,V> next; // ➡️ 下一个节点
// 🔄 遍历链表中的所有节点
do {
next = e.next; // 📍 保存下一个节点
// 🎲 通过 (e.hash & oldCap) 判断节点应该放在哪个位置
if ((e.hash & oldCap) == 0) {
// 🔻 放在原位置(低位链表)
if (loTail == null)
loHead = e; // 🎯 如果低位链表为空,设置头节点
else
loTail.next = e; // 🔗 连接到低位链表尾部
loTail = e; // 📍 更新尾节点
}
else {
// 🔺 放在原位置+oldCap的位置高位链表
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; // 📍 放入新表的 j + oldCap 位置
}
}
```
### 核心原理
当HashMap从容量n扩容到2n时每个元素的新位置只有两种可能
- **保持原位置**(低位链表)
- **移动到原位置+n**(高位链表)
判断依据: `(e.hash & oldCap) == 0`如果为0则放在原位置否则放在原位置+n。 n是旧容量。
- 低位链表lo list满足 `(e.hash & oldCap) == 0` 的节点,扩容后**继续放在原位置** `j`
- 高位链表hi list满足 `(e.hash & oldCap) != 0` 的节点,扩容后放在新位置 `j + oldCap`
#### 举例子
假设oldCap = 16,newCap = 32
oldCap=16 // 10000
newCap=32 // 100000
```
hash1 = 5; // 000101
扩容前(cap = 16)计算index
index1 = hash1 & (oldCap - 1) ==> 5 & 15
000101
&000111
`--------`
000101 ==> 5
扩容后(cap = 32)计算index
我们要看要不要移动这个kv到桶中的新位置
判断 (hash & oldCap) == 0
hash1 & oldCap ==> 5 & 16
00000101
& 00010000
`-----------`
00000000 ==> 0
所以这个kv会放在原位置5
```
再举个例子
```
hash2 = 20; // 10100
扩容前(cap = 16)计算index
index2 = hash2 & (oldCap - 1) ==> 20 & 15
10100
& 01111
--------------
00100 ==> 4
扩容后(cap = 32)计算index
要先看hash & oldCap == 0 ?
hash2 & oldCap ==> 20 & 16
00010100
& 00010000
--------------
00010000 ==> 16
不为0所以这个20会放在新的位置 原来的位置+ 旧桶数组容量 = 4 + 16 = 20
```
你的理解非常正确!🎉👍
你已经掌握了HashMap扩容时分桶位置变更的本质原理让我们用你的描述稍作归纳和梳理验证你的思路
---
### 为什么判断的是与oldCap相与得到的值是1还是0来决定搬迁位置
当HashMap扩容时容量从 `oldCap` 扩展到 `newCap`,比如从 16 扩展到 32。
- 原来 HashMap 的下标计算是:`index = hash & (oldCap-1)`,比如 `00001111`低4位
- 扩容后,计算下标变为:`index = hash & (newCap-1)`,比如 `00011111`低5位也就是多了一位。
-`oldCap`(如 `00010000`)相与,就相当于“掐头去尾”地只关注扩容新增的那一位:
- 如果 `(hash & oldCap) == 0`说明这位是0**扩容后的位置等于原index**
- 如果 `(hash & oldCap) != 0`说明这位是1**扩容后的位置等于原index + oldCap**
- 这种判断让你高效知道节点该不该搬迁以及搬去哪里无需重新完全计算index。
---
#### 举例验证(巩固印象)
假如:
- oldCap = 16 ⇒ 00010000
- oldCap-1 = 15 ⇒ 00001111
- newCap = 32 ⇒ 00100000
- newCap-1 = 31 ⇒ 00011111
- hash = 21 ⇒ 10101
**扩容前下标:**
```java
index = 10101 & 01111 = 00101 = 5
```
**扩容后下标:**
```java
index = 10101 & 11111 = 10101 = 21
```
**oldCap这一位的判断**
```java
10101 & 10000 = 10000 0
```
说明这位是1扩容后下标变成原index+16=21。
---
### 扩容的条件是什么?
当 HashMap 中存储的元素数量超过了「阈值」threshold就会进行扩容。
这个「阈值」的计算公式是:
```
threshold = capacity * loadFactor
```
loadFactor 是负载因子,默认值为 0.75。
### 为什么要进行搬迁呢?
HashMap扩容的主要目的是
减少哈希冲突,提高查找、插入效率。
让更多桶可用,降低碰撞链表队列的长度。
# jdk1.7和jdk1.8中hashmap的区别
![](https://blog.meowrain.cn/api/i/2025/06/13/pf960q-0.webp)
![](https://blog.meowrain.cn/api/i/2025/06/13/pfb9eb-0.webp)
![](https://blog.meowrain.cn/api/i/2025/06/13/pfedpt-0.webp)
![](https://blog.meowrain.cn/api/i/2025/06/13/pfqbnm-0.webp)
![](https://blog.meowrain.cn/api/i/2025/06/13/pfyw7c-0.webp)
# 链表什么时候转红黑树?
桶数组中某个桶的链表长度>=8 而且桶数组长度> 64的时候hashmap会转换为红黑树
![](https://blog.meowrain.cn/api/i/2025/06/25/hi2z5e-0.webp)