【HashMap】1w字解析HashMap底层部分源码


今天博主带着大家来康康我们平常面试经常被提到的HashMap,博主今天会带着老铁们仔仔细细的阅读一样hashMap的底层代码。同时说一说平常面试中考到的有关于hashmap的题解。

1.谈一下你对HashMap的理解?

其实此时面试官说出这个问题,是一个泛泛的问题,没有具体的表示他想知道有关于HashMap的什么内容。

那么我们其实可以这样回答。

就直接把有关于HashMap的这个数据结构的特性直接往出说。

就是我们在使用HashMap的时候,使用这个类中的方法,其实就是为了提高时间效率,这个类的增删查改的时间复杂度为O(1).

因为HashMap中的put()-----添加元素,初始化一个这个类对应的对象的时候,就是hashMap<K,V> map = new HashMap<>(), 这里的K表示的是是一个key(键),V表示的是key对应的Value值。就是我们可以使用这个key找到对应的value。使用get(K) ---- 找到K对应的元素,就可以找到K对应的Value值。

在jdk1.7 中 使用put()方法存储对象的时候,采用的是数组+链表

在jdk1.8中 使用put()方法存储对象的时候,采用的是数组+链表 + 红黑树

2. 说一下HashMap的put()方法是怎样实现的?

那么我们现在就从HashMap中的源码看起!!!

我们现在从这个简单的一个实例说起。HashMap<T,T> map = new HashMap<>() 按住Ctrl,然后鼠标点击new 后面的这个HashMap进入HashMap.java文件----HashMap的源码。在研究源码的时候,博主主要说重要的部分。
在这里插入图片描述
我们此时可以看到这个HashMap类继承自 AbstractMap类 实现了Map<K,V>, Cloneable, Serializable类中的抽象方法。但是我们可以通过AbstractMap<K,V>的源码中看到其实这个类也实现了Map<K,V>接口。其实HashMap类继承这个 AbstractMap类 就是一个多余的。可以把它去掉。但是HashMap源码官方也知道这个继承是多余的,但是人家就是不改,你能把我咋滴😂😂
在这里插入图片描述
在这里插入图片描述

起先我们这里先定义了一些要在put()等方法中要是用到的常量。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

DEFAULT_INITIAL_CAPACITY:表示的是默认初始容量为16

static final int MAXIMUM_CAPACITY = 1 << 30;

MAXIMUM_CAPACITY:表示的是容量的最大值为 1 << 30 1向左移动30位,这是一个很大的数字

static final float DEFAULT_LOAD_FACTOR = 0.75f;

DEFAULT_LOAD_FACTOR:表示的是默认的负载因子是0.75

static final int TREEIFY_THRESHOLD = 8;

TREEIFY_THRESHOLD:表示的是满足链表转红黑树的原由之一,就是当链表链表的长度大于8的时候,链表就会发生树化,转变成为红黑树。

static final int UNTREEIFY_THRESHOLD = 6;

UNTREEIFY_THRESHOLD:表示的是由红黑树转变成为链表的节点数临界值,当红黑树中的节点小于6个的时候,此时就会有红黑树转变成为链表。

static final int MIN_TREEIFY_CAPACITY = 64;

MIN_TREEIFY_CAPACITY:直译为最小树化容量,表示的是当hash数组的长度小于64 的时候,就实现hash数组扩容,当hash数组的长度大于64的时候,就把hash数组中的每个位置中的链表变成红黑树。
在这里插入图片描述

size:表示的是此时有几个key - value键值对在map中。

modCount:表示的是在hash数组中一个下标位置中一个链表挂了多少个Node类型的节点

threshold:表示的是hash数组中的阈值,如果在hash数组中要存储的节点要占用大于threshold大位置,那么此时就要对数组扩容。

loadFactor: 表示的是负载因子

Node<K,V>[]table: 表示的是要 创建一个Node类型的数组 。那么这个Node类型中有哪些属性呢?
在这里插入图片描述

在一个Node类型的节点中包括:这个节点对应的hash码,key – 表示添加到map中的键Value—表示key对应的值next表示的是在发生hash冲突的时候,在这个数组对应的位置的这个节点后在添加一个节点,那么此时的这个next这个后继指针指向这个节点。在jdk1.8中在链表中插入节点的时候使用的是尾插法,在jdk1.7中使用的是头插法。

我们还可以在这个Node类中看到它实现了Map.Entry<K,V>这个接口。

