前言
我们的手机通讯录之所以能快速定位到特定联系人,就是因为它运用了HashMap底层的原理。手机通讯录将每个联系人的姓名作为键,电话号码作为对应的值,通过这个键值对的方式实现了快速的数据定位和获取。就像你通过关键字快速找到对应的联系人一样,HashMap通过键的映射,为我们提供了高效的数据管理方式。通过手机通讯录这个生活化的例子,我们能更直观地理解HashMap底层的原理。在接下来的文章中,我们将深入剖析HashMap的内部机制,解开它背后的技术奥秘。让我们一起揭开手机通讯录这个神奇的密码保险柜,探索HashMap的魅力吧!
HashMap底层结构
HashMap的实现结构主要有三个:
-
二叉树
-
红黑树
-
散列表
红黑树
红黑树(Red Black Tree):也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树(Symmetric Binary B-Tree),CRUD操作的时间复杂度为:O(log n)
-
性质1:节点要么是红色,要么是黑色
-
性质2:根节点是黑色
-
性质3:叶子节点都是黑色的空节点
-
性质4:红黑树中红色节点的子节点都是黑色
-
性质5:从任一节点到叶子节点的所有路径都包含相同数目的黑色节点(高度差不能超过1)
在添加或删除节点的时候,如果不符合这些性质会发生旋转,以达到所有的性质
散列表(哈希表)
在散列表中,数据元素通常被存储在数组中。每个数据元素都有一个对应的关键字,通过对关键字进行哈希(散列)运算,得到一个数组索引,将数据元素保存在该索引位置上。当需要访问一个数据元素时,通过相同的哈希运算找到对应的索引即可快速地获取到数据。
其实本质上就是个数组的理念,只不过说现在是将数组的下标可以设置为了一个key而已,对于客户端来说是通过key来找到该元素,实际上底层是对该key进行散列计算,得到底层数组的该元素的下标而已。
哈希冲突
由于散列算法的结果是无限的,是由于哈希算法被计算的数据是无限的,而计算后的结果范围有限,所以总会存在不同的数据经过计算后得到的值相同,这就是哈希冲突。
解决方法:
-
开放定址法,也称为线性探测法,就是从发生冲突的那个位置开始,按照一定的次序从 hash 表中找到一个空闲的位置,然后把发生冲突的元素存入到这个空闲位置中。ThreadLocal 就用到了线性探测法来解决 hash 冲突的。
-
链式寻址法,这是一种非常常见的方法,简单理解就是把存在 hash 冲突的 key,以单向链表的方式来存储,比如 HashMap 就是采用链式寻址法来实现的。
-
再 hash 法,就是当通过某个 hash 函数计算的 key 存在冲突时,再用另外一个 hash 函数对这个 key 做 hash,一直运算直到不再产生冲突。这种方式会增加计算时间,性能影响较大。
-
建立公共溢出区, 就是把 hash 表分为基本表和溢出表两个部分,凡事存在冲突的元素,一律放入到溢出表中。
HashMap 在 JDK1.8 版本中,通过链式寻址法+红黑树的方式来解决 hash 冲突问题,其中红黑树是为了优化 Hash 表链表过长导致时间复杂度增加的问题。当链表长度大于 8 并且 hash 表的容量大于 64 的时候,再向链表中添加元素就会触发转化。将链表法中的链表改造红黑树还有一个非常重要的原因,可以防止DDos攻击
DDoS(Distributed Denial of Service)攻击是一种网络攻击方式,目的是通过同时从多个计算机向目标服务器发送大量的请求,以致使目标服务器无法正常工作或响应正常用户的请求。采用红黑树的结构,可以避免二叉树斜树问题的产生,容纳更多的数据,避免服务瘫痪
拓展:红黑树和AVL树的区别
红黑树特点
-
规则1: 每个节点不是黑色就是红色
-
规则2:根节点为黑色
-
规则3:红色节点的父节点和子节点不能为红色
-
规则4:所有的叶子节点都是黑色(空节点视为叶子节点NIL)
-
规则5:每个节点到叶子节点的每个路径黑色节点的个数都相等。
平衡二叉树特点
-
规则1:每个节点最多只有两个子节点(二叉)
-
规则2:每个节点的值比它的左子树所有的节点大,比它的右子树所有节点小(有序)
-
规则3:每个节点左子树的高度与右子树高度之差的绝对值不超过1
二者比较
红黑树和AVL树都是自平衡二叉搜索树,它们的平衡维护是为了保证树的高度不至于太大,从而使树的基本操作的时间复杂度始终保持在 O(log n)
的级别内。
插入删除性能
-
AVL树的平衡维护是通过每个节点的左子树高度和右子树高度之差(即平衡因子)是否为 -1、0、1 来实现的,这种平衡维护要求非常严格。当一个节点的平衡因子超出了 -1、0、1 的范围时,就需要通过旋转操作来调整树的结构。因为AVL树是严格平衡的,所以在插入或删除节点时,可能需要进行多次旋转操作才能将树保持在平衡状态,并且还需要更新每个节点的平衡信息,导致时间代价较高。
-
而红黑树则采用更加灵活的平衡维护方式。它将节点分为红色和黑色并通过特定的规则来保证树的平衡。与 AVL 树不同,红黑树允许节点之间的高度差超过 1,只需要改变少量节点的颜色信息不用旋转,就能达到平衡,并可能进行较少的旋转,但它通过保证从根节点到叶节点的任意路径上黑色节点的数量相同来避免树的高度过大。因为红黑树的平衡维护更加灵活,所以在插入或删除节点时,需要调整的次数相对较少,导致时间代价较低。
空间成本
-
红黑树相对于AVL树需要额外存储颜色信息、指向父节点的指针,以及额外的1位(或1字节)空间来表示颜色。相比之下,AVL树相对简单,不需要额外存储颜色信息,只需要存储平衡因子。
HashMap的实现原理
HashMap解决哈希冲突在jdk1.7和jdk1.8的区别
-
JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
-
jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8) 时并且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。扩容 resize( ) 时,红黑树拆分成的树的结点数小于等于临界值6个,则退化成链表
HashMap的put方法的具体流程
-
HashMap是
懒惰加载
,在创建对象时并没有初始化数组 -
在无参的构造函数中,设置了默认的加载因子是
0.75
-
判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
-
根据键值key计算hash值得到数组索引
-
判断table[i]==null,条件成立,直接新建节点添加
-
如果table[i]==null ,不成立
-
判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
-
判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
-
遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操 作,遍历过程中若发现key已经存在直接覆盖value
-
-
插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。
HashMap源码分析-扩容
HashMap的寻址算法
一旦获得了键的哈希码,HashMap会使用一个寻址算法来确定键值对在Hash表中的存储位置。这个寻址算法被称为散列冲突解决方法。
HashMap允许存储null作为键和值。当你插入键为null时,HashMap会将它们放入内部数组的0下标位置
具体的hash值计算方式为
-
key取hash值为h
-
对h二进制向右逻辑移动 16 位,通过这样的移位操作,可以使得不同的对象的高16位也参与到哈希计算中,从而在一定程度上减少哈希冲突的可能性。
-
将h与步骤2的位移结果进行异或运算,得到最终的哈希值。
为何HashMap的数组长度一定是2的次幂?
-
计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
-
扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
HashMap多线程下死循环问题
HashMap死循环问题是JDK1.7之前存在问题,主要源*于HashMap的自身的工作机制和并发处理导致*的问题,而对于JDK1.8后,官方就彻底解决了这个问题,对于死循环问题,我们首先了解一下HashMap数据插入原理
JDK1.7的HashMap头插法
在Java的HashMap中,put()操作采用的是“头插法”,也就是把新元素插入到链表头部。如有相同的key值插入,会覆盖旧元素。如果新元素的key值在HashMap中不存在,则会新建一个节点并放在链表头部。如果此时桶数组中对应位置已经有了元素,那么新插入的元素会作为该元素的前驱。
导致死循环的原因
阶段一:
多线程下多个线程同时在扩容临界点进行插入操作,同时开始进行扩容
阶段二:
多个线程同时进行扩容,扩容前首先需要获取结尾元素的下一个节点信息,以便使用头插法进行扩容。然后有部分线程时间片用完,在获取节点信息后就就行休眠了,还没开始进行头插扩容。有部分线程直接进行扩容操作,在扩容操作过程种的节点结构重组消息休眠线程不知道。
阶段三:
第二阶段进行扩容操作后,节点结构重组后,休眠的线程拿着旧的节点消息进行二次头插,致使死循环
总结
为什么重写equals()就一定要重写hashCode()方法?
在Java中,equals()方法用于比较两个对象的内容是否相等,而hashCode()方法用于计算对象的哈希码(hash code)。哈希码是一个整数,用于快速定位对象在哈希表等数据结构中的存储位置。
当我们将对象存储在基于哈希的数据结构中,例如HashSet、HashMap等,它们会使用hashCode()方法来确定对象在内部数据结构中的存储位置。当我们要查找、插入或删除对象时,会首先计算对象的哈希码,然后在相应的位置进行操作。
而equals()方法则用于在发生哈希碰撞(相同哈希码,不同对象)的情况下,进一步比较对象的内容是否相等。哈希碰撞是指不同的对象具有相同的hashCode()值,在哈希表等数据结构中可能发生冲突。
如果两个对象被equals()方法判断为相等,那么它们的hashCode()方法应该返回相同的值。这是因为在哈希表等数据结构中,equals()方法相等的对象应该存储在相同的位置,而hashCode()方法相等的对象应该具有相同的哈希码。
如果我们只重写equals()方法而不重写hashCode()方法,会导致在哈希表等数据结构中出现问题。查找等操作可能无法正确地定位到对象,甚至会导致对象无法正常存取。
为什么使用红黑树而不使用AVL树?
-
插入、删除操作的代价较低:红黑树的调整策略对于插入或删除的代价相对较低,而 AVL 树的严格平衡策略可能需要更多的操作来保持平衡,从而导致时间代价更高。
-
更好的插入、删除性能:当需要插入和删除节点时,红黑树的结构调整代价相对较小,因此相对来说插入和删除操作的性能更好
在解决 hash 冲突的时候,为什么选择先用链表,再转红黑树?
因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于 8 个的时候,链表结构可以保证查询性能。当元素大于 8 个的时候, 红黑树搜索时间复杂度是 O(logn),而链表是 O(n),此时需要红黑树来加快查询速度,但是插入和删除节点的效率变慢了。如果一开始就用红黑树结构,元素太少,插入和删除节点的效率又比较慢,浪费性能。