-
指定容量在区间内
-
指定容量就是一个2次幂
当指定容量在区间内时,代表这个容量大于左边的二次幂数,小于右边的二次幂数,因此就有这样一个表达式 2 ( n − 1 ) < x < 2 n 2^{(n-1)} < x < 2^n 2(n−1)<x<2n(x代表容量),那么我们对条表达式进行乘2变成 2 n < 2 x < 2 ( n + 1 ) 2^n < 2x < 2^{(n+1)} 2n<2x<2(n+1),所以2倍的容量的最近的小二次幂就是容量的最近的大二次幂
当指定容量就是一个2次幂,也就是不落在上述的区间内,那么就需要进行减1,来让其落在区间内(而且这个减1操作对于本就在区间内的数没有影响),当然,比如2和1,如果指定容量为2,那么减一就变成了1,但这里要注意的是必须要大于1,才会执行这个方法,所以我们不用考虑1的情况
至于Integer的highestOneBit方法的细节,能力不足,看不懂,这里就贴上源码自己看吧
最后就是进行initHashSeedAsNeeded,这个方法是用来使用代替散列才使用的, 不太重要,因为我们一般使用hashMap都不会使用字符串来代替散列
inflateTable方法到此结束
总结一下inflateTable干了什么东西
-
确定容量,容量大小为指定容量的最近的大二次幂
-
修改扩容门限,扩容门限为扩容因子乘上容量
-
如果要使用代替散列,对hashseed变量进行修改
添加Key不为null元素
先贴上代码
我们将其分为两部分
-
第一部分是形成链表时独有的操作
-
第二部分是没有形成链表和形成链表时都有的操作
第一个部分步骤如下
-
先判断要添加的键值对的key是否为null,如果为空,就调用putForNullKey方法
-
如果key不为空,调用hash方法来计算key的hash值
-
调用indexFor来找到key的hash值对应的索引
-
最后根据索引找到位置,判断这个位置是否已经有键值对
-
如果有,代表整个位置形成了之前发生过哈希冲突,形成了链表,就需要遍历这个链表看是否出现相同的键
-
判断相同的键是先根据链表中的键值对的hash值是否与当前要添加的键值对相等,如果不相等,再比较key是不是相同,判断是否是相同的key是根据是不是同一个与equals方法进行综合考虑的
-
如果出现相同的键,那么就记录旧的键值对的value,将旧的键值对的value修改成新的value(也就是键不变,值替换)
-
调用recordAccess方法
-
最后将旧的value返回
第二个部分步骤如下
-
让modCount自增(之前研究ArrayList也有这个变量,是用来记录年代的,也就是这个底层数组的版本变化)
-
最后调用addEntry方法,参数为哈希值,键,值,索引
总体的架构就是如上,现在来看调用的各个方法
hash方法
先来看看hash值是怎么计算的
上面那部分不用管,是替代散列时使用的(使用字符串替代散列值)
步骤如下
-
先调用key自己拥有的hashCode方法
-
最后使用一序列的扰乱函数
所以键值对的hash值生成是跟key有关的,而且细节来说是跟key的hashCode有关(这也就是为什么使用集合来存储对象时,一定要重写hashCode方法,而且hashCode要跟对象的属性值有关,这样就可以将相同属性值的对象视为同一个对象)
接下来我们看看扰乱函数是干什么的,从注释来看
总的来说这个扰乱函数就是用来减少哈希冲突的
indexFor方法
这个方法就是用来根据哈希值寻找到底层主数组的索引的
可以看到这个方法单纯是用哈希值与上了底层数组的最大索引(也就是长度减一)
这里是利用了与运算的特点,前面已经提到过,底层数组的长度一定是2的幂次方,那么减一操作就会让其变成全为1的二进制数,也就确保了底层数组的索引数量化成二进制里面所有数字一定全部都为1
那么此时这个算法就会等效成用哈希值对最大索引进行取余,也就是h % ( length - 1 )
所以索引的计算方法其实就是哈希值对最大索引进行取余操作,保证不会超过底层数组的最大索引
这也是为什么长度必须为2的幂次方
recordAccess
可以看到这个方法什么都没干,可能是为了以后方便拓展
我们再看下注释
注释上提到这个方法是出现重复key的时候都会调用
addEntry
最后我们再看看addEntry的方法
我们可以看到,addEntry方法里面竟然包含了扩容操作即resize方法
步骤如下
-
判断当前HashMap的元素数量是否大于门限
-
如果大于,再进行比较当前要添加键值对的位置是否为空
-
如果也为空,那就会进行扩容操作
-
所以,要扩容必须满足两个条件
-
一个是HashMap的元素数量大于门限值
-
另一个是当前添加键值对的索引位置必须发生冲突
-
下面进行扩容,扩容调用的是resize方法,而且传的参数是当前底层数组长度的两倍
-
扩容了之后,首先进行再次hash,即重新计算当前键值对的key的hash值
-
然后再用新的hash值计算出新的索引位置
-
最后就是调用createEntry方法来正式添加元素
扩容
我们来看看resize是如何进行扩容的
可以看到,扩容操作也是会进行判断
-
先判断旧的容量,也就是当前主数组的长度,是否等于最大容量
-
如果已经等于,就不可以继续扩了
-
然后将扩容门限设为Integer的最大值
-
结束
-
如果主数组的长度,不等于最大长度,那么就代表一定小于最大容量
-
那么就会创建一个新容量的数组(前面已经知道容量为旧容量的两倍)
-
然后调用transfer方法
-
让底层的table数组指向新容量的数组
-
最后就是重置了门限值,让门限值为最大的容量+1和新的容量乘上负载因子的最小值
可以看到,是不是少了就是将旧的数据放在新数组上的步骤,所以,可以推测到,这一步是放在了transfer上去完成的
可以看到,对于旧数组,不是像ArrayList那样单纯的进行复制过去,而是要重新散列的
可以看到,使用了增强for来桥套while循环,来遍历所有元素(增强for遍历数组,while循环来遍历每个项的链表),然后对所有键值对的hash(键值对不仅保存键值,还保存着哈希值和下一个键值对的指向)进行了重新计算,这一步称为rehash,当然,计算的规则依然是使用key来计算的,重新计算了hash当然也要重新计算索引,然后根据重新计算的索引,对应存放在新创建的扩容数组里面
不过这里有一个问题?如果rehash了之后重新装填又发生了哈希冲突会怎样?
根据源码来看,如果重新装填的过程发生了哈希冲突,那么后面的键值对就会替代前面的键值对。
至此,整个扩容过程就结束了
总结一下
-
扩容要先判断旧的容量是否已经最大,最大的话,只是单纯改变了门限值为Integer最大值
-
扩容后的容量为旧的容量的2倍
-
扩容后要将旧的数据重新装填进新的数组里面,重新装填并不是单纯的复制,而是要遍历所有项的链表进行重新hash,重新计算索引值,然后根据新的索引值放在数组里面
-
假如重新装填过程中发生哈希冲突,产生的索引值一样,那么就会进行数据替换,会产生数据丢失
-
最后将底层table指向新扩容后的数组
-
将门限改为新的容量乘上负载因子
createEntry
最后我们来看看createEntry方法
这个方法也就是真正底层的添加操作
-
首先获取底层数组指定索引位置处的旧键值对
-
让后新建一个键值对,键值对存要添加的键值、哈希值和指向下一个的旧键值对
可以知道,底层的插入使用的是一个头插法
补充:当添加的是key为null的元素
前面put方法源码上,是会判断key是否为null的,假如为null就会执行putForNullKey的方法
这样拆开是因为hash值是根据key的hashCode来生成的,如果key为null就不能生成hashCode,所以需要进行拆开
可以判断,putForNullKey的执行跟put方法后面的操作差不多
可以看到for循环里面,遍历的是底层table数组的第一个项,也就是第一个项的链表
那么就可以知道,key为null的键值对都是放置在底层table数组第一个项里面来形成链表的,当然里面还存在key不为null的键值对对象,然后如果发现有key也为null,而且value相等的,就会发生value替换,返回旧的value
可以看到,addEntry里面的bucketIndex变为了0,hash值也变成了0
所以,key为null的元素的hash为0,即使判断要进行扩容,重新hash的时候,因为key为null,索引也是为0的,因为bucketIndex为0,所以是存储在底层table数组里面的第一个项里面
get方法
接下来看看get方法是如何执行的
步骤如下
-
先判断key是否为null
-
如果为null就要在执行getForNullKey方法
-
如果Key不为null,执行getEntry方法
-
然后判断是否找到
-
通过比对获得的entry是否为Null来判断
-
如果为null就返回null
-
如果不为null,就返回entry的value属性
getEntry
我们先来看看如果key不为null时执行的getEntry方法
步骤如下
-
先判断HashMap里面的元素数量是否为0,如果为0就直接返回null
-
如果不为0,就要根据给的key计算哈希值
-
如果Key为null,那么对应的哈希值就为0
-
如果key不为null,对应调用实现的hashCode去获取hash值
-
使用indexFor方法获取哈希值的索引(0余上长度-1依然为0,所以不用担心0对索引的影响)
-
遍历底层table数组指定索引的链表,通过hash和key比较
-
首先比较哈希值是否相同
-
如果哈希值相同,再比较key是不是相同
-
先比较key是否是同一个对象,如果是,就代表key相同
-
如果key不是同一个对象,使用equals方法比较key值是否相同,如果是,就表示key相同
-
只要哈希值不同,就不会比较key是否相同
-
如果最后判断hash值相同,同时key也相同,就代表找到那个键值对了,返回键值对的value属性
-
如果遍历完了都还找不到,就返回Null
getForNullKey
接下来我们看看如果key为Null时会怎样
key为Null时,调用的是getForNullKey
可以看到,这个方法相比于getEntry没有那么复杂
因为key为null时只需遍历底层table数组的第一个项的链表即可。
注意这里返回的是遇到的第一个key为null的键值对,因为采用头插法,也就是最新插入的一个key为null的键值对
remove方法
最后我们看下删除方法
步骤如下
-
调用removeEntryForKey的方法
-
判断返回的entry是不是为空
-
如果为空,返回null
-
如果不为空,返回键值对的value属性
removeEntryForKey方法
源码如下
final Entry<K,V> removeEntryForKey(Object key) {
//判断底层数组元素数量是否为0
//如果为0直接返回null
if (size == 0) {
return null;
}
//计算hash值
int hash = (key == null) ? 0 : hash(key);
//根据hash值计算索引值
int i = indexFor(hash, table.length);
//prev记录的是上一个节点
Entry<K,V> prev = table[i];
//用e来记录当前节点
Entry<K,V> e = prev;
//下面对e进行while遍历
while (e != null) {
//next记录当前节点的下一个节点
Entry<K,V> next = e.next;
Object k;
//判断key是否一致的方法是跟前面一样的
//先判断hash值是否一样
//再判断key是否是同一个对象
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
//匹配成功
//记录底层数组的版本,又更新了一次
modCount++;
//让数量减一
size–;
//如果链表第一个就匹配成功
if (prev == e)
//直接断开第一个,让下一个成为数组里面的节点
//也就是下一个成为第一个
table[i] = next;
else
//如果不是第一个
//让上一个的next为当前节点的下一个节点
prev.next = next;
//每次删除都要记录recordRemoval方法
e.recordRemoval(this);
//返回倍删除的节点
return e;
}
prev = e;
e = next;
}
return e;
}
可以看到,删除方法一次只会删除第一个匹配的键值对,删除第一个就直接返回了
看一下recordRemoval干了什么
可以看到,其什么都没干,跟前面的recordAccess一样,都是简单的标记会使用而已
clear方法
clear方法更加简单,步骤如下
总结
这个月马上就又要过去了,还在找工作的小伙伴要做好准备了,小编整理了大厂java程序员面试涉及到的绝大部分面试题及答案,希望能帮助到大家
watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0dEVVRfVHJpbQ==,size_16,color_FFFFFF,t_70#pic_center)
可以看到,其什么都没干,跟前面的recordAccess一样,都是简单的标记会使用而已
clear方法
clear方法更加简单,步骤如下
总结
这个月马上就又要过去了,还在找工作的小伙伴要做好准备了,小编整理了大厂java程序员面试涉及到的绝大部分面试题及答案,希望能帮助到大家
[外链图片转存中…(img-moykyjBJ-1719275488284)]
[外链图片转存中…(img-YiTIQMjT-1719275488285)]