其实此时面试官说出这个问题,是一个泛泛的问题,没有具体的表示他想知道有关于HashMap的什么内容。
那么我们其实可以这样回答。
就直接把有关于HashMap的这个数据结构的特性直接往出说。
就是我们在使用HashMap的时候,
使用这个类中的方法,其实就是为了提高时间效率,这个类的增删查改的时间复杂度为O(1).
因为HashMap中的put()-----添加元素,初始化一个这个类对应的对象的时候,就是hashMap
map = new HashMap<>(), 这里的K表示的是是一个key(键),V表示的是key对应的Value值。就是我们可以使用这个key找到对应的value。使用get(K) ---- 找到K对应的元素,就可以找到K对应的Value值。 在jdk1.7 中 使用put()方法存储对象的时候,采用的是数组+链表
在jdk1.8中 使用put()方法存储对象的时候,采用的是数组+链表 + 红黑树
那么我们现在就从HashMap中的源码看起!!!
我们现在从这个简单的一个实例说起。HashMap
按住Ctrl,然后鼠标点击new 后面的这个HashMap进入HashMap.java文件----HashMap的源码。在研究源码的时候,博主主要说重要的部分。
我们此时可以看到这个HashMap类继承自 AbstractMap类
实现了Map
中的抽象方法。但是我们可以通过AbstractMap
的源码中看到其实这个类也实现了MapAbstractMap类
就是一个多余的。可以把它去掉。但是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
: 表示的是要 创建一个Node类型的数组 。那么这个Node类型中有哪些属性呢?
在一个Node类型的节点中包括:这个节点对应的hash码,key – 表示添加到map中的键,Value—表示key对应的值,next表示的是在发生hash冲突的时候,在这个数组对应的位置的这个节点后在添加一个节点,那么此时的这个next这个后继指针指向这个节点。在jdk1.8中在链表中插入节点的时候使用的是尾插法,在jdk1.7中使用的是头插法。
我们还可以在这个Node类中看到它实现了Map.Entry
那么这个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数组中的节点中的key值是相同的节点。也就是我之前添加的是map.put(“张三”,“123”),我现在有添加一个map.put(“张三”,“456”).那么此时在hash数组中我们是如何把这个节点添加的?
还是康康我们的源码,写源码的大佬是永远的神,写的源码几乎没有一句是废话的,如果你把hashMap的源码读通了,你就会觉得自己是多么的渺小,大佬还是依然的大佬。
其实这种情况就是有节点中的key和以前在hash数组中添加的节点中的key值是相同的,那么它们两个计算出来的hash码也是一样的,那么在使用hash& (n - 1)得到的在数组中的位置也是一样的。那么此时这个新的节点中的value值就会把以前的这个位置上相同的key值的节点中的value替代。但是在返回的时候,返回的是以前的value.
图例:
那么如果在hash数组中添加节点的时候,如果我们此时的key值计算之后,得到的hash码,然后在根据hash码得到的这个节点要添加到的hash数组的位置下标,如果这个下标的位置已经有节点把这个位置给占了。那么此时就是所谓的hash冲突或者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数组中添加节点的类型。数组中的每个下标中链表的节点个数大于8
,并且
hash数组的长度大于64
一定要记着还有一个条件就是hash数组的长度大于64,要不然在面试和时候,面试官提问题这个问题,你说个链表中节点的数目大于8,这就正好调到了面试官的圈套了,还是让我们看看HashMap的源码吧!!!
当hash数组中的某个下标位置下的链表的个点个数8,并且hash数组的长度大于64 ,此时就进行树化,但是在调用treeifyBin()这个方法的时候,并没有直接进行树化,而是先把下标位置的单向链表变成双向链表。
通俗来讲,当负载因子为1.0时,
意味着只有当hashMap装满之后才会进行扩容,虽然空间利用率有大的提升,但是这就会导致大量的hash冲突
,使得查询效率变低 此时你就要想一想阈值是不是也增大了,此时的阈值 = 16 * 1.0,那么就是hash数组被占满之后,才扩容,会有大量的hash冲突。当负载因子为0.5或者更低的时候,hash冲突降低,查询效率提高,但是由于负载因子太低,导致原来只需要1M的空间存储信息,现在用了2M的空间。最终结果就是空间利用率太低。
负载因子是0.75的时候,这是时间和空间的权衡,空间利用率比较高,而且避免了相当多的Hash冲突 ,使得底层的链表或者是红黑树的高度也比较低,提升了空间效率。
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碰撞!
首先这里的 计算节点在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冲突
那么就是HashMap
其实当我们创建实例的时候,调用了一个无参的构造方法,在这个构造方法中,只是设置了当前的负载因子
为 DEFAULT_LOAD_FACTOR
默认的负载因子为0.75.那就没有在实例化一个map的时候,创建出一个hash数组。
谈到HashMap扩容还是要说说我们的源码滴。
看源码!!!源码是一个非常适合学习的东西,倘若你把一个封装类的源码读懂了,你将会有极大的成就感。
我们使用一个简答的例子,就例如说此时的hash数组的长度为默认的16,那么此时的扩容阈值也自然是16 * 0.75 = 12,当实际在数组中用到的空间位置大于12的时候,就会进行扩容,在HashMap扩容的时候,我们可以通过上述的源码得知,它生成了一个长度为 2 * oldCap的新的hash数组(newTab),我们就要把旧的在oldTab中的节点,迁移到newTab中.在HashMap中实现的是2倍扩容,newTab.length = 32 newThr = 24
那么在迁移的过程中就有3种情况
当此时oldTab中的这个下标下的节点只有一个 即 e.next == null
此时oldTab中的这个下标下的节点大于1个但是没有大于8个,就形成了一个链表
此时的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中的两个下标位置。
那么与这里的高四位二进制 和 低四位二进制 有什么关系呢?
此时的数组长度就为 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(链表的头节点)
其实当我们要把oldTab中的每一个下标位置上的节点,迁移到newTab的时候,因为我们已经对数组扩容了,那么就能容纳更多的节点,也就有更多的链表,如果我们能把以前在oldTab中的节点组成的链表,在迁移过程中改成数组位置只有一个节点(其实这样是可能不存在的,当一个下标位置中的链表节点数大的时候)。在迁移的时候,我们把一个链表分成了两份。这样就有效的降低了hash冲突。
那么此时就要到了如果在oldThr中的某个下标的位置下,已经把链表树化。那我们该怎样扩容?
因为此时的在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 就会退化成链表。
这个高四位的判定和低四位的判定是一样的,这里就不多说了。
简单的说就是在迁移红黑树的时候,遍历红黑树构成两个链表------高四位链表,低四位链表。并且记录每个链表的节点个数。在连接newTab的时候,判断此时的两个链表的个数,如果个数小于等于6 那么红黑树退化成链表,否则还是会形成红黑树。