当前位置 博文首页 > 文章内容

    面试官:小伙子,你能给我说一下HashMap的实现原理吗?

    作者: 栏目:未分类 时间:2020-07-27 14:02:39

    本站于2023年9月4日。收到“大连君*****咨询有限公司”通知
    说我们IIS7站长博客,有一篇博文用了他们的图片。
    要求我们给他们一张图片6000元。要不然法院告我们

    为避免不必要的麻烦,IIS7站长博客,全站内容图片下架、并积极应诉
    博文内容全部不再显示,请需要相关资讯的站长朋友到必应搜索。谢谢!

    另祝:版权碰瓷诈骗团伙,早日弃暗投明。

    相关新闻:借版权之名、行诈骗之实,周某因犯诈骗罪被判处有期徒刑十一年六个月

    叹!百花齐放的时代,渐行渐远!



    1. HashMap概述:

    HashMap是基于哈希表的Map接口的非同步实现(他与Hashtable类似,但Hashtable是线程安全的,所以是同步的实现),此实现提供可选的映射操作,允许使用null值和null键,但他并非有序。

    2. HashMap数据结构与实现原理:

    在jdk1.7和jdk1.8中,HashMap的数据结构是有所差别的,进行一些优化来解决冲突问题,下面我们就分别从两个版本的角度来分析一下他的改动与区别

    (一)jdk1.7版本

    在jdk1.7版本的时候采用的是数组+链表的形式,也就是采用了拉链法。

     

     

    将哈希冲突值加入到每个数组的链表中,他的插入采用的是头插法的形式(这种方法最大的弊端就是会使插入值产生环,从而无限循环,后面我们将详细讲解这种方法的弊端操作),在进行hash值计算的时候,jdk1.7则采用的是9次扰动(4次位运算+5次异或运算)的方式进行处理(因为本人目前暂时用的jdk1.8所以无法利用源码进行讲解,望见谅),除此之外在扩容上也有所不同,在jdk1.7中采用的全部按照原来的方式进行计算(即hashCode ->> 扰动函数 ->> (h&length-1)),而在jdk1.8中则采用按照扩容后的规律计算(即扩容后的位置=原位置 or 原位置 + 旧容量),下面让我们来详细讲解一下jdk1.8。

    (二)jdk1.8版本

    而jdk1.8的版本则采用数组+链表+红黑树的方式,如下图:

     

     

    这种方法可以大大优化了哈希冲突的问题,减少了搜索时间,当添加的数目达到阈值的时候可以将链表转换为红黑树的形式,而当红黑树的节点小于6的时候就会从红黑树转化为链表的形式(如果不理解红黑树的话,可以参考我的上一篇文章),而在进行插入值的时候则采用的是尾插法的形式,这种方法解决了成环的情况发生

     final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
            Node<K,V>[] tab; Node<K,V> p; int n, i;
            if ((tab = table) == null || (n = tab.length) == 0)
                n = (tab = resize()).length;
            if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
            else {
                Node<K,V> e; K k;
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    e = p;
                else if (p instanceof TreeNode)
                    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                else {
                    for (int binCount = 0; ; ++binCount) {
                        if ((e = p.next) == null) {
                            p.next = newNode(hash, key, value, null);
                            if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                                treeifyBin(tab, hash);
                            break;
                        }
                        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;
        }
    

      

    上面代码便是对数组进行put操作时,节点的判断以及添加,里面有几个核心的方法让我们一个一个来分析:

    resize():

    这个方法顾名思义------扩容,他在jdk1.8的时候进行两次调用,第一次是在数组进行初始化的时候对其进行扩容,而第二次则是在数组满的时间进行的扩容,一次是开始,一次则是快结束的时候,让我们点进去来详细查看一下他在内部所作的操作及含义:

     final Node<K,V>[] resize() {
            Node<K,V>[] oldTab = table; //定义老数组
            int oldCap = (oldTab == null) ? 0 : oldTab.length;  //判断老数组是否为空,来实现初始化扩容操作
            int oldThr = threshold; //使初始容量暂时为创建数组时产生的容量,默认为16
            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; // 双重容量,也可以理解为进行扩容
            }
            else if (oldThr > 0) // 初始容量处于阈值
                newCap = oldThr;
            else {               //零初始阈值表示使用默认值
                newCap = DEFAULT_INITIAL_CAPACITY;
                newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
            }
            if (newThr == 0) {
                float ft = (float)newCap * loadFactor;  //进行与加载因子的乘积,此时表示在自动扩容之前,数组达到多满的一种度量,当超过这个乘积值的时候才进行扩容,加载因子的默认值为0.75
                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;
        }
    

      

    从中我们可以看出,扩容方法对数组初始化以及为空都进行了详细的判断,许多人看到这里都会问为什么加载因子的默认值为0.75呢?那么这个问题我们会在接下来的面试典型题中进行简单的解释。让我们回到上面的put方法中继续分析。

    hash(key)

    这个方法也是我们的重点,他是为了保证充分利用数组的每个位置(下标)并大大解决哈希冲突问题而诞生的,在jdk1.7中我们提到了计算hash的时候进行了9次扰动,而在jdk1.8中我们仅仅使用了2次扰动就进行了hash值的计算,

      static final int hash(Object key) {
            int h;
            return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
        }
    

      

    从上面代码可以看出他是通过key.hashCode()进行高16位和低16位进行了一次异或运算得到的,减少了hash的碰撞问题。
    两个主要的方法分析完了,让我们来整体描述一下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],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

    ⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
    至此我们的讲解完毕,下面来讲述一下面试过程中所遇到的一些面试题。

    面试过程中的典型问题

    1.为什么采用hashcode的高16位和低16位异或能降低hash碰撞?hash函数能不能直接用key的hashcode?

    因为key.hashCode调用的是key键值类型自带的哈希函数,他返回的是一个int类型的散列值,我们都知道int类型的-2147483648~2147483647**大约有40亿的空间,而在我们的数组中容量仅仅为16个,所以就会造成数组无法承载值的情况,因为必须是均匀分布才能有效地避免哈希碰撞的问题,所以就要对其进行取模运算。

    2.HashMap默认加载因子为什么选择0.75?

    因为提高空间利用率和 减少查询成本的折中,主要是泊松分布,0.75的话碰撞最小,因为在hashmap中影响性能的因为有俩个,一个是初始容量,一个便是加载因子,如果加载因子小的话就会造成空间利用率低,并提高了rehash的次数,为了保证最大程度上减少rehash的次数,0.75是最适宜的标准数也是折中的一种考虑。

    3.jdk1.7插入数据方式有什么弊端?

    上面我提到了形成环,那么到底是为什么呢,因为在使用的过程中,如果一个线程在插入一个节点的时候,另一个线程也在插入节点,而且两个线程插入的都是对方线程上的几点,这样当进行头插的时候会使链表发生反转,便形成了环状,造成了死循环,如下图:

     

     

    最后

    感谢你看到这里,看完有什么的不懂的可以在评论区问我,觉得文章对你有帮助的话记得给我点个赞,每天都会分享java相关技术文章或行业资讯,欢迎大家关注和转发文章!