HashMap&HashSet源码解读
HashMap
HashSet需要提起的只有一句话
HashSet使用适配器模式包装了HashMap,所有的Value都是同一个Object对象,只有Key不一样,HashSet就是HashMap的KeySet
HashMap概述
一个允许Key为空也允许Value为空的哈希表
Hash冲突
当多个对象的hashcode计算结果一致时,需要处理冲突
-
开放寻址法
-
基本思想是当发生冲突时,通过探测来查找另外一个空的位置
-
探测方法有线性探测、二次探测、双重hash
-
线程探测就是:i+1、i+2、i+3.....
-
二次探测就是间隔按照平方相加:i+12、i+22、i+3^2.....
-
双重hash就是使用第二个hash函数探测间隔i + j * h2(key)
-
使用开放寻址法时,删除操作需要标记为已删除,查找操作时需要根据探测策略生成探测序列,例如i+1、i+2、i+3。并且在探测时需要判断当前元素的hash值,防止探测位置溢出。
-
-
冲突链表法
-
HashMap在Java8之前采用的就是冲突链表法,将所有相同哈希值的元素放在同一个链表(bucket)中
-
table太大和太小会有什么影响?
-
table太小会导致频繁扩容,并且会有大量元素在链表中,影响查询速度。
-
table太大会导致迭代效率低,因为需要遍历全部bucket。
-
-
负载因子
负载因子 = 当前元素数量 / 哈希表的容量
负载因子低:
-
空间利用率低
-
查询效率高
负载因子高:
-
空间利用率高
-
哈希碰撞多,导致查询效率低
因此如何平衡空间利用率和查询效率是如何设计HashMap需要做的事情。
HashMap的内部参数
-
DEFAULT_INITIAL_CAPACITY:初始容量16
-
MAXIMUM_CAPACITY:最大容量2^30,这是一个默认最大值,如果超过就是Integer.MAX_VALUE
-
DEFAULT_LOAD_FACTOR:负载因子阈值0.75,超过自动扩容
-
TREEIFY_THRESHOLD:树化阈值8,超过链表变成红黑树
-
UNTREEIFY_THRESHOLD:还原阈值6,小于红黑树变成链表
-
MIN_TREEIFY_CAPACITY:最小树型化阈值64,容量大于该值时才能使用红黑树,否则优先扩容,而不是树化。
-
modCount:结构性修改标志,用于快速失败。
链表转为红黑树的逻辑
// 在 HashMap 中,当链表长度达到 TREEIFY_THRESHOLD 时,考虑链表转化为红黑树 if (binCount >= TREEIFY_THRESHOLD) { if (tab.length < MIN_TREEIFY_CAPACITY) { // 扩容前的处理 resize(); } treeifyBin(tab, hash); }
解释:如果链表长度超过树化阈值8,此时考虑转为红黑树,但是不是马上转,而是如果当前哈希表容量小于64,则先扩容。扩容之后如果链表仍然很长,仍然大于8,则将链表转换为红黑树。
为什么优先扩容而不是优先转化红黑树?
-
减少负载因子,降低链表转化红黑树的频率,因为转化是一个复杂的操作,需要额外的开销和复杂性。
-
降低链表长度,有效利用hash表空间,优化性能。
-
一句话:用更多的空间换更多的性能。
为什么链表转树阈值为8,树转链表阈值为6?
- 为了避免红黑树和链表频繁转换降低性能,设置了一段缓冲区。
扩容逻辑
1、触发扩容:当元素数量超过了当前容量 * 负载因子时,进行扩容,默认负载因子为0.75,可以在初始化时指定。
2、计算新容量:扩容为原来的两倍
3、创建新的数组:分配一个新的数组Node[],用于存储元素。
4、重新哈希Rehash
-
迁移元素,重新遍历旧数组中的每个桶,重新计算每个元素的索引,移动到新数组中的相应位置。
-
在Rehash过程中,如果某个bucket链表长度超过8,就转红黑树,此时不管MIN_TREEIFY_CAPACI
为什么每次扩容为两倍?为什么容量都是2的幂次方?
在计算元素bucket位置时,计算公式为:(n-1)&hash(key)
代表按位与运算,如果n总是2的幂次方,则10000==16,减去1则为1111,会导致低位变成全1
这个公式实际上是从hash值中取出最低的几位。并且通过“&”运算符实现,可以均匀hash。
位运算&比模运算%效率要高,可以提高性能
伪代码如下:
void resize() { // 计算新容量 int newCapacity = oldCapacity * 2; // 创建新数组 Node<K,V>[] newTable = (Node<K,V>[]) new Node[newCapacity]; // 迁移旧数组中的所有元素到新数组 transfer(newTable); // 更新哈希表的容量和阈值 threshold = (int) (newCapacity * loadFactor); table = newTable; } void transfer(Node<K,V>[] newTable) { // 遍历旧数组中的每个桶 for (Node<K,V> e : table) { if (e != null) { // 将桶中的所有元素重新哈希到新数组中 Node<K,V> next; do { next = e.next; int i = indexFor(e.hash, newTable.length); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null); } } }
判断相同元素的逻辑
首先计算hashcode,hashcode不相同的元素一定不相同
如果hashcode相同,再使用equals方法判断
这也是为什么hashcode和equals方法要一起重写的原因,即:
保证equals方法相同的时候hashcode也相同
hashcode的计算方法
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
-
如果为空直接把索引设置为0,避免异常
-
计算元素自身的hashcode,但是为了避免自身hash方法的问题,加了一个哈希扰动
-
扰动函数:将原来的hash值和无符号右移16位的值异或,增加hash函数的随机性,减少哈希冲突
-
右移操作将哈希码的高 16 位移到低 16 位,有助于将哈希码的不同部分组合在一起。这样可以确保哈希值的变化更为复杂,不容易被预测。
红黑树和B+树
红黑树(Red-Black Tree)是一种自平衡的二叉搜索树。
-
自平衡:任意插入和删除操作都只需要常数级别的旋转操作就可以维持平衡。
-
性能稳定
-
高效:查找、插入、删除的时间复杂度都是logN。
-
非严格平衡,维护成本低。
红黑树也适合在内存中做索引
-
内存高效:只需要存储少量额外信息。
-
性能稳定,避免了退化成链表的可能性
-
适合频繁更新操作的系统,内存中的数据一般是频繁修改的。
为什么不使用红黑树作为磁盘索引?
-
B+树能减少IO操作,相比红黑树更高效。
-
红黑树的结构要求节点访问路径不均匀,节点的访问会涉及到多个磁盘块,导致多次IO。
B+树的优势
-
块对齐,B+树的叶子节点可以设计为与块大小对齐,使得每次读取一个节点刚好是一整个块,提高了利用率,减少了IO。
-
减小深度,B+树一般3-4层,IO少。
-
批量读取,B+树叶子节点有链表串起来,可以提高范围查找的效率,而红黑树范围查找需要中序遍历。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构