那么这个hash码是什么呢?其实这是使用这个节点中的key然后在调用hashCode()方法,再异或上一个扰动函数(h >>> 16 使用计算出来的 h 无符号向右移动16位)
在这里插入图片描述

在hashMap中,允许key为null,如果此时的key为null,那么就直接返回一个0

那么我们已经使用hashCode方法计算出来了一个hash码,为什么还要异或上一个扰动函数呢?

其实就是进行的二次散列,就是在一定程度上的解决hash冲突。因为进行一次散列之后,有可能有好多的节点中的hash码是一样的,如果hash码是一样的,那么再经过一个计算这个节点在hash数组中的位置的一个算式其实这个算式就是(hash & n - 1)这里的 n 表示的是此时的hash数组长度。那么此时在不扩容的情况下,节点的位置在哪,就完全是由hash码来决定的,如果这些节点中的大部分hash码都是相同的,那么在添加到hash数组中的时候,就会出现很大程度上的hash冲突。

在这里插入图片描述

在这个HashMap的有参构造方法中 initialCapacity :表示的是用户传来的初始hash容量,loadFactor:表示的是用户传来的负载因子。我们发现在确定hash数组长度的时候有一个tableSizeFor()方法。

在这个方法中 if(initialCapacity < 0)表示的是如果此时的初始容量小于0,那么就会抛出异常,还有就是if(initialCapacity > MAXIMUM_CAPACITY)表示的是如果初始容量大于最大容量,就让把最大容量赋予这个初始容量。还有if(loadFactor <= 0 || Float.isNaN(loadFactor)):表示的是如果负载因子小于等于0 或者 NaN(Not a Number,非数)是计算机科学中数值数据类型的一类值,表示未定义或不可表示的值。也就是说如果此时的loadFactor值一个未被定义的值。那么就抛出异常。

其实上面的这些if语句,如果在输入符合规则的话就不会被执行。当然这也体现出了Java代码的健壮性.
在这里插入图片描述

这个方法的主要功能就是:把用户传来的初始容量经过一系列的位运算得到一个2 ^ n的值,就是距离这个cap,最近的2 ^ n。那么为什么要创建一个 2 ^ n长度的hash数组呢? 我们在后面具体说明。
在这里插入图片描述

在这个有一个参数的HashMap(int initialCapacity)构造方法中,就是用户传进来一个默认初始容量,

然后再调用this这里的this再调用有两个参数的构造方法也就是 public HashMap(int initialCapacity,float loadFactor)这个方法。还是操作一样的程序。

这个无参的构造方法也是一样的,就是设置了它的负载因子,然后在进行public HashMap(int initialCapacity,float loadFactor)这个方法。

那么我们接下来就正式的看看关于HashMap的put()方法
在这里插入图片描述

我们在调用HashMap的put()方法的时候,也就是在map中存储key-value值,就会向这个put()方法传入我们要存入的key 和 value值。我们在put()方法的源码中看到,返回了一个putVal()的返回值。

在这个putVal()方法中,

hash(key)(使用key来计算hash码)

key — 键

value ---- 值

onlyfAbsent(如果当前位置已存在一个值,是否替换,false是替换,true是不替换) — false

evict(表是否在创建模式,如果为false,则表是在创建模式。) — true

在这里插入图片描述

hash数组中添加节点的第一种类型:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

总结一下在hash数组中添加节点的第一种类型:

总体来说就是

  1. 判断当前的table为不为空,它的长度是不是0,如果是的话就创建出一个新的Tab并且使用resize()方法,得到这个数组的长度,和数组扩容阈值.

  2. 然后在使用传来的hash码,计算出这个节点应该落在hash数组的哪个下标上。

    图例:
    在这里插入图片描述

  3. 再然后一个hash数组中的下标位置中的节点数binCount++,判断此时的size是否大于数组扩容阈值。返回一个null
    在这里插入图片描述

hash数组中添加节点的第二种类型:

那么如果我现在又在hash数组中添加一个和我之前添加到hash数组中的节点中的key值是相同的节点。也就是我之前添加的是map.put(“张三”,“123”),我现在有添加一个map.put(“张三”,“456”).那么此时在hash数组中我们是如何把这个节点添加的?

还是康康我们的源码,写源码的大佬是永远的神,写的源码几乎没有一句是废话的,如果你把hashMap的源码读通了,你就会觉得自己是多么的渺小😂😂,大佬还是依然的大佬。

在这里插入图片描述

总结一下在hash数组中添加节点的第二种类型:

