Go最全HashMap实现原理, 扩容机制,面试题和总结_hashmap扩容机制面试,Golang开发面试基础

img
img

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

        // 在链表最末插入结点(尾插法)
        for (int binCount = 0; ; ++binCount) {
            // 到达链表的尾部
            if ((e = p.next) == null) {
                // 在尾部插入新结点
                p.next = newNode(hash, key, value, null);
                // 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法
                // 这个treeifyBin()方法会根据 HashMap 数组情况来决定是否转换为红黑树。
                // 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少执行效率。否则,就是只是对数组扩容。
                if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
				// 树化操作
                    treeifyBin(tab, hash);
                // 跳出循环 此时e=null,表示没有在链表中找到与插入元素key和hash值相同的节点
                break;
            }
            // 判断链表中结点的key值和Hash值与插入的元素的key值和Hash值是否相等
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                // 若相等,则不用将其插入了,直接跳出循环
                break;
            // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
            p = e;
        }
    }
    // 当e!=null时,表示在数组桶或链表或红黑树中存在key值、hash值与插入元素相等的结点。此时就直接用原有的节点就可以了,不用插入新的元素了。此时e就代表原本就存在于HashMap中的元素
    if (e != null) {
        // 记录e的value,也就是旧value值
        V oldValue = e.value;
        // onlyIfAbsent为false或者旧值为null,则需要用新的value值对旧value值进行覆盖
        if (!onlyIfAbsent || oldValue == null)
            //用新值替换旧值
            e.value = value;
        // 替换旧值时会调用的方法(默认实现为空)
        afterNodeAccess(e);
        // 返回旧值
        return oldValue;
    }
}
// 结构性修改,记录HashMap被修改的次数,主要用于多线程并发时候
++modCount;
// 实际大小大于阈值则扩容 ++size只有在插入新元素才会执行,如果发现HashMap中已经存在了相同key和hash的元素,就不会插入新的元素,在上面就已经执行return了,也就不会改变size大小
if (++size > threshold)
    resize();
// 插入成功时会调用的方法(默认实现为空)
afterNodeInsertion(evict);
// 没有找到原有相同key和hash的元素,则直接返回Null
return null;

}


HashMap是懒加载,只有在第一次put时才会创建数组。  
 **总结**  
 ①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;  
 ②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;  
 ③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;  
 ④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值  
 对,否则转向⑤;  
 ⑤.遍历table[i],并记录遍历长度,如果遍历过程中发现key值相同的,则直接覆盖value,没有相同的key则在链表尾部插入结点,插入后判断该链表长度是否大等于8,大等于则考虑树化,如果数组的元素个数小于64,则只是将数组resize,大等于才树化该链表;  
 ⑥.插入成功后,判断数组中的键值对数量size是否超过了阈值threshold,如果超过,进行扩容。


#### 7.HashMap 的 get 方法的具体流程?



public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

//Node数组不为空,数组长度大于0,数组对应下标的Node不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
    //也是通过 hash & (length - 1) 来替代 hash % length 的
    (first = tab[(n - 1) & hash]) != null) {
    
    //先和第一个结点比,hash值相等且key不为空,key的第一个结点的key的对象地址和值均相等
    //则返回第一个结点
    if (first.hash == hash && // always check first node
        ((k = first.key) == key || (key != null && key.equals(k))))
        return first;
    //如果key和第一个结点不匹配,则看.next是否为空,不为null则继续,为空则返回null
    if ((e = first.next) != null) {
        //如果此时是红黑树的结构,则进行处理getTreeNode()方法搜索key
        if (first instanceof TreeNode)
            return ((TreeNode<K,V>)first).getTreeNode(hash, key);
        //是链表结构的话就一个一个遍历,直到找到key对应的结点,
        //或者e的下一个结点为null退出循环
        do {
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        } while ((e = e.next) != null);
    }
}
return null;

}


**总结**


1. 首先根据 hash 方法获取到 key 的 hash 值
2. 然后通过 hash & (length - 1) 的方式获取到 key 所对应的Node数组下标 ( length对应数组长度 )
3. 首先判断此结点是否为空,是否就是要找的值,是则返回空,否则判断第二个结点是否为空,是则返回空,不是则判断此时数据结构是链表还是红黑树
4. 链表结构进行顺序遍历查找操作,每次用 == 符号 和 equals( ) 方法来判断 key 是否相同,满足条件则直接返回该结点。链表遍历完都没有找到则返回空。
5. 红黑树结构执行相应的 getTreeNode( ) 查找操作。


#### 8.HashMap的扩容操作是怎么实现的?


不管是JDK1.7或者JDK1.8 当put方法执行的时候,如果table为空,则执行resize()方法扩容。默认长度为16。


##### JDK1.7扩容


**条件**:发生扩容的条件必须同时满足两点


1. 当前存储的数量大于等于阈值
2. 发生hash碰撞



> 
> 因为上面这两个条件,所以存在下面这些情况
> 
> 
> 1. 就是hashmap在存值的时候(默认大小为16,负载因子0.75,阈值12),可能达到最后存满16个值的时候,再存入第17个值才会发生扩容现象,因为前16个值,每个值在底层数组中分别占据一个位置,并没有发生hash碰撞。
> 2. 当然也有可能存储更多值(超多16个值,最多可以存26个值)都还没有扩容。原理:前11个值全部hash碰撞,存到数组的同一个位置(这时元素个数小于阈值12,不会扩容),后面所有存入的15个值全部分散到数组剩下的15个位置(这时元素个数大于等于阈值,但是每次存入的元素并没有发生hash碰撞,所以不会扩容),前面11+15=26,所以在存入第27个值的时候才同时满足上面两个条件,这时候才会发生扩容现象。
> 
> 
> 


