精华内容
下载资源
问答
  • 一个HashMap好杠的

    2021-05-08 16:27:22
    如果要说到HashMap的底层数据结构,我相信大家应该都能说出来–数组加链表,HashMap的底层数据结构在JDK1.7与JDK1.8的时候是有很大区别的,在JDK1.7的时候结构是数据加链表 , 但在JDK1.8除了数组加链表外,还引入了...

    大家好,我是哪吒,本期文章给大家讲讲HashMap的底层原理以及HashMap中涉及到的一些小的细节

    初始化

    如果要说到HashMap的底层数据结构,我相信大家应该都能说出来–数组加链表,HashMap的底层数据结构在JDK1.7与JDK1.8的时候是有很大区别的,在JDK1.7的时候结构是数据加链表 , 但在JDK1.8除了数组加链表外,还引入了红黑树,除了这两者的区别以外,还有其底层的hash值的计算也有区别

    当我们在了解HashMap的时候,我们首先就是要去了解它的初始化的过程,我们在使用HashMap(JDK1.8)的时候,一般就是简单的去put一个值

    比如一个很简单的一个例子

    第一次put值

    当然除了String类型的,你还可以使用int,long甚至直接用一个对象,我这边就直接用一个简单的String类型

    当我们在调用put方法的时候,其实put方法里面还调用了另外的一个方法,我们点击进去可以看到

    put方法内部调用

    内心OS:这哪是方法调用啊,这分明就是在套娃

    可以看到其put方法内部直接调用了putVal方法,那这个方法就是在我们put值的时候,会去创建一个新的数组,然后去初始化大小,还有阈值等信息

    第一次调用会直接走第一个if的逻辑

    当我们第一次put(“a”,“张三”)这个值的时候会直接走第一个if的逻辑,也就是说刚开始的时候成员变量table是为null

    这里的成员变量table就是数据结构中的数组了,而且是一个Node对象类型的数组

    所以我们在刚开始 new 一个 HashMap 对象的时候,数组并还没有创建出来,而是在我们第一次put值的时候才去创建数组

    紧接着就会调用resize()方法,这个方法主要是做两个事情,一个是初始化一个数组并设置数组的长度以及扩容的阈值,二个是扩容

    初始化调用resize方法

    当第一次进入到这个方法里面来的时候,变量oldTab(数组)是null 、odlCap(数组的长度)为0 、oldhr(阈值)0,那么接下来就是HashMap要去初始化这些值了

    第一次初始化扩容的阈值和数组长度

    可以看到Hash给数组的默认长度是16,阈值是直接用 数组长度 * 0.75 算出来的

    可以看到 JDK1.8 中的HashMap是在第一次 put 值得时候才会去初始化,有点类似于懒加载,这也是与 JDK1.7 的不同之处

    那我们接着往下走,看看还做了哪些的初始化

    初始化数组

    可以看到,当走到这一步的时候,直接 new Node[newCap] 数组,并将数组的长度设置为默认的长度16,然后最终将对象传递给了成员变量 table,数组才真正的初始化出来并把数组返回,至此完成了所有的初始化

    当我们继续往下走的时候就会走到这段代码

    根据hash值计算存放的位置

    它这边会直接根据hash值和数组的长度算出应存放在数组具体的位置,如果这个位置为 null 则直接将 计算出来的hashkeyvalue 封装成Node对象然后存放到数组对应的位置,存放完之后,第一次存放数据的逻辑就完成了

    到最后我们再来看一个有意思的东西

    初始化完之后modCount变量会进行一次递增

    我们看到都走完所有逻辑之后会 modCount变量进行一次递增,那么这个modCount变量到底是干啥的呢

    其实这也是为了安全考虑而设计只用机制–Fail-Fast机制

    我们都知道HashMap是一个线程不安全的类,这个机制主要体现在迭代器中,当有一个线程在循环HapMap里面的数据的时候,如果恰好另外的一个线程操作了这个HashMap , 那么就会抛出ConcurrentModificationException 的异常

    内部迭代安全机制

    当这个迭代器初始化的时候,会直接把modCount值赋值给expectedModCount,可以看到HashMap的内部维护的迭代器就去判断了modCount和expectedModCount这两个是不是相等,如果不相等就代表有其他的线程对HashMap做了操作,则直接抛出异常

    那说到这里又有读者要杠了

    某读者:那我要是不用循环去获取数据,而是直接用get方法去获取数据,是不是这个机制就不管用了?

    我:我们在使用get方法去获取数组的时候,由于get方法里面没有去做判断,所以是不会报异常,也就说这个机制也就不管用了,所以我们在使用非线程安全的数据结构的时候,尽量使用迭代器

    Hash值的计算

    我们上面讲过,HashMap会根据key的Hash值以及数组的长度来计算在数组中存放的位置

    HashMap对key进行Hash计算的方法在JDK1.7与JDK1.8有很大的区别,那么这里我们再来分析一下HashMap对hash值的计算,这里我将用分别结合JDK1.7 和 JDK1.8 来分析

    在用 JDK1.7 中的HashMap进行put操作的时候,我们可以看到JDK1.7的方法没有了套娃的这种操作,而是直接在put方法里面实现了业务逻辑

    JDK1.7是直接在put方法中实现逻辑

    可以看到JDK1.7的put方法并没有什么初始化的代码,这是因为初始化的操作在构造方法里面就已经完成了

    在构造方法中完成初始化

    我们还是用 hash.put(“a”,“张三”) 这个例子来看看 JDK1.7 是如何计算hash值的

    直接调用hash方法计算hash值

    可以看到,put方法内部直接调用了一个hash()方法来对key去计算hash值的,那我们再来看看这个hash方法里面主要做了啥

    变量h与key的hashCode进行异或运算

    这边局部变量h被赋予了默认值0 ,然后再与key的hashCode值进行了异或运算,得出结果值 97

    进行两次无符号位移和异或运算

    然后再用 97 进行了两次无符号位移和异或运算 ,得出hash值103

    计算得出最终值

    讲到这里,又有读者要杠了

    某读者:为什么要进行两次进行异或和无符号位移运算呢

    我:这里既然是用key的hashCode来进行运算,那无法避免有负数的情况,假如真的有负数,那计算数组索引的时候那就有问题了,因为数据的索引只能是一个正数,这里无符号位移也是为了这避免出现负数而造成索引位负数的问题,还有就是能够让hash值更散列,这样才能减少hash冲突,从而避免了链表过长

    那么算出hash值之后,接下来就是根据hash值和数据的长度算出具体存放的位置了,我们照样可以进入到方法里面看看是怎么算的

    计算存放到数组中的具体位置

    很简单的进行了一次位与运算得出一个值 7

    计算得出存放数组的索引位置

    算出这个值之后,接下来的操作简单了,如果这个位置是空的,则直接直接放到这个位置上,如果这个位置被其他的元素占了,则采用头插法的方式形成一个链表

    那知道了 JDK1.7 的Hash值如何计算的时候,那我们再来看看 JDK1.8 的Hash值是如何计算的

    我们还是用hash.put(“a”,“张三”)这个例子来分析

    直接在调用putVal方法

    这边put方法内部直接调用了putVal方法,同样也是使用了hash这个方法对key进行hash运算,那我们来看看这个hash方法是怎么对key计算hash值的

    计算逻辑很简单,这里是直接把局部变量h进行了一次无符号位移16位得出一个值,然后再用key的hashCode与这个值进行异或运算得出一个值 97

    计算得出的最终值 97

    这边算数组位置的时候与 JDK1.7 的运算相同,也是使用算出来的hash值与数组的长度进行位与运算

    这里的计算数组的索引位置与之前的一样

    关于HashMap如何计算Hash值我们只做一个了解就行了, 一般在面试中不会去问这些东西

    链表插入的方式

    HashMap数据结构是数组加链表(JDK1.7),那为什么会使用到链表呢

    我们都知道HashMap是根据key计算出hash值,那这样就不免有hash冲突的情况,那链表就是为了解决这种hash冲突而加上的

    那如果真的产生了hash冲突的时候,HashMap是如何将元素插入到链表里面去的呢

    在 JDK1.7 的时候是使用的头插法,但在 JDK1.8 之后就改成了尾插法

    说到这里又有读者要杠了

    某读者:为啥要改成尾插法呢

    我:其实头插法的效率比尾插法高,但是因为头插法在多线程的环境下会造成死链问题,所以为了避免这种情况就改用了尾插法的方式

    我们先来看看什么是头插法

    头插法,顾名思义,就是从链表的头部插入,这样的好处就是在插入的过程中不需要去遍历整个链表,可以很快的将数据插入到链表中

    试想一下,假如我们现在有一个链表

    数组加链表

    链表中已经有了a、b、c这三个元素,但此时 d 很有想法,它也想要插入到这个链表中去,于是就想着怎么插入才是最快的,看到链表的尾部离它太远了,而且还要经过很多的元素,于是就直接从头部插入

    直接从头部插入

    插完之后,d 元素所处的这个位置应该就成为了头部位置,那为了让 d 元素能够成为头部,我们还需要把整个链表往下移动

    从头部插入之后整个链表往下移动

    至此整个头插法的过程就完成了

    可别小看这么简单的一个数组加链表结构模型,当我们把这个结构模型弄清楚了之后,我们再来看HashMap中的头插法过程就会变得非常简单

    那HashMap中的头插法到底是怎么样的呢

    因为头插法是在 JDK1.7 版本及之前版本的实现,所以接下来的分析还是基于 JDK1.7 来分析

    我们还是以hash.put(“a”,“张三”)为例,当直接new HashMap()的时候会直接初始化一个数组大小,以及阈值(因为JDK1.7是直接在构造方法中初始化的),如下图

    初始化后的数组

    数组中的深蓝色代表存放的位置,这四个格子分别对应数组下标6、7、8、9、10

    HashMap会把键和值封装为 new Entrty(97,“a”,“张三”) 对象

    JDK1.7及以前的版本的链表的节点是一个Entrty节点,也就是说会把键和值封装成为一个Entrty对象

    我们暂且把这个对象叫做firstEntrty,HashMap用对"a"这个key计算出的hash值与数组的长度位与运算得到索引下标的值假设是7,firstEntrty对象会放在数组下标为 7 的这个位置

    将封装好的Entrty对象放到下标为7的位置

    放置完之后,如果又put了一个键值对 hash.put(“b”,“李四”),假如HashMap用对"b"这个key计算出的hash值与数组的长度位与运算得到索引下标值假设也是 7 ,那首先会把值封装为 new Entrty(97,“b”,“李四”) 对象,我们暂且将这个对象叫做lastEntrty ,因为下标为7的这个位置已经被firstEntrty这个对象占据,所以lastEntrty这个对象的会直接指向firstEntrty对象

    直接从头部插入对象

    lastEntrty这个对象从头部链接firstEntrty对象之后,因为要让lastEntrty这个对象处在头部位置,所以HashMap还会将这个链表整体往下移

    链表整体往下移动

    可以看到移完之后,lastEntrty被移到了firstEntrty 这个位置上,整个头插的过程就完成了

    那知道了HashMap的头插的过程我们再来看看源码就很清楚了

    HashMap中的源码

    这里是直接通过计算得出的索引下标值 7 来获取 firstEntry 对象,获取到firstEntry 对象之后再去新创建一个 lastEntry 对象,lastEntry 对象直接指向 firstEntry对象,再将 lastEntry 对象放到索引下标为 7 的那个位置上,完成下移操作

    注意,这里所说的指向 firstEntry 对象其实也是个赋值的操作,看到这行代码 table[bucketIndex] = new Entry<>(hash, key, value, e); 的构造方法里传了个变量e ,而这个 变量e 就是传的需要指向的下一个对象。它这里首先获取到 firstEntry 对象后,直接把这个对象传给了 lastEntry 这个对象的构造方法里的 变量next ,完成指向的操作

    其实这里的下移操作非常的简单,就赋个值完事了,因此插入的速度是非常快的

    看完头插法的这个过程之后,我们再来看看尾插法是怎么回事

    尾插法其实就跟头插法完全相反,头插法直接从头部插入,那尾插法自然就是从尾部插入了,但是这样去插的话会去遍历整个链表,相对来说没有头插法速度快

    我们同样还是有这个的一个链表

    从尾部插入

    此时 e 元素也是个很有想法的,它也想插入到这个链表当中,它想,既然从头部插入有问题,那我就直接从尾部插入吧

    直接从尾部插入不需要移动

    可以看到当 e 元素通过 d、c、b 这三个元素找到 a 的时候,直接用 a 元素的指针指向了 e 元素,指完之后 e 元素也就变成了尾部,不需要做任何移动,整个尾插法就完成了

    那我们再来看看HashMap是如何完成尾插这个过程的,注意这里我的 JDK 版本就要换成 1.8 了 ,因为尾插法是 1.8 之后才有的

    我们还是用hash.put(“a”,“张三”)这个例子来分析,当用HashMap第一次put值的时候首先回去初始化一个容量为16的数组,如图

    初始化后数组

    这里由于长度有限我就只画了4个格子

    HashMap会把值封装成 new Node(97,“a”,“张三”) 对象

    在 JDK1.8 的时候,链表的节点就换成了 Node 节点,因此它会把键和值封装成为一个 Node 对象

    我们暂且把这个对象叫做 firstNode,HashMap根据"a"计算出hash值,然后再用hash值与数组的长度进行位与运算得到数组的下标值假设是 7 ,因此fistNode这个对象会放到数组下标为7的位置

    将Node对象放到数组下标为7的这个位置

    放置完之后,如果此时又有一个值put进来了hash.put(“b”,“李四”),HashMap会把值封装成 new Node(97,“b”,“李四”),我们暂且把这个对象叫做lastNode,假如HashMap根据"b"计算出hash值,然后再用hash值与数组的长度进行位与运算得到数组的下标值假设也是 7 的时候,会直接将lastNode对象插入到firstNode的尾部,也就是说firstNode的 变量next 会直接指向lastNode对象

    firstNode直接指向lastNode

    这样形成链表之后,就不需要往下移动了,至此HashMap整个尾插法的过程就完成了

    那了解了整个插入的过程之后,我们再来看看源码中是怎么做的

    HashMap遍历链表插入

    这里直接用了一个循环操作去遍历整个链表,然后获取到最后一个元素并且最后一个元素的 next 为null之后,通过这段代码就知道 p.next = newNode(hash, key, value, null); 直接把最后一个元素的 next 指向新加进来的元素,完成尾插操作

    可以看到其实尾插的过程也很简单,也只是一个赋值的操作,但是如果链表过长,你想想,要是这样循环去找到最后一个元素,然后再插入新加进来的元素,这个过程过程相对来说是比较慢的

    于是 JDK1.8 为了优化尾插法带来的效率问题,直接引入了 红黑树 ,这就是为什么 JDK1.8 的HashMap数据结构变成了 数组+链表+红黑树

    不仅仅是插入的问题,我们在查询的时候,如果链表过长,也是比较影响查询速度的,因为要去循环遍历去查找

    当HashMap的链表达到一定的长度 8之后,就会直接转换成红黑树

    红黑树转换的阈值

    我们看完HashMap的底层数据结构的后,HashMap还有一个很重要的东西,那就是扩容,那这个扩容又是怎么一回事呢,我们继续往下看

    扩容

    HashMap的扩容阈值值根据数组的长度和负载因子计算出来的 数组的长度 * 0.75,当超过这个阈值之后就会自动进行扩容

    那在扩容的时候这里就涉及到一个很重要的方法 resize ,扩容都是通过这个resize方法实现的

    扩容方法

    在扩容的时候,数组里只有一个元素,也就是说不存在Hash冲突的时候,那在扩容的时候会直接将该元素copy至新数组的对应的位置

    不存在冲突时直接copy到新数组对应的位置

    哈希桶数组中某个位置的节点为树节点时,则执行红黑树的扩容操作

    红黑树扩容

    哈希桶数组中某个位置的节点为普通节点时,则执行链表扩容操作,在JDK1.8中,为了避免之前版本中并发扩容所导致的死链问题,引入了高低位链表辅助进行扩容操作

    HashMap中的细节

    细节一:

    我们可以先看 JDK1.7 向数组添加值的方法

    JDK1.7添加元素的方法

    可以看到在真正在向数组添加元素的时候,会先去判断一下数组是否大于 threshold 这个值,如果大于的话就会调用 resize方法 进行扩容,而且是进行两倍的扩容,如果不需要的话就会直接调用 createEntry方法 添加元素

    所以在 JDK1.7 的时候,HashMap会先去判断是否需要进行扩容,然后再添加元素

    我们再来看看 JDK1.8 的HashMap

    JDK1.8添加元素的方法

    我们可以在 putVal方法 里面看到是在添加完元素之后,才去判断是否大于 threshold 这个值,如果大于会直接调用 resize方法 ,如果不大于则直接返回

    所以在 JDK1.8 的时候,HashMap会先添加完元素之后,再去判断是否需要进行扩容

    这个细节虽然也没啥,但是如果有些比较刁钻的面试官一旦问到了这个细节,你说你在面试官面前说看过源码,很厉害,那不就尴尬了吗,哈哈哈。。。

    细节二:

    我们上面讲过HashCode的计算通过键的HashCode通过两次位与和一次异或,这样其实是为了让HashCode更散列

    我们可以再来看看其中的源码

    JDK1.8 hash值的计算

    这里 JDK1.7 的hash值计算跟 JDK1.8 的差不多,这里我们就以 JDK1.8 为例

    那为什么说进行了位移、异或就是为了让Hash值更散列呢

    我们先来看看假如不进行位移、异或会怎样

    我们还是以hash.put(“a”,“张三”)为例,假如 a 的 HashCode的二进制是 1000010001110001000001111000000 ,我们的这个HashMap的长度为 16 ,那么在它计算数组的索引时会这样去算 (16 - 1 ) & a.hashCode ** ,可以看到其实真正参与进来运算的长度是15,那对应二进制计算是这样的

    此时又添加了一个hash.put(“b”,“李四”),假如b的hashCode是 0111011100111000101000010100000 ,那对应的二进制计算是这样的

    可以看到添加进去的两个值计算得出的索引位置都是 0 ,那么这就直接导致了hash冲突

    你想想,如果计算出的数组索引位置因为hash冲突都集中在某个位置上,那必然会造成链表的长度过长,最终会导致查询效率慢

    假如上面的两个hashCode进行了位移、异或,那结果就不一样了

    位移异或再与长度进行位与

    所以HashMap就是通过这种位移异或操作打乱真正参与运算的低 16 位,从而避免了上面的情况发生

    细节三:

    我们知道HashMap在达到扩容阈值的时候会在原来的基础上扩容一倍,也就是说原来长度是16,那么扩容后就是 32 了,我不知道你们有没有发现这一点,就是扩容后的长度都是 2的n次幂,为什么会是这样呢

    我们先来看看不是2的n次幂会是什么样子

    假如有个HashMap的长度是 33 ,那此时计算索引位置的时候就会用32去计算,32 的二进制是 0010 0000,那么在计算的它的低四位都是 0 ,也就是说无论hashCode怎么变,最后算出来的索引值,它的低四位都是 0 ,这就会导致数组中的某个位置永远都是空的,因为由于低四位都是空,所以计算不到那个索引的位置

    而如果长度是2的n次幂,假如长度是16,那么参与运算的长度就是 (16 - 1),就是15了,它的二进制位 0000 1111,可以看到低四位都是 1 ,那么当hashCode发生了变化的时候,数组中的索引位置都有可能被计算得到

    其实还是为了能够让节点在数组中能够更散列,更有可能的放到数组中的每个位置,从而达到均匀分布

    细节四:

    我们都知道HashMap的扩容因子是0.75 ,那为什么是 0.75 呢

    加载因子过高,虽然提高了空间的利用率,但增加了查询时间的成本;加载因子过低,虽然减少查询时间的成本,但是空间利用率又很低了,所以0.75是一个折中的选择,这也是符合泊松分布

    符合泊松分布,是一种统计与概率学里常见到的离散概率分布

    还有一点不知道有没有发现,在进行 & 计算索引位置的时候,会直接用数组的长度 - 1 然后再和hashCode进行 & 的操作

    为什么要去还要减1呢,这是因为数组的索引下标是从0开始的,所以减1也是为了避免索引越界

    总结

    HashMap在JDK1.7和JDK1.8的改变非常大

    在JDK1.7:

    1、底层的数据结构是数组+链表,在添加元素前会判断是否需要进行扩容,链表插入的方法是头插法

    2、在多线程环境下,扩容的时候会造成死链

    在JDK1.8:

    1、底层数数据结构是数组+链表+红黑树,在添加元素后会判断是否需要进行扩容,链表插入的方式也改成了尾插法

    2、在扩容的过程中采用了高低位拉链,避免了死链问题

    HashMap为了更好的减少hash冲突,通过位移异或等操作来打乱参与运算的低位

    好了本期文章就到这里了,如果喜欢的话,还请留言点赞哦

    展开全文
  • HashMap的resize都

    2016-10-06 11:10:40
    HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素;当然java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的...

    什么是resize?
    resize就是重新计算容量;向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素;当然java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组;就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。

    1. 何时resize,下面是addEntry()方法的代码片段


    if (size++ >= threshold)
                resize(2 * table.length);


    map里的元素个数(size)大于一个阈值(threshold)时,map将自动扩容,容量扩大到原来的2倍; 
    阈值(threshold)是怎么计算的?如下源码: 

    threshold = (int)(capacity * loadFactor);

    阈值 = 容量 X 负载因子;容量默认为16,负载因子(loadFactor)默认是0.75; map扩容后,要重新计算阈值;当元素个数大于新的阈值时,map再自动扩容; 
    以默认值为例,阈值=16*0.75=12,当元素个数大于12时就要扩容;那剩下的4(如果内部形成了Entry链则大于4)个数组位置还没有放置对象就要扩容,岂不是浪费空间了? 
    这是时间和空间的折中考虑;loadFactor过大时,map内的数组使用率高了,内部极有可能形成Entry链,影响查找速度;loadFactor过小时,map内的数组使用率旧低,不过内部不会生成Entry链,或者生成的Entry链很短,由此提高了查找速度,不过会占用更多的内存;所以可以根据实际硬件环境和程序的运行状态来调节loadFactor; 

    2. 如何做resize?我们看一看resize()源码: 

    void resize(int newCapacity) {   //传入新的容量
        Entry[] oldTable = table;    //引用扩容前的Entry数组
        int oldCapacity = oldTable.length;         
        if (oldCapacity == MAXIMUM_CAPACITY) {  //扩容前的数组大小如果已经达到最大(2^30)了
            threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
            return;
        }
    
        Entry[] newTable = new Entry[newCapacity];  //初始化一个新的Entry数组
        transfer(newTable);                         //!!将数据转移到新的Entry数组里
        table = newTable;                           //HashMap的table属性引用新的Entry数组
        threshold = (int)(newCapacity * loadFactor);//修改阈值
    }


    这里就是使用一个容量更大的数组来代替已有的容量小的数组;transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里; 

    3. transfer()偷偷干了些什么? 
    如果把旧的Entry链放到新数组的对应位置上,简单明了,但是这样操作对吗?? 
    看一看transfer()源码: 

    void transfer(Entry[] newTable) {
        Entry[] src = table;                   //src引用了旧的Entry数组
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
            Entry<K,V> e = src[j];             //取得旧Entry数组的每个元素
            if (e != null) {
                src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
                    e.next = newTable[i]; //标记[1]
                    newTable[i] = e;      //将元素放在数组上
                    e = next;             //访问下一个Entry链上的元素
                } while (e != null);
            }
        }
    }


    注释标记[1]处,将newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话); 
    indexFor()是计算每个元素在数组中的位置,源码:

    static int indexFor(int h, int length) {
        return h & (length-1); //位AND计算
    }


    这样,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上; 
    例如,旧数组容量为16,对象A的hash值是4,对象B的hash值是20,对象C的hash值是36; 
    通过indexFor()计算后,A、B、C对应的数组索引位置分别为4,4,4; 说明这3个对象在数组的同一位置上,形成了Entry链; 
    旧数组扩容后容量为16*2,重新计算对象所在的位置索引,A、B、C对应的数组索引位置分别为4,20,4; B对象已经被放到别处了; 

    总结:resize时,HashMap使用新数组代替旧数组,对原有的元素根据hash值重新就算索引位置,重新安放所有对象;resize是耗时的操作。
    展开全文
  • HashMap

    2019-09-04 14:52:25
    加载因子是嘛用的?为什么每次扩容是2的n次方? HashMap扩容过程 为什么要把链表转换为红黑树? HashMap为什么线程不安全? ConcurrentHashMap怎么保证线程安全的,jdk1.8做了哪些优化? HashMap数据结构?怎么...

    HashMap是java中最重要的数据结构之一,下面我们就基于jdk1.8来聊聊HashMap

    HashMap数据结构?怎么put数据的?加载因子是干嘛用的?为什么每次扩容是2的n次方?

    • hashmap底层是数组+链表的数据结构
    • hashmap默认初始长度为16,map每次put数据的时候,会根据key的hash&(length-1),然后定位到数组的位置,紧接着判断当前位置是否有数据,没有则直接放在这个位置即可,如果有则判断当前数据是链表,还是红黑树,如果是链表,则遍历链表上的每条数据,判断当前位置的key和要插入数据的key的hash是否相等,是否equals为true,相等的话则表示为一个同一个数据,直接覆盖之前的value,如果遍历完所有数据都不等,则把数据插入到链表的末尾。当链表的长度大于8个,且hashmap的长度大于64,则会把链表转换为红黑树,如果hashmap的长度小于64,hashmap就会进行扩容操作,把链表上的数据重新hash到新数组上。
    • hash&(length-1)为什么要这样设计呢?length-1就是15,15的二进制表示就是1111,&表示与运算,如果两个数都为1则为1,否则为0。这样设计的好处就是当key的hash值从0000-1111之间&1111得到结果也是0000-1111之间,也即相与之后,数据可能存放在数组的每个位置上,这样就尽可能的避免了数据倾向,避免了数据都放在一个数组位置,形成链表,降低查询效率。
    展开全文
  • 虽然一直以来都经常的使用HashMap,但是却一直没有看过源码,可能是没有意识到阅读源码的好处,经过分析,发现阅读源码让自己对集合有了更加深刻的了解,因此会一直将这个系列进行下去,这次要说的是HashMap。...

    似乎所有的java面试或者考察都绕不开hash,准确说是必问集合,问集合必问hash表。

     

    虽然一直以来都经常的使用HashMap,但是却一直没有看过源码,可能是没有意识到阅读源码的好处,经过分析,发现阅读源码让自己对集合有了更加深刻的了解,因此会一直将这个系列进行下去,这次要说的是HashMap。

     

     

     

    HashMap的基本概况

    HashMap是一个Hash表,其数据以键值对的结构进行存储,在遇到冲突的时候会使用链表来进行解决,JDK8以后引入了红黑树的模式,具体会在文中分析。

     

    其次,HashMap是非线程安全的,Key和Value都允许为空,Key重复会覆盖、Value允许重复。

     

    补充一句,在多线程下我们可以使用concurrentHashMap。

     

    HashMap和Hashtable的区别:

    (HashMap和Hashtable)

     

    HashMap定义

    public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable

    HashMap没有什么要说的,直接切入正题,初始化一个HashMap。

     

    初始化

    HashMap map = new HashMap();

    通过这个方法会调用HashMap的无参构造方法。

    //两个常量 向下追踪
    public HashMap() {
      this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }
    
    //无参构造创建对象之后 会有两个常量
    //DEFAULT_INITIAL_CAPACITY 默认初始化容量 16  这里值得借鉴的是位运算
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    //DEFAULT_LOAD_FACTOR 负载因子默认为0.75f 负载因子和扩容有关 后文详谈
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    //最大容量为2的30次方
    static final int MAXIMUM_CAPACITY = 1 << 30;
    
    //以Node<K,V>为元素的数组,长度必须为2的n次幂
    transient Node<K,V>[] table;
    
    //已经储存的Node<key,value>的数量,包括数组中的和链表中的,逻辑长度
    transient int size;
    
    threshold 决定能放入的数据量,一般情况下等于 Capacity * LoadFactor

     

    通过上述代码我们不难发现,HashMap的底层还是数组(注意,数组会在第一次put的时候通过 resize() 函数进行分配),数组的长度为2的N次幂。

     

    在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。

     

    相对来说素数导致冲突的概率要小于合数,Hashtable初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩容后不能保证还是素数)。

     

    HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。

     

    那么Node<K,V>是什么呢?

    //一个静态内部类 其实就是Map中元素的具体存储对象
    static class Node<K,V> implements Map.Entry<K,V> {
            //每个储存元素key的哈希值
            final int hash;
            //这就是key-value
            final K key;
            V value;
            //next 追加的时候使用,标记链表的下一个node地址
            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;
            }

    此时我们就拥有了一个空的HashMap,下面我们看一下put。

     

    put

    JDK8 HashMap put的基本思路:

    1. 对key的hashCode()进行hash后计算数组下标index;

    2. 如果当前数组table为null,进行resize()初始化;

    3. 如果没碰撞直接放到对应下标的位置上;

    4. 如果碰撞了,且节点已经存在,就替换掉 value;

    5. 如果碰撞后发现为树结构,挂载到树上。

    6. 如果碰撞后为链表,添加到链表尾,并判断链表如果过长(大于等于TREEIFY_THRESHOLD,默认8),就把链表转换成树结构;

    7. 数据 put 后,如果数据量超过threshold,就要resize。

     

    public V put(K key, V value) {
      //调用putVal方法 在此之前会对key做hash处理
      return putVal(hash(key), key, value, false, true);
    }
    //hash
    static final int hash(Object key) {
      int h;
     // h = key.hashCode() 为第一步 取hashCode值
     // h ^ (h >>> 16)  为第二步 高位参与运算
      //具体的算法就不解释了 作用就是性能更加优良
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
    //进行添加操作
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
      Node<K,V>[] tab; Node<K,V> p; int n, i;
      //如果当前数组table为null,进行resize()初始化
      if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
      //(n - 1) & hash 计算出下标 如果该位置为null 说明没有碰撞就赋值到此位置
      if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
      else {
        //反之 说明碰撞了
        Node<K,V> e; K k;
        //判断 key是否存在 如果存在就覆盖原来的value
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
          e = p;
        //没有存在 判断是不是红黑树
        else if (p instanceof TreeNode)
          //红黑树是为了防止哈希表碰撞攻击,当链表链长度为8时,及时转成红黑树,提高map的效率
          e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //都不是 就是链表
        else {
          for (int binCount = 0; ; ++binCount) {
            if ((e = p.next) == null) {
              //将next指向新的节点
              p.next = newNode(hash, key, value, null);
              //这个判断是用来判断是否要转化为红黑树结构
              if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                treeifyBin(tab, hash);
              break;
            }
            // key已经存在直接覆盖value
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
              break;
            p = e;
          }
        }
        if (e != null) { // existing mapping for key
          V oldValue = e.value;
          if (!onlyIfAbsent || oldValue == null)
            e.value = value;
          afterNodeAccess(e);
          return oldValue;
        }
      }
      ++modCount;
      if (++size > threshold)
        resize();
      afterNodeInsertion(evict);
      return null;
    }

     

    在刚才的代码中我们提到了红黑树是为了防止哈希表碰撞攻击,当链表链长度为8时,及时转成红黑树,提高map的效率。那么接下来说一说什么是哈希表碰撞攻击。

     

    现在做web开发RESTful风格的接口相当的普及,因此很多的数据都是通过json来进行传递的,而json数据收到之后会被转为json对象,通常都是哈希表结构的,就是Map。

     

    我们知道理想情况下哈希表插入和查找操作的时间复杂度均为O(1),任何一个数据项可以在一个与哈希表长度无关的时间内计算出一个哈希值(key),从而得到下标。但是难免出现不同的数据被定位到了同一个位置,这就导致了插入和查找操作的时间复杂度不为O(1),这就是哈希碰撞

     

    java的中解决哈希碰撞的思路是单向链表和黑红树,上文提到红黑树是JDK8之后添加,为了防止哈希表碰撞攻击,为什么?

     

    不知道你有没有设想过这样一种场景,添加的所有数据都碰撞在一起,那么这些数据就会被组织到一个链表中,随着链表越来越长,哈希表会退化为单链表。哈希表碰撞攻击就是通过精心构造数据,使得所有数据全部碰撞,人为将哈希表变成一个退化的单链表,此时哈希表各种操作的时间均提升了一个数量级,因此会消耗大量CPU资源,导致系统无法快速响应请求,从而达到拒绝服务攻击(DoS)的目的。

     

    我们需要注意的是红黑树实际上并不能解决哈希表攻击问题,只是减轻影响,防护该种攻击还需要其他的手段,譬如控制POST数据的数量。

     

    扩容

    不管是list还是map,都会遇到容量不足需要扩容的时候,但是不同于list,HashMap的扩容设计的非常巧妙,首先在上文提到过数组的长度为2的N次方,也就是说初始为16,扩容一次为32...


    好处呢?扩容是性能优化和减少碰撞,就是体现在此处。

     

    数组下标计算:index = (table.length - 1) & hash ,由于 table.length 也就是capacity 肯定是2的N次方,使用 & 位运算意味着只是多了最高位,这样就不用重新计算 index,元素要么在原位置,要么在原位置+ oldCapacity.

     

    如果增加的高位为0,resize 后 index 不变;高位为1在原位置+ oldCapacity。resize 的过程中原来碰撞的节点有一部分会被分开。

     

    扩容简单说有两步:

    1. 扩容

    创建一个新的Entry空数组,长度是原数组的2倍。

     

    2. ReHash

    遍历原Entry数组,把所有的Entry重新Hash到新数组。

    //HashMap的源码真的长  0.0  这段改天补上
    final Node<K,V>[] resize() {
      Node<K,V>[] oldTab = table;
      int oldCap = (oldTab == null) ? 0 : oldTab.length;
      int oldThr = threshold;
      int newCap, newThr = 0;
      
      if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
          threshold = Integer.MAX_VALUE;
          return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
          newThr = oldThr << 1; // double threshold
      }
      else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
      else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
      }
      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;
      if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
          Node<K,V> e;
          if ((e = oldTab[j]) != null) {
            oldTab[j] = null;
            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 { // preserve order
              Node<K,V> loHead = null, loTail = null;
              Node<K,V> hiHead = null, hiTail = null;
              Node<K,V> next;
              do {
                next = e.next;
                if ((e.hash & oldCap) == 0) {
                  if (loTail == null)
                    loHead = e;
                  else
                    loTail.next = e;
                  loTail = e;
                }
                else {
                  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;
              }
            }
          }
        }
      }
      return newTab;
    }

     

    为什么HashMap是线程不安全的?

    由于源码过长,HashMap的其他方法就不写了。下面说一下关于HashMap的一些问题。

     

    1. 如果多个线程同时使用put方法添加元素会丢失元素

    假设正好存在两个put的key发生了碰撞,那么根据HashMap的实现,这两个key会添加到数组的同一个位置,这样最终就会发生其中一个线程的put的数据被覆盖。

     

    2.多线程同时扩容会造成死循环

    多线程同时检查到扩容,并且执行扩容操作,在进行rehash的时候会造成闭环链表,从而在get该位置元素的时候,程序将会进入死循环

     

    如何让HashMap实现线程安全?

     

    1. 直接使用Hashtable

    2. Collections.synchronizeMap方法

    3. 使用ConcurrentHashMap 下篇文章就是分析ConcurrentHashMap是如何实现线程安全的

     

    总结

    1. HashMap 在第一次 put 时初始化,类似 ArrayList 在第一次 add 时分配空间。

    2. HashMap 的 bucket 数组大小一定是2的n次方。

    3. HashMap 在 put 的元素数量大于 Capacity * LoadFactor(默认16 * 0.75) 之后会进行扩容。

    4. 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。

    5. JDK8处于提升性能的考虑,在哈希碰撞的链表长度达到TREEIFY_THRESHOLD(默认8)后,会把该链表转变成树结构。

    6. JDK8在 resize 的时候,通过巧妙的设计,减少了 rehash 的性能消耗。

    7. 扩容是一个特别耗性能的操作,所以当在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。

    展开全文
  • 【干货】HashMap解析篇

    2021-08-14 15:32:18
    HashMap知识点易懂梳理map底层解析map常用apimap常用遍历方式 map底层解析 底层:数组+链表+红黑树的结构 ①底层结构:HashMap的主干是一个Entry数组(即map是个数组)。Entry是HashMap的基本组成单元,每一个Entry...
  • HashMap是Java中常用的集合,而且HashMap的一些思想,对于我们平时解决业务上的一些问题,在思路上有帮助,基于此,本文将分析HashMap底层设计思想,并手写一个迷你版的Ha...
  • 点击关注公众号,Java干货及时送达作者:废物大师兄来源:www.cnblogs.com/cjsblog/p/8207211.htmlJDK1.8中的HashMap实现跟JDK1.7中的实...
  • Hashmap面试干货!!!
  • HashMap是面试必问的知识点之一,也是java开发最常用的一种数据模型,HashMap属于复合结构,以key-value形式存储数据,其中key是不允许重复的但是允许为空,value是可以重复或为空的,在jdk1.8前,它的结构为数组+...
  • HashMap分析

    2021-02-04 17:10:46
    public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; //负载因子大小 默认为0.75 } /** 构造方法 2 */ public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } /** 构造...
  • HashMap的工作原理是近年来常见的Java面试题。几乎每个Java程序员都知道HashMap,都知道哪里要用HashMap,知道Hashtable和HashMap之间的区别,那么为何这道面试题如此特殊呢?是因为这道题考察的深度很深。这题经常...
  • 史上最详细的 JDK 1.8 HashMap 源码解析

    万次阅读 多人点赞 2018-01-07 18:00:41
    可能是史上最详细的 HashMap 源码解析。
  • hashMap拓展

    2020-05-12 09:40:02
    HashMap我们谈点不一样的 课程目的 早年间,HashMap是面试场上必问的面试题,如今学生出去面试也会经常碰见。为了彰显个人学习的深度,以及领悟力。我们必须在面试的过程中,说点不一样的东西,像什么数组+链表的...
  • 面试阿里,HashMap 这一篇就够了

    万次阅读 多人点赞 2020-05-25 09:19:32
    HashMap 面试中可能问到的知识点,这边全都有
  • HashMap详细解析

    2020-09-26 13:57:08
    Hashmap是用来嘛的? Hashmap的结构是怎么样的? 我们就基于这几个方面来讲一下。 一、什么是hashmapHashMap是Java程序员使用频率最高的用于映射(键值对)处理的数据类型。随着JDK(Java Developmet Kit)版本的...
  • Java HashMap

    2019-11-24 19:45:23
    一.哈希表 哈希表即散列表(Hash table),是根据关键码值(Key value)而直接进行访问的数据结构。...我们这里所说的HashMap是采用链地址法的方式,即数组+链表的形式,更具体一点是数组+链表/红黑树的形式,对于...
  • HashMap原理

    2019-04-12 14:47:31
    1.HashMap嘛的?存储键值对——存储数据 计算机如何存储数据? 存储方式/结构(数据结构) HashMap底层数据结构搞清楚,应该可以看懂源码 数据结构 数组,链表,树形 问题:不能对常见的数据结构对号入座 数组--...
  • HashMap相关

    2020-05-22 10:04:54
    一、HashMap的实现原理? 1.你看过HashMap源码+原理 针对这个问题,嗯,当然是必须看过HashMap源码。至于原理,下面那张图很清楚了: HashMap采用Entry数组来存储key-value对,每一个键值对组成了一个Entry实体,...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 36,199
精华内容 14,479
关键字:

hashmap干啥的