其实这种情况就是有节点中的key和以前在hash数组中添加的节点中的key值是相同的,那么它们两个计算出来的hash码也是一样的,那么在使用hash& (n - 1)得到的在数组中的位置也是一样的。那么此时这个新的节点中的value值就会把以前的这个位置上相同的key值的节点中的value替代。但是在返回的时候,返回的是以前的value.

图例:

在这里插入图片描述
在这里插入图片描述

hash数组中添加节点的第三种类型:

那么如果在hash数组中添加节点的时候,如果我们此时的key值计算之后,得到的hash码,然后在根据hash码得到的这个节点要添加到的hash数组的位置下标,如果这个下标的位置已经有节点把这个位置给占了。那么此时就是所谓的hash冲突或者hash碰撞,那么此时该怎么办呢?

还是接着看看我们的HashMap的底层源码😏😏
在这里插入图片描述

总结一下在hash数组中添加节点的第三种类型:

其实在这个putVal()的源码中的那个死循环就是要找到链表的最后一个节点,最后一个节点,在接上新插入的节点。

如果我们当前在hash数组中添加节点的时候,map.put(“李四”,“123”),此时使用key去计算hash码,在使用hash码经过hash & (n - 1)找到位置,但是此时的这个位置还是被以前的节点给占了,但是这两个节点的key值是不一样的,只是他们两个节点的hash码可能是一样的。但是也有可能是不一样的。就比如说,我现在有一个hash码为17 有一个hash值为21 同时 & (10 - 1)

17: 00000000 00000000 00000000 0001 0001 21: 00000000 00000000 00000000 0001 0101

9: 00000000 00000000 00000000 0000 1001 9: 00000000 00000000 00000000 0000 1001

&

00000000 00000000 00000000 0000 0001 00000000 00000000 00000000 0000 0001

那么我们此时可以看到不同的hash码,可能得到相同的hash数组下标,正如上面 17 & (10 - 1) = 1

21 & (10 - 1) = 1,两个节点应在的hash数组下标为1,也就有了hash冲突,所以说hash冲突时依然存在的,我们只能想办法让产生hash冲突的概率变低。

那么我们在数组中的同一个下标下的位置,我们此时采用的是拉链法,就是把计算相同位置的节点使用链表串起来,在jdk1.8中使用的是尾插法,在jdk1.7中使用的是头插法。

图例:
在这里插入图片描述

hash数组中添加节点的第四种类型:

那么这个就是最后一种在hash数组中添加节点的类型。数组中的每个下标中链表的节点个数大于8并且 hash数组的长度大于64 一定要记着还有一个条件就是hash数组的长度大于64,要不然在面试和时候,面试官提问题这个问题,你说个链表中节点的数目大于8,这就正好调到了面试官的圈套了😮😮,还是让我们看看HashMap的源码吧!!!
在这里插入图片描述

总结一下在hash数组中添加节点的第四种类型:

当hash数组中的某个下标位置下的链表的个点个数8,并且hash数组的长度大于64 ,此时就进行树化,但是在调用treeifyBin()这个方法的时候,并没有直接进行树化,而是先把下标位置的单向链表变成双向链表。

图例:
在这里插入图片描述

3. 为什么hashMap中的负载因子是0.75,为什么不会是0.5,或者1.0呢?

通俗来讲,当负载因子为1.0时,意味着只有当hashMap装满之后才会进行扩容,虽然空间利用率有大的提升,但是这就会导致大量的hash冲突,使得查询效率变低 此时你就要想一想阈值是不是也增大了,此时的阈值 = 16 * 1.0,那么就是hash数组被占满之后,才扩容,会有大量的hash冲突。

当负载因子为0.5或者更低的时候,hash冲突降低,查询效率提高,但是由于负载因子太低,导致原来只需要1M的空间存储信息,现在用了2M的空间。最终结果就是空间利用率太低。
负载因子是0.75的时候,这是时间和空间的权衡空间利用率比较高,而且避免了相当多的Hash冲突 ,使得底层的链表或者是红黑树的高度也比较低,提升了空间效率。

4. 当初始化一个HashMap对象的时候,如果此时在HashMap<>()中传入一个整形参数,比如10,那么此时的hash数组是多长的?在Hash主数组中为什么它的长度必须为2 ^ n?

16
在这里插入图片描述

hash % length == hash & ( length - 1 ) 的前提是length是2的n次方; 为什么这样能均匀分布减少碰撞呢?2的n次方实际就是1后面n个0,2的n次方-1 实际就是n个1;