**特点**:先扩容,再添加(扩容使用的头插法)


**缺点**:头插法会使链表发生反转,多线程环境下可能会死循环


**扩容之后对table的调整:**


table容量变为2倍,所有的元素下标需要重新计算,newIndex = hash (扰动后) & (newLength - 1)


##### JDK1.8扩容


**条件**:


1. 当前存储的数量大于等于阈值
2. 当某个链表长度>=8,但是数组存储的结点数size() < 64时


**特点**:先插后判断是否需要扩容(扩容时是尾插法)


**缺点**:多线程下,1.8会有数据覆盖



> 
> 举例:  
>  线程A:往index插,index此时为空,可以插入,但是此时线程A被挂起  
>  线程B:此时,对index写入数据,A恢复后,就把B数据覆盖了
> 
> 
> 


**扩容之后对table的调整:**


table容量变为2倍,但是不需要像之前一样计算下标,只需要将hash值和旧数组长度相与即可确定位置。


1. 如果 Node 桶的数据结构是链表会生成 low 和 high 两条链表,是红黑树则生成 low 和 high 两颗红黑树
2. 依靠 (hash & oldCap) == 0 判断 Node 中的每个结点归属于 low 还是 high。
3. 把 low 插入到 新数组中 当前数组下标的位置,把 high 链表插入到 新数组中 [当前数组下标 + 旧数组长度] 的位置
4. 如果生成的 low,high 树中元素个数小于等于6退化成链表再插入到新数组的相应下标的位置


#### 9.HashMap 在扩容时为什么通过位运算 (e.hash & oldCap) 得到下标?


从下图中我们可以看出,计算下标通过(n - 1) & hash,旧table的长度为16,hash值只与低四位有关,扩容后,table长度为32(两倍),此时只与低五位有关。


所以此时后几位的结果相同,前后两者之间的差别就差在了第五位上。


同时,扩容的时候会有 low 和 high 两条链表或红黑树来记录原来下标的数据和原来下标 + 旧table下标的数据。  
 ![在这里插入图片描述](https://img-blog.csdnimg.cn/4c495905cac948bcba0b66fac4347a6c.png)  
 如果第五位 **b** 是 0,那么只要看低四位 (也就是原来的下标);如果第五位是 1,只要把低四位的二进制数 + `1 0 0 0 0` ,就可以得到新数组下标。前面的部分刚好是原来的下标,后一部分就是旧table的长度 。那么我们就得出来了为什么把 low 插入扩容后 新数组[原来坐标] 的位置,把 high 插入扩容后 新数组[当前坐标 + 旧数组长度] 的位置。


那为什么根据 (e.hash & oldCap) == 0 来做判断条件呢?是因为旧数组的长度 length 的二进制数的第五位刚好是 1,hash & length 就可以计算 hash 值的第五位是 0 还是 1,就可以区别是在哪个位置上。


#### 10.链表升级成红黑树的条件


链表长度大于8时才会考虑升级成红黑树,是有一个条件是 HashMap 的 Node 数组长度大于等于64(不满足则会进行一次扩容替代升级)。


#### 11.红黑树退化成链表的条件


1. 扩容 resize( ) 时,红黑树拆分成的 树的结点数小于等于临界值6个,则退化成链表。
2. 删除元素 remove( ) 时,在 removeTreeNode( ) 方法会检查红黑树是否满足退化条件,与结点数无关。如果红黑树根 root 为空,或者 root 的左子树/右子树为空,root.left.left 根的左子树的左子树为空,都会发生红黑树退化成链表。


#### 12.HashMap是怎么解决哈希冲突的?


1. 使用链地址法(使用散列表)来链接拥有相同下标的数据;
2. 使用2次扰动函数(hash函数)来降低哈希冲突的概率,使得数据分布更平均;
3. 引入红黑树进一步降低遍历的时间复杂度,使得遍历更快;


#### 13.HaspMap的初始化时数组长度和加载因子的约束范围



public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}


可以看到如果初始化数组长度 initialCapacity 小于 0 的话会跑出 IllegalArgumentException 的异常,initialCapacity 大于 MAXIMUM\_CAPACITY 即 2 的 30 次幂的时候最大长度也只会固定在 MAXIMUM\_CAPACITY ,在扩容的时候,如果数组的长度大等于MAXIMUM\_CAPACITY,会将阈值设置为Integer.MAX\_VALUE。


加载因子小于等于0时,或者加载因子是NaN时 (NaN 实际上就是 Not a Number的简称) 会抛出 IllegalArgumentException 的异常。






![img](https://img-blog.csdnimg.cn/img_convert/04083bc905369ee6383d1679d5aa4c67.png)
![img](https://img-blog.csdnimg.cn/img_convert/eca7e10c1f65b240e4b4603a1992a7ae.png)

**网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。**

**[需要这份系统化的资料的朋友,可以添加戳这里获取](https://bbs.csdn.net/topics/618658159)**


**一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!**

片转存中...(img-lMTqsa74-1715815942840)]

**网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。**

**[需要这份系统化的资料的朋友,可以添加戳这里获取](https://bbs.csdn.net/topics/618658159)**


**一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!**

  • 4
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值