另外一个剪短的解释,2^n也就是说2的n次方的主要核心原因是hash函数的源码中右移了16位让低位保留高位信息,原本的低位信息不要,那么进行&操作另一个数低位必须全是1,否则没有意义,所以len必须是2 ^n ,这样能实现分布均匀,有效减少hash碰撞!

5. 为什么我们在使用hash码找到这个节点在数组中的位置的时候,使用的是hash & (n - 1) 为什么要 -1?还有就是为什么不使用%操作?

首先这里的 计算节点在hash数组中的位置的式子 hash & (n - 1) 等效于 hash % n。等效的前提是n 必须是2的整数位。 简单的说就是把这些hash码赋到hash数组中的每个下标位置。但是在HashMap源码中不使用 hash % n 是因为 hash % n 不是位运算,hash & (n - 1)位运算,可以很快的计算出结果。其实% 还是使用 除法 和 减法得到的。这里的 % 没有 & 高效。

这里不使用 hash & n 主要是防止hash冲突,防止在hash数组中的位置冲突。

如果说此时的n = 8 hash = 3 此时使用n - 1 hash = 2

hash : 00000000 00000000 00000000 0000 0011 00000000 00000000 00000000 0000 0010

n: 00000000 00000000 00000000 0000 0111 00000000 00000000 00000000 0000 0111

& 00000000 00000000 00000000 0000 0011 00000000 00000000 00000000 0000 0010

如果此时 没有 - 1

hash: 00000000 00000000 00000000 0000 0011 00000000 00000000 00000000 0000 0010

n: 00000000 00000000 00000000 0000 1000 00000000 00000000 00000000 0000 1000

& 00000000 00000000 00000000 0000 0000 00000000 00000000 00000000 0000 0000

那么此时这两个节点都要被放到同一个下标之下的链表中,就产生了hash冲突。所以说使用hash & (n - 1) 可以有效的减少hash冲突

6. 当你想使用HashMap的时候,此时初始化了一个map对象,那么此时的hash数组此时建立了吗?

那么就是HashMap<String,String> map = new HashMap<>() 创建出了一个map实例。至于有没有在实例的时候,创建出了hash数组,还是要看看我们的源码😮😮😮 。
在这里插入图片描述

其实当我们创建实例的时候,调用了一个无参的构造方法,在这个构造方法中,只是设置了当前的负载因子DEFAULT_LOAD_FACTOR 默认的负载因子为0.75.那就没有在实例化一个map的时候,创建出一个hash数组。

7. 说一下HashMap是怎样扩容的?

谈到HashMap扩容还是要说说我们的源码滴。

看源码!!!源码是一个非常适合学习的东西,倘若你把一个封装类的源码读懂了,你将会有极大的成就感。

扩容机制的第一种情况:

在这里插入图片描述

总结一下hash数组扩容的第一种情况:

我们使用一个简答的例子,就例如说此时的hash数组的长度为默认的16,那么此时的扩容阈值也自然是16 * 0.75 = 12,当实际在数组中用到的空间位置大于12的时候,就会进行扩容,在HashMap扩容的时候,我们可以通过上述的源码得知,它生成了一个长度为 2 * oldCap的新的hash数组(newTab),我们就要把旧的在oldTab中的节点,迁移到newTab中.在HashMap中实现的是2倍扩容,newTab.length = 32 newThr = 24

那么在迁移的过程中就有3种情况

  1. 当此时oldTab中的这个下标下的节点只有一个 即 e.next == null

  2. 此时oldTab中的这个下标下的节点大于1个但是没有大于8个,就形成了一个链表

  3. 此时的oldTab中的这个下标位置的节点,组成了红黑树。

第一种情况:

阅读过源码之后,知道在oldTab中的这个下标中的节点只有一个的时候,即oldTab[j].next == null的时候,直接使一个指针指向这个节点e,记住这个节点,然后把oldTab[j] = null,然后根据指针e指向的这个节点hash码,计算这个节点在newTab中的位置。注意此时的计算下标位置的算式为 hash & (newCap - 1) 是新数组的长度 - 1

图例:
在这里插入图片描述

扩容机制的第二种情况:

那么我们已经把在oldTab中的某个下标处的位置只有一个节点时,在hash数组扩容时迁移到newTab中的情况已经说明了。那么在一个下标处由于hash冲突,众多的节点构成了一个链表。那么我们该怎样把oldTab数组中挂的链表迁移到newTab中?在没有看代码之前,有的同学可能会想我可以根据链表的头节点,算出这个链表在newTab中的新的位置。这种说法其实是错误的,因为我们要 尽可能的缩小hash冲突,所以就会把一个长的链表分成两个。分别挂到newTab中的两个下标位置。

look 源码!!!😁😁
在这里插入图片描述

在这里我们简单的说一下什么是高四位,什么是低四位?
在这里插入图片描述

那么与这里的高四位二进制 和 低四位二进制 有什么关系呢?

此时的数组长度就为 32 计算节点在数组中的位置的式子为 hash & (n - 1) n 表示的是数组长度

31: 00000000 00000000 00000000 0001 1111

hash: 00000000 00000000 00000000 0000 1010

&

​ 00000000 00000000 00000000 0000 1010 和原数组中的位置是一样的

31: 00000000 00000000 00000000 0001 1111

hash: 00000000 00000000 00000000 0001 1010

&

​ 00000000 00000000 00000000 0001 1010 此时在新数组中的位置 = 原数组的长度 + 原本在数组中的位置

如果此时hash的高四位中没有和31中的高四位中的位数1对齐,那么此时就算出来的节点位置是和在原数组中的位置是相同的。如果在hash中的高4位有1和31中的高4位中的1对齐,那么此时这个节点的位置就是 原数组的长度 + 在原数组的具体位置的长度。那么我们就把这个下标下的所有节点都进行这样的运算之后,就可以得到上面所说的低四位链表 和 高四位链表。
在这里插入图片描述
在这里插入图片描述

但是有些同学会问,这里的e.hash & oldCap == 0 也是在判断这个hash对应的节点在高四位链表添加还是在低四位链表添加吗😁😁 对滴。

这个也能判断高低四位。
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

在高低位链表添加完节点之后,loTail、hiTail尾指针指向null,newTab连上loHead 、hiHead(链表的头节点)

总结hash数组扩容的第二种情况:

其实当我们要把oldTab中的每一个下标位置上的节点,迁移到newTab的时候,因为我们已经对数组扩容了,那么就能容纳更多的节点,也就有更多的链表,如果我们能把以前在oldTab中的节点组成的链表,在迁移过程中改成数组位置只有一个节点(其实这样是可能不存在的,当一个下标位置中的链表节点数大的时候)。在迁移的时候,我们把一个链表分成了两份。这样就有效的降低了hash冲突。

扩容机制的第三种情况:

那么此时就要到了如果在oldThr中的某个下标的位置下,已经把链表树化。那我们该怎样扩容?

还是一样look at 源码!!! ❤️❤️❤️😄
在这里插入图片描述

因为此时的在hash数组中的一个下标位置中的链表,已经树化,那么所以此时的节点类型就是TreeNode,那么就要对这棵红黑树迁移到新的hash数组中
在这里插入图片描述

在这里插入图片描述
我们可以看到在调用这个split()方法的时候,传入了 map,newTab,节点在oldTab数组中的下标位置,oldCap:原来hash数组的长度。在这里我们还是这里四个指针。分别输高低四位头指针,高低四位尾指针。并且这里的lc,hc使用来记录高低四位链表中的节点个数。
在这里插入图片描述

因为我们这里的TreeNode继承自Node,所以此时的TreeNode具有next属性,所以我们可以使用这个next,来遍历这个红黑中的的每一个节点。这里的bit 指的是 oldCap,那么的if(e.hash & bit == 0) 判断的是遍历到的这个节点,是不是是添加到低四位链表的节点。如果满足这个条件,那么就添加到低四位链表中,并且lc++。

还有这个else 表示的就是要在高四位链表上添加节点,并且hc++。
在这里插入图片描述

判断此时的低四位链表头节点是否为空,如果不为空,那么就向下指向,如果此时的lc(添加到链表中的节点数),如果 <= UNTREEIFY_THRESHOLD 那么就不把这个链表树化,这个树化的阈值为6,如果大于6,那么还是会把链表转化为红黑树
在这里插入图片描述

也就是说红黑树在迁移到newTab的时候,有可能退化成链表。节点的个数 <= 6 就会退化成链表。

这个高四位的判定和低四位的判定是一样的,这里就不多说了。

总结hash数组扩容的第三种情况:

简单的说就是在迁移红黑树的时候,遍历红黑树构成两个链表------高四位链表,低四位链表。并且记录每个链表的节点个数。在连接newTab的时候,判断此时的两个链表的个数,如果个数小于等于6 那么红黑树退化成链表,否则还是会形成红黑树。

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小周学编程~~~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值